Compare commits

...

160 Commits

Author SHA1 Message Date
Bram Kragten
d98e373f64 Bumped version to 20260128.6 2026-02-04 15:41:09 +01:00
Paul Bottein
649516c9fa Change default icon for blank area if not icon configured (#29394) 2026-02-04 15:40:36 +01:00
Paul Bottein
bbc4fb96b2 Load domain translation when integration page load (#29391) 2026-02-04 15:40:35 +01:00
Paul Bottein
0ae639aeb0 Remove old lovelace overview from pickers (#29390) 2026-02-04 15:40:34 +01:00
karwosts
0e7e41065e Don't shrink ha-dropdown checkboxes (#29387) 2026-02-04 15:40:33 +01:00
Paul Bottein
685843f112 Add translations for new overview dialog (#29382) 2026-02-04 15:40:32 +01:00
Paul Bottein
5e1a99d94a Use area icon for area empty state (#29371) 2026-02-04 15:40:31 +01:00
Bram Kragten
d843349865 Bumped version to 20260128.5 2026-02-03 16:58:01 +01:00
Paul Bottein
ec23164aa9 Improve other devices page in home dashboard (#29370) 2026-02-03 16:57:45 +01:00
Paul Bottein
e74ef11101 Hide edit and delete actions for YAML dashboards in config (#29368)
YAML dashboards are defined in configuration files and cannot be
modified or deleted through the UI. This change ensures the edit
and delete actions are only shown for storage-mode dashboards.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:57:43 +01:00
Paul Bottein
a222f6a736 Add missing danger variant in dropdown item (#29359) 2026-02-03 16:57:40 +01:00
Petar Petrov
ef3dd16d45 Move dialog scrim to pseudo-element (#29357) 2026-02-03 16:57:39 +01:00
karwosts
5d4e1d205e Fix missing imports in devtools-statistics (#29355) 2026-02-03 16:57:38 +01:00
Darryn Capes-Davis
1ee5ebbe75 Fix CSS minification issue for ha-card (#29354) 2026-02-03 16:57:37 +01:00
Bram Kragten
59d705aa3d Bumped version to 20260128.4 2026-02-02 17:17:24 +01:00
Paul Bottein
332e108dae Fix "Reload resources" menu for YAML resource mode (#29346) 2026-02-02 17:17:17 +01:00
karwosts
3c15b29d0a Entity diagnostic - handle entity not in the registry (#29344) 2026-02-02 17:17:16 +01:00
Wendelin
130c708e23 Fix dropdown width in datatables (#29340) 2026-02-02 17:17:15 +01:00
Paul Bottein
588a14a8a7 Fix type error for missing hass.themes race condition in themes mixin (#29338) 2026-02-02 17:17:14 +01:00
Petar Petrov
a1ef6ad266 Remove redundant dialog backdrop color (#29337) 2026-02-02 17:17:13 +01:00
Aidan Timson
a6c1f87730 Ensure template renderer overflows on overflow (#29335) 2026-02-02 17:17:12 +01:00
Wendelin
49252a3808 Fix missing ha-md-menu in config/labels (#29334) 2026-02-02 17:17:11 +01:00
Aidan Timson
c7877fe38f Show hint only if keyboard shortcuts is enabled (#29332)
Enabled by default, must be explicity disabled
2026-02-02 17:17:10 +01:00
Wendelin
e355a61d8f Revert "Fix automation sidebar ui supported check" (#29331) 2026-02-02 17:17:08 +01:00
Linus Rath
f2e19e51ce Update untracked consumption threshold to 1W (#29310) 2026-02-02 17:17:07 +01:00
karwosts
fd9ab8f561 Use ha-form for condition template (#29301) 2026-02-02 17:17:06 +01:00
Kristel
faa1b3c98f bugfix: add eventlistener for exposed-entities-changed to Entities page (#29299) 2026-02-02 17:17:06 +01:00
Aidan Timson
acc4a84fc9 Fix scrolling for labs page (#29287) 2026-02-02 17:17:05 +01:00
karwosts
4d723dac37 Fix areas cannot be deleted (#29285) 2026-02-02 17:17:03 +01:00
Aidan Timson
f1d4d0ef98 Fix type error for missing hass.config race condition in themes mixin (#29280) 2026-02-02 17:17:02 +01:00
Paul Bottein
88180a2708 Fix demo because of new default panel (#29279) 2026-02-02 17:17:01 +01:00
Aidan Timson
258d87e3d5 Add missing settings nav items for quick search (#29278)
* Add missing repairs quick search item

* Add voice assistants
2026-02-02 17:17:00 +01:00
Wendelin
55f22ba61a Implement fallback for dialog close event in Quick Search (#29260) 2026-02-02 17:16:59 +01:00
Aidan Timson
812f3ca8b9 Change default shortcut tip in Quick Search to mod+k, add tip to settings (#29253) 2026-02-02 17:16:58 +01:00
Marcin Bauer
7f880d11a0 Keep focus on search field when clicking filter chips (#29249)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:16:57 +01:00
Bram Kragten
6b2452c538 Update compress.js 2026-02-02 17:15:44 +01:00
Paul Bottein
c2cbf8bd21 Bumped version to 20260128.3 2026-01-30 10:31:26 +01:00
Wendelin
224bcece9c Fix multi select in quick search (#29272)
Add item selection state management to QuickBar component
2026-01-30 10:30:33 +01:00
Wendelin
dc84b7698f Fix --wa-color-text-normal (#29271)
Update normal text color variable in wa.globals.ts
2026-01-30 10:30:32 +01:00
Wendelin
bc22e6a9bd Fix device download diagnostic via overflow (#29269)
fix diagnostic download link handling to simplify URL signing
2026-01-30 10:30:31 +01:00
Simon Lamon
d44874783a Remove duplicated text (#29265) 2026-01-30 10:30:30 +01:00
Paul Bottein
8d1bb5c867 Fix default lovelace yaml loading (#29240) 2026-01-30 10:30:29 +01:00
Bram Kragten
da1b528eee Bumped version to 20260128.2 2026-01-29 18:04:42 +01:00
Paul Bottein
756138408a Remove default title for new dashboards (#29259) 2026-01-29 18:04:09 +01:00
Paul Bottein
3c8f112565 Prevent action in tile container (#29257) 2026-01-29 18:04:08 +01:00
Paul Bottein
2521f3dde4 Fix actions in dashboard overflow menu (#29256) 2026-01-29 18:04:07 +01:00
TheJulianJES
56390aa01a Fix Matter dashboard using disabled and ignored config entries (#29254) 2026-01-29 17:52:32 +01:00
Paul Bottein
9aac5b19da Stop click propagation when clicking item in icon overflow (#29252) 2026-01-29 17:52:31 +01:00
Wendelin
24afc3dc88 Prevent quick search to close from hot keys (#29251) 2026-01-29 17:52:30 +01:00
Paul Bottein
873c7b2947 Remove unused theme option in distribution card (#29250) 2026-01-29 17:52:29 +01:00
Aidan Timson
648db4276b Add protocols to quick search (#29248)
Add protocols to quick search, extract logic and translations
2026-01-29 17:52:28 +01:00
Aidan Timson
f86c3e7856 Remove unused "app" item from quick search (#29244) 2026-01-29 17:52:27 +01:00
Aidan Timson
1d0251cc28 Fixes for picker combo box scrolling and selection (#29242) 2026-01-29 17:52:26 +01:00
Wendelin
518cf87847 Fix quick search apps (#29238) 2026-01-29 17:52:25 +01:00
ildar170975
81a9216c44 computeGroupEntitiesState(): fix condition (#29234)
* fix condition

* fix condition

* prettier
2026-01-29 17:52:24 +01:00
Paul Bottein
f0e10e0058 Fix default yaml lovelace panel loading (#29230) 2026-01-29 17:52:23 +01:00
Paul Bottein
5df8ea4f07 Add welcome banner for new overview dashboard (#29223) 2026-01-29 17:52:22 +01:00
Aidan Timson
73f081f5cc Add meta+click/enter support to quick search (#29220)
* Allow meta+click event from combobox

* Handle new tab events for navigations

* Add mod+enter support for new tab

* Helper function
2026-01-29 17:52:21 +01:00
Petar Petrov
f0d1db1da6 Add non standard power sensor support (#28845)
* Add non standard power sensor support

* remove useless code

* GridPowerSourceInput type for grid power source saving
2026-01-29 17:52:20 +01:00
Bram Kragten
c658eb414b Bumped version to 20260128.1 2026-01-28 17:52:10 +01:00
Bram Kragten
bac493b72b dont include brotli compression 2026-01-28 17:50:19 +01:00
Bram Kragten
922e8c7752 Merge branch 'rc' into dev 2026-01-28 16:43:29 +01:00
Bram Kragten
e63301cd9c Bumped version to 20260128.0 2026-01-28 16:42:45 +01:00
Aidan Timson
29a3d67e48 AI suggestions: Areas (#29090) 2026-01-28 16:35:24 +01:00
Bram Kragten
4c98a7791b Move developer tools panel to config panel (#29221) 2026-01-28 15:31:47 +00:00
Bram Kragten
5a76c3f606 Make addon selector an alias of app selector (#29222) 2026-01-28 15:27:30 +00:00
Steven Travers
251a4ce5ce Add device database labs feature (#29104)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-01-28 14:09:17 +01:00
karwosts
408735fa77 Live inline template previews (#27557)
* Live inline template previews

* Opt out for markdown, no fullscreen

* flask/flaskOff

* styling

* mdiBug

* Update src/components/ha-selector/ha-selector-template.ts

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

* Apply suggestions from code review

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

* resub on connect

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-28 11:57:41 +02:00
Wendelin
c0442b5b39 Fix automation sidebar ui supported check (#29219) 2026-01-28 10:52:12 +01:00
ildar170975
c6284987fd ha-filter-domains: set a plural caption (#29153)
* add "domains" / "caption"

* fix caption
2026-01-28 06:38:33 +01:00
Bram Kragten
ed618124dc Fix double action handler area card (#29217) 2026-01-28 06:37:48 +01:00
Paul Bottein
3e350b7642 Attempt to make icon fetching and caching more reliable (#29195) 2026-01-27 21:34:19 +01:00
Paul Bottein
c66b4e2027 Update top bar title margin on config and dashboard page (#29212) 2026-01-27 21:32:43 +01:00
Paul Bottein
4c25c639af Don't show back button when opening the add integration sub page directly (#29213) 2026-01-27 21:31:59 +01:00
Paul Bottein
0fbde5024e Fix back button in energy panel (#29214) 2026-01-27 21:31:05 +01:00
Paul Bottein
b991a8122b Set home as default dashboard (#28446)
* Set home as default dashboard

* Handle lovelace to home redirect

* Remove special url path for lovelace

* Rename special rules for lovelace dashboard

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

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Handle default config

* Fix default section

* Early check for lovelace panel

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-01-27 17:08:19 +01:00
Jan Čermák
c2c4e06915 Add AppSelector following the Add-ons->Apps rename (#29209) 2026-01-27 15:19:51 +00:00
Aidan Timson
91c12605d3 Add timezone selector (#29205)
* Add timezone selector

* Use timezone selector in clock card
2026-01-27 17:10:39 +02:00
Wendelin
cddf91cfd0 Replace ha-md-button-menu with ha-dropdown (#29210) 2026-01-27 15:10:32 +00:00
Paul Bottein
6e1999ceb7 Update top bar background color with the same color as config panel (#29208) 2026-01-27 17:01:15 +02:00
Marcin Bauer
3b571d42fa Add find and replace button to code editor toolbar (#28980) 2026-01-27 14:53:50 +00:00
Marcin Bauer
08ee742233 Move entity attributes to three-dots menu subview (#29186)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 14:07:23 +00:00
Wendelin
b3cc88e124 Fix PickerComboBox to not overwrite local selected section (#29206) 2026-01-27 13:41:45 +00:00
Paul Bottein
9fe9456f3c Add link to manage discovered devices in add integration dialog (#29188)
* Add link to manage discovered devices in add integration dialog

* Prettier

* Update src/panels/config/integrations/dialog-add-integration.ts

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-27 14:04:33 +01:00
Wendelin
6d1d7690ef Refactor dropdown menus to use ha-dropdown and ha-dropdown-item components (#29204) 2026-01-27 12:36:53 +00:00
Aidan Timson
4a2b7324f7 Fix position of tooltips in sidebar (#29203) 2026-01-27 12:17:15 +00:00
Aidan Timson
15b85d6f19 Move developer tools to settings (admin) area (#29201) 2026-01-27 11:13:09 +00:00
ildar170975
c49115a91e ha-sidebar: exclude some items from scrollable + fade + various fixes (#28747)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-01-27 09:55:58 +00:00
Aidan Timson
efc67a30f3 Migrate currency picker to generic (#29193)
* Migrate currency picker to generic

* Pass required pro
2026-01-27 11:54:28 +02:00
Aidan Timson
bf41b3f7e3 Migrate country picker to generic picker (#29190)
* Migrate country picker to generic picker

* Add country code as secondary text

* Pass required prop

* Remove
2026-01-27 11:08:54 +02:00
Tomasz
30eb50a962 Add color setting for calendar entities (#28882)
* Enhance calendar entity options with color support and update UI components for color selection

* Add loading spinner to calendar components and improve event loading state management

* simplify

* Remove redundant color change check in HuiCalendarCard update logic

* Add color validation utility and update calendar components to use it. color need to be hex strings

* Adds logic to reset the _eventsLoaded state to false when either the card configuration or the entity registry changes, ensuring events are reloaded appropriately.

* remove casting

* Use SubscribeMixin for entity registry subscription

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-27 08:54:41 +00:00
Aidan Timson
567e8c51d0 Migrate timezone picker to generic (#29192)
* Migrate timezone picker to generic

* Pass required prop, remove query
2026-01-27 08:29:50 +02:00
renovate[bot]
e214c79cd5 Update dependency vite-tsconfig-paths to v6.0.5 (#29198)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 08:24:18 +02:00
renovate[bot]
c0cae1cead Update dependency @babel/helper-define-polyfill-provider to v0.6.6 (#29197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-27 08:23:54 +02:00
Petar Petrov
22742eec84 Group small power consumers into Other node instead of hiding (#29185) 2026-01-26 17:05:41 +02:00
Aidan Timson
37d8273e7c Navigation picker: add sections/grouping and related nav paths (#29094)
* Add sections to navigation picker

* Use PANEL_DASHBOARDS to assign system dashboards to the dashboards section

* Clean

* Add context based related section

* Add integration icon for related device

* Add floor and sort

* Consolidate and cleanup

* Reuse type

* Add context check and catch findRelated errors

* Remove floor from set, use area

* Memoize related updates

* Log error

* Remove

* Fix icon path usage
2026-01-26 15:10:09 +02:00
AlCalzone
9ba34869be Display Z-Wave home ID as hexadecimal (#29187) 2026-01-26 11:58:09 +00:00
Petar Petrov
63284b328c Support app param in my links (#29142) 2026-01-26 08:55:42 +00:00
Simon Lamon
9bb9ae6ad6 Fix tooltip (#29169) 2026-01-26 08:44:45 +00:00
Petar Petrov
0377bf378d Fix sizing of the period selector in energy dashboard (#29183) 2026-01-26 08:42:03 +00:00
Kristel
7e5ecf4007 bugfix: correct assistant column and filtering for entities without unique id (#29039)
* correct assistant column and filtering for entities without unique id

* _fetchExposedEntities with additional guard

* refactor _getExposedEntitySettingsAsOptions for better code readability

* processed review comments

* move _fetchExposedEntities to firstUpdated

* resolve merge conflicts with #29137

* Update src/panels/config/entities/ha-config-entities.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-26 07:27:11 +00:00
renovate[bot]
e17055bef0 Update dependency globals to v17.1.0 (#29176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 09:01:06 +02:00
renovate[bot]
38f64b0e93 Update vitest monorepo to v4.0.18 (#29175)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 09:00:43 +02:00
dependabot[bot]
4ea207d74a Bump release-drafter/release-drafter from 6.1.0 to 6.2.0 (#29180)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](b1476f6e6e...6db134d15f)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 6.2.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-01-26 07:27:55 +01:00
dependabot[bot]
04b0db35f6 Bump actions/setup-python from 6.1.0 to 6.2.0 (#29182)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](83679a892e...a309ff8b42)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: 6.2.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-01-26 07:27:39 +01:00
dependabot[bot]
0d22b88f27 Bump github/codeql-action from 4.31.10 to 4.31.11 (#29181)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.31.10 to 4.31.11.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](cdefb33c0f...19b2f06db2)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.31.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 07:27:20 +01:00
dependabot[bot]
ddf209bd8d Bump actions/checkout from 6.0.1 to 6.0.2 (#29179)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.1 to 6.0.2.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](8e8c483db8...de0fac2e45)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-26 07:26:51 +01:00
renovate[bot]
ce5c1d2a9f Update dependency @rspack/dev-server to v1.2.1 (#29178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-26 07:26:37 +01:00
Bram Kragten
956dbb5346 Merge branch 'rc' 2026-01-16 13:36:43 +01:00
Bram Kragten
2b7cd8fe3a Bumped version to 20260107.2 2026-01-16 13:36:27 +01:00
Wendelin
dbff31a281 Automation add TCA: fix: prevent multiple dialog closures by tracking closing state (#28978) 2026-01-16 13:36:12 +01:00
Petar Petrov
6572200e8b Respect user-configured grid options for fixed_rows/fixed_columns cards (#28961) 2026-01-16 13:36:11 +01:00
Wendelin
1465515fb8 Fix category-picker unknown check (#28957) 2026-01-16 13:36:09 +01:00
Petar Petrov
ddaba99f64 Sanitize names in history card and map card (#28947) 2026-01-16 13:36:08 +01:00
karwosts
1c73bc6f6c Update energy summary visibility condition (#28913)
* Update energy summary visibility condition

* add grid power as special case

* Always show summary when you have powersource
2026-01-16 13:36:07 +01:00
SmartCoder
44870cb3eb Fixed modal visibility issue in settings -> areas -> edit room (#28907)
* Fixed modal visibility issue in settings -> areas -> edit room

* converting both components to use ha-wa-dialog

* removed z-index from ha-wa-dialog

* fixed hardcoded .open in media browser dialog and remove unnecessary z-index CSS variables
2026-01-16 13:36:06 +01:00
Brendan Annable
481a90352b Fix timer restore bug (#28898) 2026-01-16 13:36:05 +01:00
Simon Lamon
9b536b2172 Remove twine and introduce trusted publishing (#27110)
* Remove twine and introduce trusted publishing

* Update release.yaml
2026-01-16 13:36:04 +01:00
Bram Kragten
78d41dfd55 Merge branch 'rc' 2026-01-09 23:28:00 +01:00
Bram Kragten
905435db3e Bumped version to 20260107.1 2026-01-09 23:26:47 +01:00
Yosi Levy
ea73fd3f01 Fix for volume scroll in media player (#28891) 2026-01-09 23:26:22 +01:00
Yosi Levy
e519a0203e Arrow fixes in media browser (#28890) 2026-01-09 23:26:21 +01:00
Bram Kragten
d98ee7e0b5 Add support for choose selector to initial form data (#28876)
* Add support for choose selector to initial form data

* Update compute-initial-ha-form-data.ts
2026-01-09 23:26:20 +01:00
Bram Kragten
6fc8c17909 Fix color palette creation (#28867) 2026-01-09 23:26:19 +01:00
dcapslock
201169c3d8 Fix choose selector active_choice when card editor config changes (#28858) 2026-01-09 23:26:18 +01:00
DAccord
303538ac21 Handling empty history (#28852)
Co-authored-by: DAccord <11232265+DAccord@users.noreply.github.com>
2026-01-09 23:26:17 +01:00
Bram Kragten
ac88f3ed0b Merge branch 'rc' 2026-01-07 16:32:25 +01:00
Bram Kragten
3c5a6193d0 Bumped version to 20260107.0 2026-01-07 16:32:09 +01:00
Wendelin
5ee4bd63f8 Fix logs provider picker mobile width (#28847) 2026-01-07 16:31:52 +01:00
Wendelin
b193929bd9 Throttle unknown value checks in ha-generic-picker (#28842) 2026-01-07 16:31:51 +01:00
Paul Bottein
3bee5c8cd4 Remove ha-combo-box-textfield (#28841) 2026-01-07 16:31:50 +01:00
Paul Bottein
976c74b8da Prefill the field with current value when editing a custom text item (#28840) 2026-01-07 16:31:49 +01:00
Wendelin
3a4a13db21 Improve device picker performance (#28835) 2026-01-07 16:31:48 +01:00
Paul Bottein
a2f033dd88 Reduce shadow effect for scrollable fade mixin (#28832) 2026-01-07 16:31:46 +01:00
Bram Kragten
a44b94c8df Prevent showing error during loading of statistics picker (#28823) 2026-01-07 16:31:45 +01:00
Bram Kragten
8796830ff9 Bumped version to 20251229.1 2026-01-06 17:18:45 +01:00
Bram Kragten
bdff13d5e1 Use target selector to filter references entities (#28822)
* Use target selector to filter references entities

* Update ha-selector-state.ts
2026-01-06 17:18:20 +01:00
Bram Kragten
4346484afc Use single path for thread icon, add KNX, simplify (#28819)
* Use single path for thread icon, simplify

* Add custom path for KNX
2026-01-06 17:18:19 +01:00
Bram Kragten
533694391e Remove iOS focus handling from dialogs (#28818) 2026-01-06 17:18:18 +01:00
Bram Kragten
3adba7aa1f Fix translation loading of choose selector (#28817) 2026-01-06 17:18:17 +01:00
karwosts
b60552c025 Fix statistic-graph-card cutoff w/ energy date picker (#28810)
* Fix statistics-graph energy-date mode end-time with 5min statistics

* don't modify date/hour for 5minute graph

* suggestedMax use period instead of days

* go back to string types
2026-01-06 17:18:16 +01:00
Paul Bottein
3011d56101 Show close button when zwave firmware update is finished (#28805) 2026-01-06 17:18:15 +01:00
Aidan Timson
c903c0d734 Add option for any state and show translated label for entity state values (#28803)
* Add option for any state

* Use translated labels for value
2026-01-06 17:18:14 +01:00
Aidan Timson
14be390994 Remove duplicate custom items, remove "no matching ..." when allow-custom-value set (#28801)
* Remove duplicate custom items, allow default from picker

* Memoize

* Memoize

* Memoize func

* Don't show no matching item when custom value is allowed

* Remove no items found label now unused

* Cleanup unused translations

* Restore used value

* Remove no items found label now unused

* Remove redundant comment

* Remove searchFn

* Ensure custom value isnt identical

* Fix duplicated value

* Fix duplicated value

* Use additional items for entity state content

* Fix duplicate values

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-01-06 17:18:12 +01:00
Paul Bottein
d48019a48e Remove custom value for unknown icon in icon picker (#28800) 2026-01-06 17:18:11 +01:00
Copilot
7b5cbb76ef Display template targets with neutral badge instead of "Unknown area" error (#28799)
* Initial plan

* Add template target display with neutral badge

- Import mdiCodeBraces icon and isTemplate function
- Check if target ID is a template before checking if it exists
- Display grey {} icon with "Template" text for templated targets
- Add "template" translation key to target_summary
- Prevents misleading red "Unknown area" badge for template targets

Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
2026-01-06 17:18:10 +01:00
Paul Bottein
c75fab025f Use regular item for bottom padding in combobox (#28798) 2026-01-06 17:18:09 +01:00
karwosts
c007206fa0 Fix statistic names w/ energy_date_selection (#28787) 2026-01-06 17:18:08 +01:00
Norbert Rittel
ab5b5a4276 A few small spelling fixes in user-facing strings (#28786)
- use correct spelling for "Wi-Fi" trademark
- capitalize "PIN" as abbreviation
- fix spelling of "set up" as verb
- fix sentence-casing
2026-01-06 17:18:07 +01:00
Paulus Schoutsen
9eb40f8470 Bluetooth panel to support multi adapter (#28763)
* Support multiple adapters in bluetooth panel

* Move connection allocations up

* Make it tabs

* Add icons

* Revert "Add icons"

This reverts commit e338b6e578.

* Revert "Make it tabs"

This reverts commit d1b19d5c3e.

* Fix scanner matching and no active connection slot support

* Update src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard.ts

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 17:18:06 +01:00
dependabot[bot]
bc827d9bf1 Bump qs from 6.14.0 to 6.14.1 (#28760)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-06 17:18:05 +01:00
Simon Lamon
24f5d58691 Make cancel a secondary action in blueprint import (#28754) 2026-01-06 17:18:04 +01:00
Simon Lamon
13505a9104 Fix matter translations (#28752) 2026-01-06 17:18:03 +01:00
Simon Lamon
c1d135aa16 Revert lit update (#28751) 2026-01-06 17:18:02 +01:00
Paulus Schoutsen
16d13c3202 Verify bluetooth config entries exist before showing entry (#28745) 2026-01-06 17:18:01 +01:00
Paulus Schoutsen
46b3c34ba1 Hide dashboard controls in kiosk mode (#28742) 2026-01-06 17:18:00 +01:00
Paulus Schoutsen
57a81b9de4 Add config entry picker for Z-Wave JS panel (#28741) 2026-01-06 17:17:58 +01:00
Simon Lamon
69f4f1dbed Provide kioskmode in demo (#28739) 2026-01-06 17:17:57 +01:00
Paulus Schoutsen
355a1aff3f Protocol link updates (#28736)
* Update icons Thread & Insteon

* Remove matter link

* Remove back path from ZHA

* Fix ZHA dashboard config entry
2026-01-06 17:17:56 +01:00
Franck Nijhof
3a3036c635 20251229.0 (#28727) 2025-12-29 14:34:15 +01:00
215 changed files with 6526 additions and 3601 deletions

View File

@@ -67,7 +67,7 @@ DO NOT DELETE ANY TEXT from this template! Otherwise, your issue may be closed w
<!--
If your issue is about how an entity is shown in the UI, please add the state
and attributes for all situations with a screenshot of the UI.
You can find this information at `/developer-tools/state`
You can find this information at `/config/developer-tools/state`
-->
```yaml

View File

@@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
@@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/init@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/autobuild@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
uses: github/codeql-action/analyze@19b2f06db2b6f5108140aeb04014ef02b648f789 # v4.31.11

View File

@@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: dev
@@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: master

View File

@@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0

View File

@@ -20,10 +20,10 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: ${{ env.PYTHON_VERSION }}

View File

@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@b1476f6e6eb133afa41ed8589daba6dc69b4d3f5 # v6.1.0
- uses: release-drafter/release-drafter@6db134d15f3909ccc9eefd369f02bd1e9cffdf97 # v6.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -26,10 +26,10 @@ jobs:
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.PYTHON_VERSION }}
@@ -98,7 +98,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Upload Translations
run: |

View File

@@ -9,11 +9,14 @@ import { selectedDemoConfig } from "./configs/demo-configs";
import { mockAreaRegistry } from "./stubs/area_registry";
import { mockAuth } from "./stubs/auth";
import { mockConfigEntries } from "./stubs/config_entries";
import { mockDeviceRegistry } from "./stubs/device_registry";
import { mockEnergy } from "./stubs/energy";
import { energyEntities } from "./stubs/entities";
import { mockEntityRegistry } from "./stubs/entity_registry";
import { mockEvents } from "./stubs/events";
import { mockFloorRegistry } from "./stubs/floor_registry";
import { mockFrontend } from "./stubs/frontend";
import { mockLabelRegistry } from "./stubs/label_registry";
import { mockIcons } from "./stubs/icons";
import { mockHistory } from "./stubs/history";
import { mockLovelace } from "./stubs/lovelace";
@@ -60,6 +63,9 @@ export class HaDemo extends HomeAssistantAppEl {
mockPersistentNotification(hass);
mockConfigEntries(hass);
mockAreaRegistry(hass);
mockDeviceRegistry(hass);
mockFloorRegistry(hass);
mockLabelRegistry(hass);
mockEntityRegistry(hass, [
{
config_entry_id: "co2signal",

View File

@@ -27,4 +27,25 @@ export const mockFrontend = (hass: MockHomeAssistant) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
hass.mockWS(
"frontend/subscribe_system_data",
(_msg, currentHass, onChange) => {
onChange?.({
value: currentHass.systemData,
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
}
);
hass.mockWS("labs/subscribe", (_msg, _currentHass, onChange) => {
onChange?.({
preview_feature: _msg.preview_feature,
domain: _msg.domain,
enabled: false,
is_built_in: true,
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
hass.mockWS("repairs/list_issues", () => ({ issues: [] }));
};

View File

@@ -7,8 +7,18 @@ export const mockTemplate = (hass: MockHomeAssistant) => {
})
);
hass.mockWS("render_template", (msg, _hass, onChange) => {
let result = msg.template;
// Simple variable substitution for demo purposes
if (msg.variables) {
for (const [key, value] of Object.entries(msg.variables)) {
result = result.replace(
new RegExp(`\\{\\{\\s*${key}\\s*\\}\\}`, "g"),
String(value)
);
}
}
onChange!({
result: msg.template,
result,
listeners: { all: false, domains: [], entities: [], time: false },
});
// eslint-disable-next-line @typescript-eslint/no-empty-function

View File

@@ -169,7 +169,7 @@ const SCHEMAS: {
{
title: "Selectors",
translations: {
addon: "App",
app: "App",
entity: "Entity",
device: "Device",
area: "Area",
@@ -188,7 +188,7 @@ const SCHEMAS: {
entities: "Entities",
},
schema: [
{ name: "addon", selector: { addon: {} } },
{ name: "app", selector: { app: {} } },
{ name: "entity", selector: { entity: {} } },
{
name: "Attribute",

View File

@@ -239,7 +239,7 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
addon: { name: "App", selector: { addon: {} } },
app: { name: "App", selector: { app: {} } },
number_box: {
name: "Number Box",
selector: {

View File

@@ -147,7 +147,7 @@
},
"devDependencies": {
"@babel/core": "7.28.6",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/helper-define-polyfill-provider": "0.6.6",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.6",
"@bundle-stats/plugin-webpack-filter": "4.21.8",
@@ -157,7 +157,7 @@
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.0",
"@rspack/core": "1.7.3",
"@rspack/dev-server": "1.1.5",
"@rspack/dev-server": "1.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
"@types/chromecast-caf-sender": "1.0.11",
@@ -176,7 +176,7 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.17",
"@vitest/coverage-v8": "4.0.18",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -216,8 +216,8 @@
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.53.1",
"vite-tsconfig-paths": "6.0.4",
"vitest": "4.0.17",
"vite-tsconfig-paths": "6.0.5",
"vitest": "4.0.18",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -229,7 +229,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"globals": "17.0.0",
"globals": "17.1.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"

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20251229.0"
version = "20260128.6"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"

View File

@@ -38,3 +38,34 @@ export function computeCssColor(color: string): string {
}
return color;
}
/**
* Validates if a string is a valid color.
* Accepts: hex colors (#xxx, #xxxxxx), theme colors, and valid CSS color names.
*/
export function isValidColorString(color: string | undefined): boolean {
if (!color || typeof color !== "string") {
return false;
}
// Check if it's a theme color
if (THEME_COLORS.has(color)) {
return true;
}
// Check if it's a hex color
if (/^#([0-9A-Fa-f]{3}){1,2}$/.test(color)) {
return true;
}
// Check if it's a valid CSS color name by trying to parse it
// Use CSS.supports() for a more efficient test without DOM manipulation
// This checks if the browser recognizes the color value
try {
const style = new Option().style;
style.color = color;
return style.color !== "";
} catch {
return false;
}
}

View File

@@ -0,0 +1,29 @@
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import type { HomeAssistant } from "../../types";
import { canShowPage } from "./can_show_page";
export interface NavigationFilterOptions {
/** Whether there are Bluetooth config entries (pre-fetched by caller) */
hasBluetoothConfigEntries?: boolean;
}
/**
* Filters navigation pages based on visibility rules.
* Handles special cases like Bluetooth (requires config entries)
* and external app configuration.
*/
export const filterNavigationPages = (
hass: HomeAssistant,
pages: PageNavigation[],
options: NavigationFilterOptions = {}
): PageNavigation[] =>
pages.filter((page) => {
if (page.path === "#external-app-configuration") {
return hass.auth.external?.config.hasSettingsScreen;
}
// Only show Bluetooth page if there are Bluetooth config entries
if (page.component === "bluetooth") {
return options.hasBluetoothConfigEntries ?? false;
}
return canShowPage(hass, page);
});

View File

@@ -8,7 +8,9 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
return UNAVAILABLE;
}
const validState = states.filter((stateObj) => isUnavailableState(stateObj));
const validState = states.some(
(stateObj) => !isUnavailableState(stateObj.state)
);
if (!validState) {
return UNAVAILABLE;

View File

@@ -16,6 +16,7 @@ export interface ShortcutConfig {
* Default is false to avoid interrupting copy/paste.
*/
allowWhenTextSelected?: boolean;
allowInInput?: boolean;
}
/**
@@ -29,7 +30,10 @@ function registerShortcuts(
Object.entries(shortcuts).forEach(([key, config]) => {
wrappedShortcuts[key] = (event: KeyboardEvent) => {
if (!canOverrideAlphanumericInput(event.composedPath())) {
if (
!config.allowInInput &&
!canOverrideAlphanumericInput(event.composedPath())
) {
return;
}
if (!config.allowWhenTextSelected && window.getSelection()?.toString()) {

View File

@@ -34,10 +34,6 @@ export function setDirectionStyles(direction: string, element: LitElement) {
"--float-end",
direction === "ltr" ? "right" : "left"
);
element.style.setProperty(
"--margin-title",
direction === "ltr" ? "var(--margin-title-ltr)" : "var(--margin-title-rtl)"
);
element.style.setProperty(
"--scale-direction",
direction === "ltr" ? "1" : "-1"

View File

@@ -51,6 +51,7 @@ export class HaCard extends LitElement {
font-weight: var(--ha-font-weight-normal);
}
/* clean-css ignore:start */
:host
::slotted(
.card-content:not(:nth-child(1 of .card-content, .card-header))
@@ -59,6 +60,7 @@ export class HaCard extends LitElement {
padding-top: 0;
margin-top: calc(var(--ha-space-2) * -1);
}
/* clean-css ignore:end */
:host ::slotted(.card-content) {
padding: var(--ha-space-4);

View File

@@ -13,6 +13,9 @@ import {
mdiArrowCollapse,
mdiArrowExpand,
mdiContentCopy,
mdiBug,
mdiBugOutline,
mdiFindReplace,
mdiRedo,
mdiUndo,
} from "@mdi/js";
@@ -36,6 +39,7 @@ import type { HaIconButtonToolbar } from "./ha-icon-button-toolbar";
declare global {
interface HASSDomEvents {
"editor-save": undefined;
"test-toggle": undefined;
}
}
@@ -82,6 +86,11 @@ export class HaCodeEditor extends ReactiveElement {
@property({ type: Boolean, attribute: "has-toolbar" })
public hasToolbar = true;
@property({ type: Boolean, attribute: "has-test" })
public hasTest = false;
@property({ attribute: false }) public testing = false;
@property({ type: String }) public placeholder?: string;
@state() private _value = "";
@@ -213,7 +222,8 @@ export class HaCodeEditor extends ReactiveElement {
if (
changedProps.has("_canCopy") ||
changedProps.has("_canUndo") ||
changedProps.has("_canRedo")
changedProps.has("_canRedo") ||
changedProps.has("testing")
) {
this._updateToolbarButtons();
}
@@ -361,6 +371,19 @@ export class HaCodeEditor extends ReactiveElement {
}
this._editorToolbar.items = [
...(this.hasTest && !this._isFullscreen
? [
{
id: "test",
label:
this.hass?.localize(
`ui.components.yaml-editor.test_${this.testing ? "off" : "on"}`
) || "Test",
path: this.testing ? mdiBugOutline : mdiBug,
action: (e: Event) => this._handleTestClick(e),
},
]
: []),
{
id: "undo",
disabled: !this._canUndo,
@@ -384,6 +407,14 @@ export class HaCodeEditor extends ReactiveElement {
path: mdiContentCopy,
action: (e: Event) => this._handleClipboardClick(e),
},
{
id: "find-replace",
label:
this.hass?.localize("ui.components.yaml-editor.find_and_replace") ||
"Find and replace",
path: mdiFindReplace,
action: (e: Event) => this._handleFindReplaceClick(e),
},
{
id: "fullscreen",
disabled: this.disableFullscreen,
@@ -418,6 +449,15 @@ export class HaCodeEditor extends ReactiveElement {
}
};
private _handleTestClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
if (!this.codemirror) {
return;
}
fireEvent(this, "test-toggle");
};
private _handleUndoClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
@@ -442,6 +482,21 @@ export class HaCodeEditor extends ReactiveElement {
this._updateFullscreenState(!this._isFullscreen);
};
private _handleFindReplaceClick = (e: Event) => {
e.preventDefault();
e.stopPropagation();
if (!this.codemirror || !this._loadedCodeMirror) {
return;
}
// Toggle search panel: close if open, open if closed
const searchPanel = this.codemirror.dom.querySelector(".cm-search");
if (searchPanel) {
this._loadedCodeMirror.closeSearchPanel(this.codemirror);
} else {
this._loadedCodeMirror.openSearchPanel(this.codemirror);
}
};
private _handleKeyDown = (e: KeyboardEvent) => {
if (
(e.key === "Escape" &&

View File

@@ -2,11 +2,17 @@ import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import "./ha-list-item";
import "./ha-select";
import type { HaSelect } from "./ha-select";
import type { FrontendLocaleData } from "../data/translation";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const SEARCH_KEYS = [
{ name: "primary", weight: 10 },
{ name: "secondary", weight: 8 },
{ name: "search_labels.english", weight: 5 },
];
export const COUNTRIES = [
"AD",
@@ -260,9 +266,45 @@ export const COUNTRIES = [
"ZW",
];
export const getCountryOptions = (
countries: string[],
noSort: boolean,
locale?: FrontendLocaleData
): PickerComboBoxItem[] => {
const language = locale?.language ?? "en";
const countryDisplayNames = new Intl.DisplayNames(language, {
type: "region",
fallback: "code",
});
const englishDisplayNames = new Intl.DisplayNames("en", {
type: "region",
fallback: "code",
});
const options: PickerComboBoxItem[] = countries.map((country) => {
const primary = countryDisplayNames.of(country) ?? country;
const englishName = englishDisplayNames.of(country) ?? country;
return {
id: country,
primary,
secondary: country,
search_labels: {
english: englishName !== primary ? englishName : null,
},
};
});
if (!noSort && locale) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.primary, b.primary, locale.language)
);
}
return options;
};
@customElement("ha-country-picker")
export class HaCountryPicker extends LitElement {
@property() public language = "en";
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string;
@@ -278,76 +320,72 @@ export class HaCountryPicker extends LitElement {
@property({ attribute: "no-sort", type: Boolean }) public noSort = false;
private _getOptions = memoizeOne(
(language?: string, countries?: string[]) => {
let options: { label: string; value: string }[] = [];
const countryDisplayNames = new Intl.DisplayNames(language, {
type: "region",
fallback: "code",
});
if (countries) {
options = countries.map((country) => ({
value: country,
label: countryDisplayNames
? countryDisplayNames.of(country)!
: country,
}));
} else {
options = COUNTRIES.map((country) => ({
value: country,
label: countryDisplayNames
? countryDisplayNames.of(country)!
: country,
}));
}
private _getCountryOptions = memoizeOne(getCountryOptions);
if (!this.noSort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, language)
);
}
return options;
}
);
private _getItems = () =>
this._getCountryOptions(
this.countries ?? COUNTRIES,
this.noSort,
this.hass?.locale
);
private _getCountryName = (country?: string) =>
this._getItems().find((c) => c.id === country)?.primary;
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._getCountryName(value) ?? value}</span>`;
protected render() {
const options = this._getOptions(this.language, this.countries);
const label =
this.label ??
(this.hass?.localize("ui.components.country-picker.country") ||
"Country");
const value =
this.value ??
(this.required && !this.disabled ? this._getItems()[0]?.id : this.value);
return html`
<ha-select
.label=${this.label}
.value=${this.value}
<ha-generic-picker
.hass=${this.hass}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.country-picker.no_countries"
) || "No countries available"}
.label=${label}
.value=${value}
.valueRenderer=${this._valueRenderer}
.disabled=${this.disabled}
.required=${this.required}
.helper=${this.helper}
.disabled=${this.disabled}
@selected=${this._changed}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
${options.map(
(option) => html`
<ha-list-item .value=${option.value}>${option.label}</ha-list-item>
`
)}
</ha-select>
.getItems=${this._getItems}
.searchKeys=${SEARCH_KEYS}
@value-changed=${this._changed}
hide-clear-icon
></ha-generic-picker>
`;
}
static styles = css`
ha-select {
ha-generic-picker {
width: 100%;
min-width: 200px;
display: block;
}
`;
private _changed(ev): void {
const target = ev.target as HaSelect;
if (target.value === "" || target.value === this.value) {
return;
}
this.value = target.value;
private _changed(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this.hass
? this.hass.localize("ui.components.country-picker.no_match", { term })
: html`No countries found for ${term}`;
};
}
declare global {

View File

@@ -2,11 +2,16 @@ import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import "./ha-list-item";
import "./ha-select";
import type { HaSelect } from "./ha-select";
import type { FrontendLocaleData } from "../data/translation";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const SEARCH_KEYS = [
{ name: "primary", weight: 10 },
{ name: "secondary", weight: 8 },
];
const CURRENCIES = [
"AED",
@@ -172,9 +177,31 @@ const curSymbol = (currency: string, locale?: string) =>
new Intl.NumberFormat(locale, { style: "currency", currency })
.formatToParts(1)
.find((x) => x.type === "currency")?.value;
export const getCurrencyOptions = (
locale?: FrontendLocaleData
): PickerComboBoxItem[] => {
const language = locale?.language ?? "en";
const currencyDisplayNames = new Intl.DisplayNames(language, {
type: "currency",
fallback: "code",
});
const options: PickerComboBoxItem[] = CURRENCIES.map((currency) => ({
id: currency,
primary: `${currencyDisplayNames.of(currency)} (${curSymbol(currency, language)})`,
secondary: currency,
}));
options.sort((a, b) =>
caseInsensitiveStringCompare(a.primary, b.primary, language)
);
return options;
};
@customElement("ha-currency-picker")
export class HaCurrencyPicker extends LitElement {
@property() public language = "en";
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string;
@@ -184,60 +211,62 @@ export class HaCurrencyPicker extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
private _getOptions = memoizeOne((language?: string) => {
const currencyDisplayNames = new Intl.DisplayNames(language, {
type: "currency",
fallback: "code",
});
const options = CURRENCIES.map((currency) => ({
value: currency,
label: `${
currencyDisplayNames ? currencyDisplayNames.of(currency)! : currency
} (${curSymbol(currency, language)})`,
}));
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, language)
);
return options;
});
private _getCurrencyOptions = memoizeOne(getCurrencyOptions);
private _getItems = () => this._getCurrencyOptions(this.hass?.locale);
private _getCurrencyName = (currency?: string) =>
this._getItems().find((c) => c.id === currency)?.primary;
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._getCurrencyName(value) ?? value}</span>`;
protected render() {
const options = this._getOptions(this.language);
const label =
this.label ??
(this.hass?.localize("ui.components.currency-picker.currency") ||
"Currency");
return html`
<ha-select
.label=${this.label}
<ha-generic-picker
.hass=${this.hass}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.currency-picker.no_currencies"
) || "No currencies available"}
.label=${label}
.value=${this.value}
.required=${this.required}
.valueRenderer=${this._valueRenderer}
.disabled=${this.disabled}
@selected=${this._changed}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
${options.map(
(option) => html`
<ha-list-item .value=${option.value}>${option.label}</ha-list-item>
`
)}
</ha-select>
.required=${this.required}
.getItems=${this._getItems}
.searchKeys=${SEARCH_KEYS}
@value-changed=${this._changed}
hide-clear-icon
></ha-generic-picker>
`;
}
static styles = css`
ha-select {
ha-generic-picker {
width: 100%;
min-width: 200px;
display: block;
}
`;
private _changed(ev): void {
const target = ev.target as HaSelect;
if (target.value === "" || target.value === this.value) {
return;
}
this.value = target.value;
private _changed(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this.hass
? this.hass.localize("ui.components.currency-picker.no_match", { term })
: html`No currencies found for ${term}`;
};
}
declare global {

View File

@@ -76,6 +76,18 @@ export class HaDialog extends DialogBase {
var(--divider-color)
);
z-index: var(--dialog-z-index, 8);
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
--mdc-typography-headline6-font-size: 1.574rem;
}
.mdc-dialog::before {
content: "";
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
pointer-events: none;
-webkit-backdrop-filter: var(
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
@@ -84,9 +96,9 @@ export class HaDialog extends DialogBase {
--ha-dialog-scrim-backdrop-filter,
var(--dialog-backdrop-filter, none)
);
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
--mdc-typography-headline6-font-size: 1.574rem;
}
.mdc-dialog .mdc-dialog__scrim {
background-color: var(--mdc-dialog-scrim-color, none);
}
.mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end);

View File

@@ -37,6 +37,7 @@ export class HaDropdownItem extends DropdownItem {
#check {
visibility: visible;
flex-shrink: 0;
}
#icon ::slotted(*) {

View File

@@ -39,9 +39,7 @@ export class HaFilterDomains extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize(
"ui.panel.config.entities.picker.headers.domain"
)}
${this.hass.localize("ui.panel.config.domains.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button

View File

@@ -1,17 +1,18 @@
import { mdiMenuDown, mdiMenuUp } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-check-list-item";
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-md-button-menu";
import "../ha-md-menu-item";
import "../ha-textfield";
import "../ha-picker-field";
import type { HaDropdown } from "../ha-dropdown";
import type { HaDropdownItem } from "../ha-dropdown-item";
import type {
HaFormElement,
HaFormMultiSelectData,
@@ -36,16 +37,12 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
@property() public label!: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, reflect: true }) public disabled = false;
@state() private _opened = false;
@query("ha-md-button-menu") private _input?: HTMLElement;
@query("ha-dropdown") private _dropdown!: HaDropdown;
public focus(): void {
if (this._input) {
this._input.focus();
}
this._dropdown?.focus();
}
protected render(): TemplateResult {
@@ -74,13 +71,14 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
}
return html`
<ha-md-button-menu
.disabled=${this.disabled}
@opening=${this._handleOpen}
@closing=${this._handleClose}
positioning="fixed"
<ha-dropdown
@wa-select=${this._toggleItem}
@wa-show=${this._showDropdown}
placement="bottom"
tabindex="0"
@keydown=${this._handleKeydown}
>
<ha-textfield
<ha-picker-field
slot="trigger"
.label=${this.label}
.value=${data
@@ -91,71 +89,42 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
)
.join(", ")}
.disabled=${this.disabled}
tabindex="-1"
></ha-textfield>
<ha-icon-button
slot="trigger"
.label=${this.label}
.path=${this._opened ? mdiMenuUp : mdiMenuDown}
></ha-icon-button>
hide-clear-icon
>
</ha-picker-field>
${options.map((item: string | [string, string]) => {
const value = optionValue(item);
const selected = data.includes(value);
return html`<ha-md-menu-item
type="option"
aria-checked=${selected}
return html`<ha-dropdown-item
.value=${value}
.action=${selected ? "remove" : "add"}
.activated=${selected}
@click=${this._toggleItem}
@keydown=${this._keydown}
keep-open
type="checkbox"
.checked=${selected}
>
<ha-checkbox
slot="start"
tabindex="-1"
.checked=${selected}
></ha-checkbox>
${optionLabel(item)}
</ha-md-menu-item>`;
</ha-dropdown-item>`;
})}
</ha-md-button-menu>
</ha-dropdown>
`;
}
protected _keydown(ev) {
if (ev.code === "Space" || ev.code === "Enter") {
ev.preventDefault();
this._toggleItem(ev);
}
}
protected _toggleItem(ev: CustomEvent<{ item: HaDropdownItem }>) {
ev.preventDefault(); // keep the dropdown open
const value = ev.detail.item.value;
const action = (ev.detail.item as any).action;
protected _toggleItem(ev) {
const oldData = this.data || [];
let newData: string[];
if (ev.currentTarget.action === "add") {
newData = [...oldData, ev.currentTarget.value];
if (action === "add") {
newData = [...oldData, value];
} else {
newData = oldData.filter((d) => d !== ev.currentTarget.value);
newData = oldData.filter((d) => d !== value);
}
fireEvent(this, "value-changed", {
value: newData,
});
}
protected firstUpdated() {
this.updateComplete.then(() => {
const { formElement, mdcRoot } =
this.shadowRoot?.querySelector("ha-textfield") || ({} as any);
if (formElement) {
formElement.style.textOverflow = "ellipsis";
}
if (mdcRoot) {
mdcRoot.style.cursor = "pointer";
}
});
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("schema")) {
this.toggleAttribute(
@@ -194,25 +163,28 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
});
}
private _handleOpen(ev: Event): void {
ev.stopPropagation();
this._opened = true;
this.toggleAttribute("opened", true);
private _showDropdown(ev) {
if (this.disabled) {
ev.preventDefault();
}
this.style.setProperty(
"--dropdown-width",
`${this._dropdown.offsetWidth}px`
);
}
private _handleClose(ev: Event): void {
ev.stopPropagation();
this._opened = false;
this.toggleAttribute("opened", false);
private _handleKeydown(ev) {
if ((ev.code === "Space" || ev.code === "Enter") && this._dropdown) {
this._dropdown.open = true;
}
}
static styles = css`
:host([own-margin]) {
margin-bottom: 5px;
}
ha-md-button-menu {
ha-dropdown {
display: block;
cursor: pointer;
}
ha-formfield {
display: block;
@@ -239,9 +211,15 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
:host([opened]) ha-icon-button {
color: var(--primary-color);
}
:host([opened]) ha-md-button-menu {
--mdc-text-field-idle-line-color: var(--input-hover-line-color);
--mdc-text-field-label-ink-color: var(--primary-color);
ha-dropdown::part(menu) {
border-top-left-radius: 0;
border-top-right-radius: 0;
width: var(--dropdown-width);
}
:host([disabled]) ha-dropdown ha-picker-field {
cursor: not-allowed;
}
`;
}

View File

@@ -3,6 +3,7 @@ import { mdiDotsVertical } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { stopPropagation } from "../common/dom/stop_propagation";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dropdown";
@@ -39,7 +40,10 @@ export class HaIconOverflowMenu extends LitElement {
return html`
${this.narrow
? html` <!-- Collapsed representation for small screens -->
<ha-dropdown @wa-show=${this._handleIconOverflowMenuOpened}>
<ha-dropdown
@wa-show=${this._handleIconOverflowMenuOpened}
@click=${stopPropagation}
>
<ha-icon-button
.label=${this.hass.localize("ui.common.overflow_menu")}
.path=${mdiDotsVertical}

View File

@@ -156,6 +156,10 @@ export class HaIcon extends LitElement {
);
chunks[chunk] = iconPromise;
this._setPath(iconPromise, iconName, requestedIcon);
// Remove chunk from cache on failure so next attempt retries
iconPromise.catch(() => {
delete chunks[chunk];
});
debouncedWriteCache();
}
@@ -177,11 +181,15 @@ export class HaIcon extends LitElement {
iconName: string,
requestedIcon: string
) {
const iconPack = await promise;
if (this.icon === requestedIcon) {
this._path = iconPack[iconName];
try {
const iconPack = await promise;
if (this.icon === requestedIcon) {
this._path = iconPack[iconName];
}
cachedIcons[iconName] = iconPack[iconName];
} catch (_err) {
// Chunk failed to load, already evicted from cache for retry
}
cachedIcons[iconName] = iconPack[iconName];
}
static styles = css`

View File

@@ -1,123 +0,0 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { FOCUS_TARGET } from "../dialogs/make-dialog-manager";
import type { HaButton } from "./ha-button";
import type { HaIconButton } from "./ha-icon-button";
import "./ha-md-menu";
import type { HaMdMenu } from "./ha-md-menu";
@customElement("ha-md-button-menu")
export class HaMdButtonMenu extends LitElement {
protected readonly [FOCUS_TARGET];
@property({ type: Boolean }) public disabled = false;
@property() public positioning?: "fixed" | "absolute" | "popover";
@property({ attribute: "anchor-corner" }) public anchorCorner:
| "start-start"
| "start-end"
| "end-start"
| "end-end" = "end-start";
@property({ attribute: "menu-corner" }) public menuCorner:
| "start-start"
| "start-end"
| "end-start"
| "end-end" = "start-start";
@property({ type: Boolean, attribute: "has-overflow" }) public hasOverflow =
false;
@property({ type: Boolean }) public quick = false;
@query("ha-md-menu", true) private _menu!: HaMdMenu;
public get items() {
return this._menu.items;
}
public override focus() {
if (this._menu.open) {
this._menu.focus();
} else {
this._triggerButton?.focus();
}
}
protected render(): TemplateResult {
return html`
<div @click=${this._handleClick}>
<slot name="trigger" @slotchange=${this._setTriggerAria}></slot>
</div>
<ha-md-menu
.quick=${this.quick}
.positioning=${this.positioning}
.hasOverflow=${this.hasOverflow}
.anchorCorner=${this.anchorCorner}
.menuCorner=${this.menuCorner}
@opening=${this._handleOpening}
@closing=${this._handleClosing}
>
<slot></slot>
</ha-md-menu>
`;
}
private _handleOpening(): void {
fireEvent(this, "opening", undefined, { composed: false });
}
private _handleClosing(): void {
fireEvent(this, "closing", undefined, { composed: false });
}
private _handleClick(): void {
if (this.disabled) {
return;
}
this._menu.anchorElement = this;
if (this._menu.open) {
this._menu.close();
} else {
this._menu.show();
}
}
private get _triggerButton() {
return this.querySelector(
'ha-icon-button[slot="trigger"], ha-button[slot="trigger"], ha-assist-chip[slot="trigger"]'
) as HaIconButton | HaButton | null;
}
private _setTriggerAria() {
if (this._triggerButton) {
this._triggerButton.ariaHasPopup = "menu";
}
}
static styles = css`
:host {
display: inline-block;
position: relative;
}
::slotted([disabled]) {
color: var(--disabled-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-md-button-menu": HaMdButtonMenu;
}
}
declare global {
interface HASSDomEvents {
opening: undefined;
closing: undefined;
}
}

View File

@@ -1,13 +1,38 @@
import { html, LitElement, nothing } from "lit";
import Fuse from "fuse.js";
import { mdiDevices, mdiTextureBox } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { titleCase } from "../common/string/title-case";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { fetchConfig } from "../data/lovelace/config/types";
import { getPanelIcon, getPanelTitle } from "../data/panel";
import { findRelated, type RelatedResult } from "../data/search";
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
import { multiTermSortedSearch } from "../resources/fuseMultiTerm";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { ActionRelatedContext } from "../panels/lovelace/components/hui-action-editor";
import "./ha-generic-picker";
import "./ha-domain-icon";
import "./ha-icon";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import {
DEFAULT_SEARCH_KEYS,
type PickerComboBoxItem,
} from "./ha-picker-combo-box";
type NavigationGroup = "related" | "dashboards" | "views" | "other_routes";
const RELATED_SORT_PREFIX = {
area: "0_area",
device: "1_device",
} as const;
interface NavigationItem extends PickerComboBoxItem {
group: NavigationGroup;
domain?: string;
}
@customElement("ha-navigation-picker")
export class HaNavigationPicker extends LitElement {
@@ -25,13 +50,57 @@ export class HaNavigationPicker extends LitElement {
@state() private _loading = true;
@property({ attribute: false }) public context?: ActionRelatedContext;
protected firstUpdated() {
this._loadNavigationItems();
}
private _navigationItems: PickerComboBoxItem[] = [];
private _navigationItems: NavigationItem[] = [];
private _configEntryLookup: Record<string, ConfigEntry> = {};
private _navigationGroups: Record<NavigationGroup, NavigationItem[]> = {
related: [],
dashboards: [],
views: [],
other_routes: [],
};
private _getRelatedItems = memoizeOne(
async (_cacheKey: string, context: ActionRelatedContext) =>
this._fetchRelatedItems(context),
(newArgs, lastArgs) => newArgs[0] === lastArgs[0]
);
protected render() {
const sections = [
...(this._navigationGroups.related.length
? [
{
id: "related",
label: this.hass.localize(
"ui.components.navigation-picker.related"
),
},
]
: []),
{
id: "dashboards",
label: this.hass.localize("ui.components.navigation-picker.dashboards"),
},
{
id: "views",
label: this.hass.localize("ui.components.navigation-picker.views"),
},
{
id: "other_routes",
label: this.hass.localize(
"ui.components.navigation-picker.other_routes"
),
},
];
return html`
<ha-generic-picker
.hass=${this.hass}
@@ -43,6 +112,8 @@ export class HaNavigationPicker extends LitElement {
.required=${this.required}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
.sections=${sections}
.customValueLabel=${this.hass.localize(
"ui.components.navigation-picker.add_custom_path"
)}
@@ -55,9 +126,23 @@ export class HaNavigationPicker extends LitElement {
private _valueRenderer = (itemId: string) => {
const item = this._navigationItems.find((navItem) => navItem.id === itemId);
return html`
${item?.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: nothing}
${item?.domain
? html`
<ha-domain-icon
slot="start"
.hass=${this.hass}
.domain=${item.domain}
brand-fallback
></ha-domain-icon>
`
: item?.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item?.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item?.primary || itemId}</span>
${item?.primary
? html`<span slot="supporting-text">${itemId}</span>`
@@ -65,9 +150,106 @@ export class HaNavigationPicker extends LitElement {
`;
};
private _getItems = () => this._navigationItems;
private _rowRenderer = (item: NavigationItem) => html`
<ha-combo-box-item type="button" compact>
${item.domain
? html`
<ha-domain-icon
slot="start"
.hass=${this.hass}
.domain=${item.domain}
brand-fallback
></ha-domain-icon>
`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
private _fuseIndexes = {
related: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
dashboards: memoizeOne((items: NavigationItem[]) =>
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
),
views: 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 getGroupItems = (group: NavigationGroup) => {
let items = [...this._navigationGroups[group]].sort(
this._sortBySortingLabel
);
if (searchString) {
const fuseIndex = this._fuseIndexes[group](items);
items = multiTermSortedSearch(
items,
searchString,
DEFAULT_SEARCH_KEYS,
(item) => item.id,
fuseIndex
);
}
return items;
};
const items: (NavigationItem | string)[] = [];
const related = getGroupItems("related");
const dashboards = getGroupItems("dashboards");
const views = getGroupItems("views");
const otherRoutes = getGroupItems("other_routes");
const addGroup = (group: NavigationGroup, groupItems: NavigationItem[]) => {
if (section && section !== group) {
return;
}
if (!section && groupItems.length) {
items.push(
this.hass.localize(`ui.components.navigation-picker.${group}`)
);
}
items.push(...groupItems);
};
addGroup("related", related);
addGroup("dashboards", dashboards);
addGroup("views", views);
addGroup("other_routes", otherRoutes);
return items;
};
private _sortBySortingLabel = (
itemA: PickerComboBoxItem,
itemB: PickerComboBoxItem
) =>
caseInsensitiveStringCompare(
itemA.sorting_label!,
itemB.sorting_label!,
this.hass.locale.language
);
private async _loadNavigationItems() {
await this._loadConfigEntries();
const panels = Object.entries(this.hass!.panels).map(([id, panel]) => ({
id,
...panel,
@@ -78,12 +260,7 @@ export class HaNavigationPicker extends LitElement {
const viewConfigs = await Promise.all(
lovelacePanels.map((panel) =>
fetchConfig(
this.hass!.connection,
// path should be null to fetch default lovelace panel
panel.url_path === "lovelace" ? null : panel.url_path,
true
)
fetchConfig(this.hass!.connection, panel.url_path, true)
.then((config) => [panel.id, config] as [string, typeof config])
.catch((_) => [panel.id, undefined] as [string, undefined])
)
@@ -91,13 +268,19 @@ export class HaNavigationPicker extends LitElement {
const panelViewConfig = new Map(viewConfigs);
this._navigationItems = [];
const related = this._navigationGroups.related;
const dashboards: NavigationItem[] = [];
const views: NavigationItem[] = [];
const otherRoutes: NavigationItem[] = [];
for (const panel of panels) {
const path = `/${panel.url_path}`;
const panelTitle = getPanelTitle(this.hass, panel);
const primary = panelTitle || path;
this._navigationItems.push({
const isDashboardPanel =
panel.component_name === "lovelace" ||
PANEL_DASHBOARDS.includes(panel.id);
const panelItem: NavigationItem = {
id: path,
primary,
secondary: panelTitle ? path : undefined,
@@ -108,7 +291,14 @@ export class HaNavigationPicker extends LitElement {
]
.filter(Boolean)
.join("_"),
});
group: isDashboardPanel ? "dashboards" : "other_routes",
};
if (isDashboardPanel) {
dashboards.push(panelItem);
} else {
otherRoutes.push(panelItem);
}
const config = panelViewConfig.get(panel.id);
@@ -118,7 +308,7 @@ export class HaNavigationPicker extends LitElement {
const viewPath = `/${panel.url_path}/${view.path ?? index}`;
const viewPrimary =
view.title ?? (view.path ? titleCase(view.path) : `${index}`);
this._navigationItems.push({
views.push({
id: viewPath,
secondary: viewPath,
icon: view.icon ?? "mdi:view-compact",
@@ -127,13 +317,156 @@ export class HaNavigationPicker extends LitElement {
viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary,
viewPath,
].join("_"),
group: "views",
});
});
}
this._navigationGroups = {
related,
dashboards,
views,
other_routes: otherRoutes,
};
this._navigationItems = [
...related,
...dashboards,
...views,
...otherRoutes,
];
this._loading = false;
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("context")) {
this._loadRelatedItems();
}
}
private async _loadRelatedItems() {
const updateRelatedItems = (relatedItems: NavigationItem[]) => {
this._navigationGroups = {
...this._navigationGroups,
related: relatedItems,
};
this._navigationItems = [
...relatedItems,
...this._navigationGroups.dashboards,
...this._navigationGroups.views,
...this._navigationGroups.other_routes,
];
};
if (!this.hass || (!this.context?.entity_id && !this.context?.area_id)) {
updateRelatedItems([]);
return;
}
const context = this.context;
const contextMatches = () =>
this.context?.entity_id === context?.entity_id &&
this.context?.area_id === context?.area_id;
const items = await this._getRelatedItems(
`${context.entity_id ?? ""}|${context.area_id ?? ""}`,
context
);
if (contextMatches()) {
updateRelatedItems(items);
}
}
private async _fetchRelatedItems(
context: ActionRelatedContext
): Promise<NavigationItem[]> {
let relatedResult: RelatedResult | undefined;
try {
relatedResult = context.entity_id
? await findRelated(this.hass, "entity", context.entity_id)
: await findRelated(this.hass, "area", context.area_id!);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error fetching related items for navigation picker", err);
return [];
}
const relatedDeviceIds = new Set(relatedResult?.device ?? []);
const relatedAreaIds = new Set(relatedResult?.area ?? []);
if (context.area_id) {
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}`;
relatedItems.push({
id: path,
primary,
secondary: path,
icon_path: mdiDevices,
sorting_label: createSortingLabel(
RELATED_SORT_PREFIX.device,
primary,
path
),
group: "related",
domain: device?.primary_config_entry
? this._configEntryLookup[device.primary_config_entry]?.domain
: undefined,
});
}
for (const areaId of relatedAreaIds) {
const area = this.hass.areas[areaId];
const primary = area?.name ?? areaId;
const path = `/config/areas/area/${areaId}`;
relatedItems.push({
id: path,
primary,
secondary: path,
icon: area?.icon ?? undefined,
icon_path: area?.icon ? undefined : mdiTextureBox,
sorting_label: createSortingLabel(
RELATED_SORT_PREFIX.area,
primary,
path
),
group: "related",
});
}
return relatedItems;
}
private async _loadConfigEntries() {
if (Object.keys(this._configEntryLookup).length) {
return;
}
try {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error fetching config entries for navigation picker", err);
}
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
this._setValue(ev.detail.value);

View File

@@ -22,6 +22,7 @@ import {
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import { isTouch } from "../util/is_touch";
import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
import "./ha-combo-box-item";
@@ -56,6 +57,11 @@ export interface PickerComboBoxItem {
icon?: string;
}
export interface PickerComboBoxIndexSelectedDetail {
index: number;
newTab?: boolean;
}
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
@@ -157,6 +163,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@state() private _items: PickerComboBoxItem[] = [];
@state() private _selectedSection?: string;
public setFieldValue(value: string) {
if (this._searchFieldElement) {
this._searchFieldElement.value = value;
@@ -191,6 +199,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
this._selectedSection = this.selectedSection;
this._allItems = this._getItems();
this._items = this._allItems;
}
@@ -227,7 +236,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
? html`
<div class="section-title-wrapper">
<div
class="section-title ${!this.selectedSection &&
class="section-title ${!this._selectedSection &&
this._sectionTitle
? "show"
: ""}"
@@ -276,9 +285,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
section === "separator"
? html`<div class="separator"></div>`
: html`<ha-filter-chip
@mousedown=${isTouch ? undefined : this._preventBlur}
@click=${this._toggleSection}
.section-id=${section.id}
.selected=${this.selectedSection === section.id}
.selected=${this._selectedSection === section.id}
.label=${section.label}
>
</ha-filter-chip>`
@@ -315,7 +325,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.getAdditionalItems?.(searchString) || [];
private _getItems = () => {
let items = [...(this.getItems(this._search, this.selectedSection) || [])];
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) => {
@@ -414,18 +424,19 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return this.value || "";
}
private _valueSelected = (ev: Event) => {
private _valueSelected = (ev: MouseEvent) => {
ev.stopPropagation();
const value = (ev.currentTarget as any).value as string;
const index = Number((ev.currentTarget as any).index);
const newValue = value?.trim();
const newTab = ev.ctrlKey || ev.metaKey;
this._fireSelectedEvents(newValue, index);
this._fireSelectedEvents(newValue, index, newTab);
};
private _fireSelectedEvents(value: string, index: number) {
private _fireSelectedEvents(value: string, index: number, newTab = false) {
fireEvent(this, "value-changed", { value });
fireEvent(this, "index-selected", { index });
fireEvent(this, "index-selected", { index, newTab });
}
private _clearSearch = () => {
@@ -497,6 +508,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._valuePinned = true;
};
private _preventBlur(ev: Event) {
ev.preventDefault();
}
private _toggleSection(ev: Event) {
ev.stopPropagation();
this._resetSelectedItem();
@@ -505,18 +520,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (!section) {
return;
}
if (this.selectedSection === section) {
this.selectedSection = undefined;
if (this._selectedSection === section) {
this._selectedSection = undefined;
} else {
this.selectedSection = section;
this._selectedSection = section;
}
this._items = this._getItems();
// Reset scroll position when filter changes
if (this.virtualizerElement) {
this.virtualizerElement.scrollToIndex(0);
}
this.virtualizerElement?.element(0)?.scrollIntoView();
}
private _registerKeyboardShortcuts() {
@@ -526,15 +539,42 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
Home: this._selectFirstItem,
End: this._selectLastItem,
Enter: this._pickSelectedItem,
"$mod+Enter": this._pickSelectedItemNewTab,
});
}
private _focusList() {
if (this._selectedItemIndex === -1) {
this._selectNextItem();
this._initializeSelectedIndex();
}
}
/**
* Initialize keyboard selection to the currently selected value,
* or fall back to the first item when searching (skipping section titles).
*/
private _initializeSelectedIndex(): void {
if (!this.virtualizerElement?.items?.length) {
return;
}
const initialIndex = this._getInitialSelectedIndex();
// Only initialize to first item if searching, otherwise require a selected value
if (initialIndex === 0 && !this._search) {
return;
}
let index = initialIndex;
// Skip section titles (strings)
if (typeof this.virtualizerElement.items[index] === "string") {
index += 1;
}
// Bounds check: ensure index is valid after skipping section title
if (index >= this.virtualizerElement.items.length) {
return;
}
this._selectedItemIndex = index;
this._scrollToSelectedItem();
}
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
@@ -553,6 +593,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
// If no item is selected yet, start from the currently selected value
if (this._selectedItemIndex === -1) {
this._initializeSelectedIndex();
if (this._selectedItemIndex !== -1) {
return;
}
}
const nextIndex =
maxItems === this._selectedItemIndex
? this._selectedItemIndex
@@ -644,7 +692,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
?.querySelector(".selected")
?.classList.remove("selected");
this.virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
this.virtualizerElement
?.element(this._selectedItemIndex)
?.scrollIntoView({ block: "nearest" });
requestAnimationFrame(() => {
this.virtualizerElement
@@ -654,6 +704,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _pickSelectedItem = (ev: KeyboardEvent) => {
this._pickItem(ev, false);
};
private _pickSelectedItemNewTab = (ev: KeyboardEvent) => {
this._pickItem(ev, true);
};
private _pickItem = (ev: KeyboardEvent, newTab: boolean) => {
ev.stopPropagation();
if (
this.virtualizerElement?.items?.length !== undefined &&
@@ -665,14 +723,17 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
if (typeof item !== "string") {
this._fireSelectedEvents(item.id, index);
this._fireSelectedEvents(item.id, index, newTab);
}
});
return;
}
if (this._selectedItemIndex === -1) {
return;
this._initializeSelectedIndex();
if (this._selectedItemIndex === -1) {
return;
}
}
// if filter button is focused
@@ -682,7 +743,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._selectedItemIndex
] as PickerComboBoxItem;
if (item) {
this._fireSelectedEvents(item.id, this._selectedItemIndex);
this._fireSelectedEvents(item.id, this._selectedItemIndex, newTab);
}
};
@@ -888,6 +949,6 @@ declare global {
}
interface HASSDomEvents {
"index-selected": { index: number };
"index-selected": PickerComboBoxIndexSelectedDetail;
}
}

View File

@@ -1,42 +1,8 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { AddonSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-addon-picker";
import { customElement } from "lit/decorators";
import { HaAppSelector } from "./ha-selector-app";
@customElement("ha-selector-addon")
export class HaAddonSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: AddonSelector;
@property() public value?: any;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`<ha-addon-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-addon-picker>`;
}
static styles = css`
ha-addon-picker {
width: 100%;
}
`;
}
export class HaAddonSelector extends HaAppSelector {}
declare global {
interface HTMLElementTagNameMap {

View File

@@ -0,0 +1,45 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { AppSelector, AddonSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-addon-picker";
@customElement("ha-selector-app")
export class HaAppSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: AppSelector | AddonSelector;
@property() public value?: any;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
protected render() {
return html`<ha-addon-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-addon-picker>`;
}
static styles = css`
ha-addon-picker {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-app": HaAppSelector;
}
}

View File

@@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { NavigationSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor";
import "../ha-navigation-picker";
@customElement("ha-selector-navigation")
@@ -21,6 +22,8 @@ export class HaNavigationSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@property({ attribute: false }) public context?: ActionRelatedContext;
protected render() {
return html`
<ha-navigation-picker
@@ -30,6 +33,7 @@ export class HaNavigationSelector extends LitElement {
.required=${this.required}
.disabled=${this.disabled}
.helper=${this.helper}
.context=${this.selector.navigation ?? this.context}
@value-changed=${this._valueChanged}
></ha-navigation-picker>
`;

View File

@@ -1,11 +1,17 @@
import type { PropertyValues } from "lit";
import { css, html, nothing, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-code-editor";
import "../ha-input-helper-text";
import "../ha-alert";
import type { RenderTemplateResult } from "../../data/ws-templates";
import { subscribeRenderTemplate } from "../../data/ws-templates";
import { debounce } from "../../common/util/debounce";
import type { TemplateSelector } from "../../data/selector";
const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"];
@@ -13,6 +19,8 @@ const WARNING_STRINGS = ["template:", "sensor:", "state:", "trigger: template"];
export class HaTemplateSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: TemplateSelector;
@property() public value?: string;
@property() public label?: string;
@@ -27,6 +35,45 @@ export class HaTemplateSelector extends LitElement {
@state() private warn: string | undefined = undefined;
@state() private _test = false;
@state() private _error?: string;
@state() private _errorLevel?: "ERROR" | "WARNING";
@state() private _templateResult?: RenderTemplateResult;
@state() private _unsubRenderTemplate?: Promise<UnsubscribeFunc>;
private _debounceError = debounce(
(error, level) => {
this._error = error;
this._errorLevel = level;
this._templateResult = undefined;
},
500,
false
);
public disconnectedCallback() {
super.disconnectedCallback();
this._debounceError.cancel();
this._unsubscribeTemplate();
}
public connectedCallback() {
super.connectedCallback();
if (this._test) {
this._subscribeTemplate();
}
}
protected updated(changedProps: PropertyValues) {
if (changedProps.has("value") && this._test) {
this._subscribeTemplate();
}
}
protected render() {
return html`
${this.warn
@@ -61,10 +108,22 @@ export class HaTemplateSelector extends LitElement {
autofocus
autocomplete-entities
autocomplete-icons
.hasTest=${this.selector.template?.preview !== false}
.testing=${this._test}
@value-changed=${this._handleChange}
@test-toggle=${this._testToggle}
dir="ltr"
linewrap
></ha-code-editor>
${this._test && this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: this._test && this._templateResult
? html`<pre class="rendered">
${typeof this._templateResult.result === "object"
? JSON.stringify(this._templateResult.result, null, 2)
: this._templateResult.result}</pre
>`
: nothing}
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
@@ -73,6 +132,69 @@ export class HaTemplateSelector extends LitElement {
`;
}
private _testToggle() {
this._test = !this._test;
if (this._test) {
this._subscribeTemplate();
} else {
this._unsubscribeTemplate();
}
}
private async _subscribeTemplate() {
await this._unsubscribeTemplate();
const template = this.value || "";
try {
this._unsubRenderTemplate = subscribeRenderTemplate(
this.hass.connection,
(result) => {
if ("error" in result) {
// We show the latest error, or a warning if there are no errors
if (result.level === "ERROR" || this._errorLevel !== "ERROR") {
this._debounceError(result.error, result.level);
}
} else {
this._debounceError.cancel();
this._error = undefined;
this._errorLevel = undefined;
this._templateResult = result;
}
},
{
template,
timeout: 3,
report_errors: true,
}
);
await this._unsubRenderTemplate;
} catch (err: any) {
this._error = err.message || "Unknown error";
this._errorLevel = undefined;
this._templateResult = undefined;
this._unsubRenderTemplate = undefined;
}
}
private async _unsubscribeTemplate(): Promise<void> {
if (!this._unsubRenderTemplate) {
return;
}
try {
const unsub = await this._unsubRenderTemplate;
unsub();
this._unsubRenderTemplate = undefined;
} catch (err: any) {
if (err.code === "not_found") {
// If we get here, the connection was probably already closed. Ignore.
} else {
throw err;
}
}
}
private _handleChange(ev) {
ev.stopPropagation();
let value = ev.target.value;
@@ -90,6 +212,20 @@ export class HaTemplateSelector extends LitElement {
p {
margin-top: 0;
}
.rendered {
font-family: var(--ha-font-family-code);
-webkit-font-smoothing: var(--ha-font-smoothing);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
clear: both;
white-space: pre-wrap;
background-color: var(--secondary-background-color);
padding: var(--ha-space-2);
margin-top: var(--ha-space-3);
margin-bottom: 0;
direction: ltr;
border-radius: var(--ha-border-radius-sm);
overflow-wrap: break-word;
}
`;
}

View File

@@ -0,0 +1,47 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { TimezoneSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-timezone-picker";
@customElement("ha-selector-timezone")
export class HaTimezoneSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: TimezoneSelector;
@property() public value?: string;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
protected render() {
return html`
<ha-timezone-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
></ha-timezone-picker>
`;
}
static styles = css`
ha-timezone-picker {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-timezone": HaTimezoneSelector;
}
}

View File

@@ -5,6 +5,7 @@ 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";
@customElement("ha-selector-ui_action")
export class HaSelectorUiAction extends LitElement {
@@ -14,6 +15,8 @@ export class HaSelectorUiAction extends LitElement {
@property({ attribute: false }) public value?: ActionConfig;
@property({ attribute: false }) public context?: ActionRelatedContext;
@property() public label?: string;
@property() public helper?: string;
@@ -24,6 +27,7 @@ export class HaSelectorUiAction extends LitElement {
.label=${this.label}
.hass=${this.hass}
.config=${this.value}
.context=${this.context}
.actions=${this.selector.ui_action?.actions}
.defaultAction=${this.selector.ui_action?.default_action}
.tooltipText=${this.helper}

View File

@@ -13,6 +13,7 @@ import type { HomeAssistant } from "../../types";
const LOAD_ELEMENTS = {
action: () => import("./ha-selector-action"),
addon: () => import("./ha-selector-addon"),
app: () => import("./ha-selector-app"),
area: () => import("./ha-selector-area"),
areas_display: () => import("./ha-selector-areas-display"),
attribute: () => import("./ha-selector-attribute"),
@@ -52,6 +53,7 @@ const LOAD_ELEMENTS = {
icon: () => import("./ha-selector-icon"),
media: () => import("./ha-selector-media"),
theme: () => import("./ha-selector-theme"),
timezone: () => import("./ha-selector-timezone"),
button_toggle: () => import("./ha-selector-button-toggle"),
trigger: () => import("./ha-selector-trigger"),
tts: () => import("./ha-selector-tts"),

View File

@@ -6,7 +6,7 @@ import {
mdiMenuOpen,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
@@ -30,7 +30,6 @@ import {
getPanelIcon,
getPanelIconPath,
getPanelTitle,
SHOW_AFTER_SPACER_PANELS,
} from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification";
@@ -39,6 +38,7 @@ import type { UpdateEntity } from "../data/update";
import { updateCanInstall } from "../data/update";
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types";
@@ -57,8 +57,6 @@ const SORT_VALUE_URL_PATHS = {
map: 2,
logbook: 3,
history: 4,
"developer-tools": 9,
config: 11,
};
const panelSorter = (
@@ -135,7 +133,6 @@ export const computePanels = memoizeOne(
}
const beforeSpacer: PanelInfo[] = [];
const afterSpacer: PanelInfo[] = [];
const allPanels = Object.values(panels).filter(
(panel) => !FIXED_PANELS.includes(panel.url_path)
@@ -153,10 +150,7 @@ export const computePanels = memoizeOne(
) {
return;
}
(SHOW_AFTER_SPACER_PANELS.includes(panel.url_path)
? afterSpacer
: beforeSpacer
).push(panel);
beforeSpacer.push(panel);
});
const reverseSort = [...panelsOrder].reverse();
@@ -164,16 +158,13 @@ export const computePanels = memoizeOne(
beforeSpacer.sort((a, b) =>
panelSorter(reverseSort, defaultPanel, a, b, locale.language)
);
afterSpacer.sort((a, b) =>
panelSorter(reverseSort, defaultPanel, a, b, locale.language)
);
return [beforeSpacer, afterSpacer];
return [beforeSpacer, []];
}
);
@customElement("ha-sidebar")
class HaSidebar extends SubscribeMixin(LitElement) {
class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -205,6 +196,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@query(".tooltip") private _tooltip!: HTMLDivElement;
@query(".before-spacer") private _scrollableList?: HTMLDivElement;
protected get scrollableElement(): HTMLElement | null {
return this._scrollableList as HTMLElement | null;
}
public hassSubscribe() {
return [
subscribeFrontendUserData(
@@ -260,14 +257,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return html`
${this._renderHeader()}
${this._renderAllPanels(selectedPanel)}
${this._renderDivider()}
<ha-md-list>
${this._renderNotifications()}
${this._renderUserItem(selectedPanel)}
</ha-md-list>
<div disabled class="bottom-spacer"></div>
<div class="tooltip"></div>
`;
<div class="tooltip"></div>`;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
@@ -275,12 +265,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
changedProps.has("expanded") ||
changedProps.has("narrow") ||
changedProps.has("alwaysExpand") ||
changedProps.has("_externalConfig") ||
changedProps.has("_updatesCount") ||
changedProps.has("_issuesCount") ||
changedProps.has("_notifications") ||
changedProps.has("_hiddenPanels") ||
changedProps.has("_panelOrder")
changedProps.has("_panelOrder") ||
changedProps.has("_contentScrolled") ||
changedProps.has("_contentScrollable")
) {
return true;
}
@@ -384,11 +375,30 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _renderAllPanels(selectedPanel: string) {
const renderList = (content, cls: string, scrollable: boolean) =>
html`<ha-md-list
class=${classMap({
"ha-scrollbar": scrollable,
[cls]: true,
})}
@focusin=${this._listboxFocusIn}
@focusout=${this._listboxFocusOut}
@touchend=${this._listboxTouchend}
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>${content}</ha-md-list
>`;
if (!this._panelOrder || !this._hiddenPanels) {
return html`
<ha-fade-in .delay=${500}>
<ha-spinner size="small"></ha-spinner>
</ha-fade-in>
${renderList(
html`${this._renderFixedPanels(selectedPanel)}`,
"after-spacer",
false
)}
`;
}
@@ -402,22 +412,36 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this.hass.locale
);
// prettier-ignore
return html`<div class="panels-list">
<div class="wrapper">
${renderList(
this._renderPanels(beforeSpacer, selectedPanel),
"before-spacer",
true
)}
${this.renderScrollableFades()}
</div>
${this._renderSpacer()}
${renderList(
html`
${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderFixedPanels(selectedPanel)}
`,
"after-spacer",
false
)}
</div>`;
}
private _renderFixedPanels(selectedPanel: string) {
// prettier-ignore
return html`
<ha-md-list
class="ha-scrollbar"
@focusin=${this._listboxFocusIn}
@focusout=${this._listboxFocusOut}
@touchend=${this._listboxTouchend}
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>
${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)}
${this.hass.user?.is_admin
? this._renderConfiguration(selectedPanel)
: this._renderExternalConfiguration()}
</ha-md-list>
${this.hass.user?.is_admin
? this._renderConfiguration(selectedPanel)
: this._renderExternalConfiguration()}
${this._renderNotifications()}
${this._renderUserItem(selectedPanel)}
`;
}
@@ -449,10 +473,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
`;
}
private _renderDivider() {
return html`<div class="divider"></div>`;
}
private _renderSpacer() {
return html`<div class="spacer" disabled></div>`;
}
@@ -474,21 +494,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
${!this.alwaysExpand &&
(this._updatesCount > 0 || this._issuesCount > 0)
? html`
<span class="badge" slot="start">
${this._updatesCount + this._issuesCount}
</span>
`
? html`<span class="badge" slot="start"
>${this._updatesCount + this._issuesCount}</span
>`
: nothing}
<span class="item-text" slot="headline"
>${this.hass.localize("panel.config")}</span
>
${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0)
? html`
<span class="badge" slot="end"
>${this._updatesCount + this._issuesCount}</span
>
`
? html`<span class="badge" slot="end"
>${this._updatesCount + this._issuesCount}</span
>`
: nothing}
</ha-md-list-item>
`;
@@ -509,9 +525,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
>
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
${!this.alwaysExpand && notificationCount > 0
? html`
<span class="badge" slot="start"> ${notificationCount} </span>
`
? html`<span class="badge" slot="start">${notificationCount}</span>`
: nothing}
<span class="item-text" slot="headline"
>${this.hass.localize("ui.notification_drawer.title")}</span
@@ -544,9 +558,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
.user=${this.hass.user}
.hass=${this.hass}
></ha-user-badge>
<span class="item-text" slot="headline">
${this.hass.user ? this.hass.user.name : ""}
</span>
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : ""}</span
>
</ha-md-list-item>
`;
}
@@ -563,9 +577,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@mouseleave=${this._itemMouseLeave}
>
<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>
<span class="item-text" slot="headline"
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
>
</ha-md-list-item>
`;
}
@@ -658,20 +672,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
}
const tooltip = this._tooltip;
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!;
const listbox = [...allListbox].find((lb) => lb.contains(item));
const top =
item.offsetTop +
11 +
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
const itemRect = item.getBoundingClientRect();
tooltip.innerText = itemText?.innerText ?? "";
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`;
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`;
tooltip.style.top = `${itemRect.top + itemRect.height / 2 - tooltip.offsetHeight / 2}px`;
tooltip.style.left = `calc(${itemRect.right + 8}px)`;
}
private _hideTooltip() {
@@ -695,8 +702,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
fireEvent(this, "hass-toggle-menu");
}
static get styles(): CSSResultGroup {
static get styles() {
return [
...super.styles,
haStyleScrollbar,
css`
:host {
@@ -763,15 +771,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
:host([expanded]) .title {
display: initial;
}
.hidden-panel {
display: none;
}
ha-fade-in,
ha-md-list {
.panels-list {
display: flex;
flex-direction: column;
height: calc(
100% - var(--header-height) - var(--safe-area-inset-top, 0px) -
132px
100vh - var(--header-height) - var(--safe-area-inset-top, 0px)
);
}
@@ -781,6 +786,10 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: flex;
justify-content: center;
align-items: center;
height: calc(
100vh - var(--header-height) - var(--safe-area-inset-top, 0px) -
152px
); /* 152px = three list items w/o padding-top */
}
ha-md-list {
@@ -789,6 +798,21 @@ class HaSidebar extends SubscribeMixin(LitElement) {
margin-left: var(--safe-area-inset-left, 0px);
}
.wrapper {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
}
ha-md-list.before-spacer {
padding-bottom: 0;
}
ha-md-list.after-spacer {
padding-top: 0;
min-height: fit-content;
}
ha-md-list-item {
flex-shrink: 0;
box-sizing: border-box;
@@ -854,16 +878,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
white-space: nowrap;
}
.divider {
bottom: 112px;
padding: 10px 0;
}
.divider::before {
content: " ";
display: block;
height: 1px;
background-color: var(--divider-color);
}
.badge {
display: flex;
justify-content: center;
@@ -902,18 +916,10 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
.spacer {
flex: 1;
margin-top: auto;
pointer-events: none;
}
.subheader {
color: var(--sidebar-text-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
padding: var(--ha-space-4);
white-space: nowrap;
}
.tooltip {
display: none;
position: absolute;

View File

@@ -1,56 +1,102 @@
import timezones from "google-timezones-json";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import "./ha-list-item";
import "./ha-select";
import type { HaSelect } from "./ha-select";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
const SEARCH_KEYS = [
{ name: "primary", weight: 10 },
{ name: "secondary", weight: 8 },
];
export const getTimezoneOptions = (): PickerComboBoxItem[] =>
Object.entries(timezones as Record<string, string>).map(([key, value]) => ({
id: key,
primary: value,
secondary: key,
}));
@customElement("ha-timezone-picker")
export class HaTimeZonePicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property() public value?: string;
@property() public label?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean, attribute: "hide-clear-icon" })
public hideClearIcon = false;
private _getTimezoneOptions = memoizeOne(getTimezoneOptions);
private _getItems = () => this._getTimezoneOptions();
private _getTimezoneName = (tz?: string) =>
this._getItems().find((t) => t.id === tz)?.primary;
private _valueRenderer = (value: string) =>
html`<span slot="headline">${this._getTimezoneName(value) ?? value}</span>`;
protected render() {
const label =
this.label ??
(this.hass?.localize("ui.components.timezone-picker.time_zone") ||
"Time zone");
return html`
<ha-select
.label=${this.label}
<ha-generic-picker
.hass=${this.hass}
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.timezone-picker.no_timezones"
) || "No time zones available"}
.label=${label}
.helper=${this.helper}
.placeholder=${this.placeholder}
.value=${this.value}
.required=${this.required}
.valueRenderer=${this._valueRenderer}
.disabled=${this.disabled}
@selected=${this._changed}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
>
${Object.entries(timezones).map(
([key, value]) =>
html`<ha-list-item value=${key}>${value}</ha-list-item>`
)}
</ha-select>
.required=${this.required}
.getItems=${this._getItems}
.searchKeys=${SEARCH_KEYS}
.hideClearIcon=${this.hideClearIcon}
@value-changed=${this._changed}
></ha-generic-picker>
`;
}
static styles = css`
ha-select {
ha-generic-picker {
width: 100%;
min-width: 200px;
display: block;
}
`;
private _changed(ev): void {
const target = ev.target as HaSelect;
if (target.value === "" || target.value === this.value) {
return;
}
this.value = target.value;
private _changed(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
private _notFoundLabel = (search: string) => {
const term = html`<b>'${search}'</b>`;
return this.hass
? this.hass.localize("ui.components.timezone-picker.no_match", { term })
: html`No time zones found for ${term}`;
};
}
declare global {

View File

@@ -42,9 +42,12 @@ export class HaTopAppBarFixed extends TopAppBarFixedBase {
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: 24px;
padding-inline-start: var(--ha-space-6);
padding-inline-end: initial;
}
:host([narrow]) .mdc-top-app-bar__title {
padding-inline-start: var(--ha-space-2);
}
`,
];
}

View File

@@ -2,6 +2,7 @@ import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { stopPropagation } from "../../common/dom/stop_propagation";
import type { ActionHandlerOptions } from "../../data/lovelace/action_handler";
import { actionHandler } from "../../panels/lovelace/common/directives/action-handler-directive";
import "../ha-ripple";
@@ -47,7 +48,11 @@ export class HaTileContainer extends LitElement {
>
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div class="container ${containerOrientationClass}">
<div
class="container ${containerOrientationClass}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>

View File

@@ -1,8 +1,13 @@
import {
computeCssColor,
isValidColorString,
} from "../common/color/compute-color";
import { getColorByIndex } from "../common/color/colors";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import type { HomeAssistant } from "../types";
import { isUnavailableState } from "./entity/entity";
import type { EntityRegistryEntry } from "./entity/entity_registry";
export interface Calendar {
entity_id: string;
@@ -139,9 +144,13 @@ const getCalendarDate = (dateObj: any): string | undefined => {
export const getCalendars = (
hass: HomeAssistant,
element: Element
element: Element,
entityRegistry?: EntityRegistryEntry[]
): Calendar[] => {
const computedStyles = getComputedStyle(element);
const entityOptionsMap = new Map(
entityRegistry?.map((entry) => [entry.entity_id, entry.options]) ?? []
);
return Object.keys(hass.states)
.filter(
(eid) =>
@@ -150,11 +159,23 @@ export const getCalendars = (
hass.entities[eid]?.hidden !== true
)
.sort()
.map((eid, idx) => ({
...hass.states[eid],
name: computeStateName(hass.states[eid]),
backgroundColor: getColorByIndex(idx, computedStyles),
}));
.map((eid, idx) => {
const stateObj = hass.states[eid];
const entityColor = entityOptionsMap.get(eid)?.calendar?.color;
let backgroundColor: string;
// Validate and use the color from entity registry if valid
if (entityColor && isValidColorString(entityColor)) {
backgroundColor = computeCssColor(entityColor);
} else {
// Fall back to default color by index
backgroundColor = getColorByIndex(idx, computedStyles);
}
return {
...stateObj,
name: computeStateName(stateObj),
backgroundColor,
};
});
};
export const createCalendarEvent = (

View File

@@ -131,11 +131,30 @@ export interface FlowToGridSourceEnergyPreference {
number_energy_price: number | null;
}
export interface GridPowerSourceEnergyPreference {
// W meter
stat_rate: string;
export interface PowerConfig {
stat_rate?: string; // Standard single sensor
stat_rate_inverted?: string; // Inverted single sensor
stat_rate_from?: string; // Battery: discharge / Grid: consumption
stat_rate_to?: string; // Battery: charge / Grid: return
}
export interface GridPowerSourceEnergyPreference {
stat_rate: string;
power_config?: PowerConfig;
}
/**
* Input type for saving grid power sources.
* Core requires EITHER stat_rate (legacy) OR power_config (new format).
* When reading from backend, stat_rate is always populated.
*/
export type GridPowerSourceInput = Omit<
GridPowerSourceEnergyPreference,
"stat_rate"
> & {
stat_rate?: string;
};
export interface GridSourceTypeEnergyPreference {
type: "grid";
@@ -159,6 +178,7 @@ export interface BatterySourceTypeEnergyPreference {
stat_energy_from: string;
stat_energy_to: string;
stat_rate?: string;
power_config?: PowerConfig;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";

View File

@@ -103,6 +103,10 @@ export interface AlarmControlPanelEntityOptions {
default_code?: string | null;
}
export interface CalendarEntityOptions {
color?: string | null;
}
export interface WeatherEntityOptions {
precipitation_unit?: string | null;
pressure_unit?: string | null;
@@ -120,6 +124,7 @@ export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
alarm_control_panel?: AlarmControlPanelEntityOptions;
calendar?: CalendarEntityOptions;
lock?: LockEntityOptions;
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
@@ -143,6 +148,7 @@ export interface EntityRegistryEntryUpdateParams {
| NumberEntityOptions
| LockEntityOptions
| AlarmControlPanelEntityOptions
| CalendarEntityOptions
| WeatherEntityOptions
| LightEntityOptions;
aliases?: string[];

View File

@@ -13,10 +13,13 @@ export interface SidebarFrontendUserData {
export interface CoreFrontendSystemData {
default_panel?: string;
onboarded_version?: string;
onboarded_date?: string;
}
export interface HomeFrontendSystemData {
favorite_entities?: string[];
welcome_banner_dismissed?: boolean;
}
declare global {

View File

@@ -78,14 +78,16 @@ export const findIconChunk = (icon: string): string => {
export const writeCache = async (chunks: Chunks) => {
const keys = Object.keys(chunks);
const iconsSets: Icons[] = await Promise.all(Object.values(chunks));
const results = await Promise.allSettled(Object.values(chunks));
const iconStore = await getStore();
// We do a batch opening the store just once, for (considerable) performance
iconStore("readwrite", (store) => {
iconsSets.forEach((icons, idx) => {
Object.entries(icons).forEach(([name, path]) => {
store.put(path, name);
});
results.forEach((result, idx) => {
if (result.status === "fulfilled") {
Object.entries(result.value).forEach(([name, path]) => {
store.put(path, name);
});
}
delete chunks[keys[idx]];
});
});

View File

@@ -1,6 +1,10 @@
import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../types";
export interface LovelaceInfo {
resource_mode: "yaml" | "storage";
}
export interface LovelaceResource {
id: string;
type: "css" | "js" | "module" | "html";
@@ -42,3 +46,8 @@ export const deleteResource = (hass: HomeAssistant, id: string) =>
type: "lovelace/resources/delete",
resource_id: id,
});
export const fetchLovelaceInfo = (hass: HomeAssistant): Promise<LovelaceInfo> =>
hass.callWS({
type: "lovelace/info",
});

View File

@@ -4,29 +4,37 @@ import {
mdiChartBox,
mdiClipboardList,
mdiFormatListBulletedType,
mdiHammer,
mdiLightningBolt,
mdiPlayBoxMultiple,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js";
import type { HomeAssistant, PanelInfo } from "../types";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import type { LocalizeKeys } from "../common/translations/localize";
/** Panel to show when no panel is picked. */
export const DEFAULT_PANEL = "lovelace";
export const DEFAULT_PANEL = "home";
export const hasLegacyOverviewPanel = (hass: HomeAssistant): boolean =>
Boolean(hass.panels.lovelace?.config);
export const getLegacyDefaultPanelUrlPath = (): string | null => {
const defaultPanel = window.localStorage.getItem("defaultPanel");
return defaultPanel ? JSON.parse(defaultPanel) : null;
};
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string =>
hass.userData?.default_panel ||
hass.systemData?.default_panel ||
getLegacyDefaultPanelUrlPath() ||
DEFAULT_PANEL;
export const getDefaultPanelUrlPath = (hass: HomeAssistant): string => {
const defaultPanel =
hass.userData?.default_panel ||
hass.systemData?.default_panel ||
getLegacyDefaultPanelUrlPath() ||
DEFAULT_PANEL;
// If default panel is lovelace and no old overview exists, fall back to home
if (defaultPanel === "lovelace" && !hasLegacyOverviewPanel(hass)) {
return DEFAULT_PANEL;
}
return defaultPanel;
};
export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
const panel = getDefaultPanelUrlPath(hass);
@@ -35,10 +43,6 @@ export const getDefaultPanel = (hass: HomeAssistant): PanelInfo => {
};
export const getPanelNameTranslationKey = (panel: PanelInfo) => {
if (panel.url_path === "lovelace") {
return "panel.states" as const;
}
if (panel.url_path === "profile") {
return "panel.profile" as const;
}
@@ -113,8 +117,6 @@ export const getPanelIcon = (panel: PanelInfo): string | undefined => {
switch (panel.component_name) {
case "profile":
return "mdi:account";
case "lovelace":
return "mdi:view-dashboard";
}
}
@@ -123,13 +125,11 @@ export const getPanelIcon = (panel: PanelInfo): string | undefined => {
export const PANEL_ICON_PATHS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
history: mdiChartBox,
logbook: mdiFormatListBulletedType,
lovelace: mdiViewDashboard,
profile: mdiAccount,
map: mdiTooltipAccount,
profile: mdiAccount,
"media-browser": mdiPlayBoxMultiple,
todo: mdiClipboardList,
};
@@ -138,4 +138,3 @@ export const getPanelIconPath = (panel: PanelInfo): string | undefined =>
PANEL_ICON_PATHS[panel.url_path];
export const FIXED_PANELS = ["profile", "config"];
export const SHOW_AFTER_SPACER_PANELS = ["developer-tools"];

View File

@@ -1,22 +1,24 @@
import {
mdiKeyboard,
mdiNavigationVariant,
mdiPuzzle,
mdiReload,
mdiServerNetwork,
mdiStorePlus,
} from "@mdi/js";
import { canShowPage } from "../common/config/can_show_page";
import {
filterNavigationPages,
type NavigationFilterOptions,
} from "../common/config/filter_navigation_pages";
import { componentsWithService } from "../common/config/components_with_service";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
import { configSections } from "../panels/config/ha-panel-config";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HassioAddonInfo } from "./hassio/addon";
import { domainToName } from "./integration";
import { getPanelIcon, getPanelNameTranslationKey } from "./panel";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
export interface NavigationComboBoxItem extends PickerComboBoxItem {
path: string;
@@ -27,6 +29,7 @@ export interface NavigationComboBoxItem extends PickerComboBoxItem {
export interface BaseNavigationCommand {
path: string;
primary: string;
secondary?: string;
icon_path?: string;
iconPath?: string;
iconColor?: string;
@@ -45,11 +48,14 @@ export interface NavigationInfo extends PageNavigation {
const generateNavigationPanelCommands = (
localize: HomeAssistant["localize"],
panels: HomeAssistant["panels"],
addons?: HassioAddonInfo[]
apps?: HassioAddonInfo[]
): BaseNavigationCommand[] =>
Object.entries(panels)
.filter(
([panelKey]) => panelKey !== "_my_redirect" && panelKey !== "hassio"
([panelKey]) =>
panelKey !== "_my_redirect" &&
panelKey !== "hassio" &&
panelKey !== "app"
)
.map(([_panelKey, panel]) => {
const translationKey = getPanelNameTranslationKey(panel);
@@ -59,12 +65,10 @@ const generateNavigationPanelCommands = (
let image: string | undefined;
if (addons) {
const addon = addons.find(({ slug }) => slug === panel.url_path);
if (addon) {
image = addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined;
if (apps) {
const app = apps.find(({ slug }) => slug === panel.url_path);
if (app) {
image = app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined;
}
}
@@ -98,33 +102,30 @@ const getNavigationInfoFromConfig = (
};
const generateNavigationConfigSectionCommands = (
hass: HomeAssistant
hass: HomeAssistant,
filterOptions: NavigationFilterOptions = {}
): BaseNavigationCommand[] => {
if (!hass.user?.is_admin) {
return [];
}
const items: NavigationInfo[] = [];
const allPages = Object.values(configSections).flat();
const visiblePages = filterNavigationPages(hass, allPages, filterOptions);
Object.values(configSections).forEach((sectionPages) => {
sectionPages.forEach((page) => {
if (!canShowPage(hass, page)) {
return;
}
for (const page of visiblePages) {
const info = getNavigationInfoFromConfig(hass.localize, page);
const info = getNavigationInfoFromConfig(hass.localize, page);
if (!info) {
continue;
}
// Add to list, but only if we do not already have an entry for the same path and component
if (items.some((e) => e.path === info.path)) {
continue;
}
if (!info) {
return;
}
// Add to list, but only if we do not already have an entry for the same path and component
if (items.some((e) => e.path === info.path)) {
return;
}
items.push(info);
});
});
items.push(info);
}
return items;
};
@@ -140,7 +141,7 @@ const finalizeNavigationCommands = (
return {
id: `navigation_${index}_${item.path}`,
icon_path: item.iconPath || mdiNavigationVariant,
secondary,
secondary: item.secondary || secondary,
sorting_label: `${item.primary}_${secondary}`,
...item,
};
@@ -148,41 +149,42 @@ const finalizeNavigationCommands = (
export const generateNavigationCommands = (
hass: HomeAssistant,
addons?: HassioAddonInfo[]
apps?: HassioAddonInfo[],
filterOptions: NavigationFilterOptions = {}
): NavigationComboBoxItem[] => {
const panelItems = generateNavigationPanelCommands(
hass.localize,
hass.panels,
addons
apps
);
const sectionItems = generateNavigationConfigSectionCommands(hass);
const supervisorItems: BaseNavigationCommand[] = [];
const sectionItems = generateNavigationConfigSectionCommands(
hass,
filterOptions
);
const appItems: BaseNavigationCommand[] = [];
if (hass.user?.is_admin && isComponentLoaded(hass, "hassio")) {
supervisorItems.push({
path: "/hassio/store",
appItems.push({
path: "/config/apps/available",
icon_path: mdiStorePlus,
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.addon_store"
"ui.dialogs.quick-bar.commands.navigation.app_store"
),
iconColor: "#F1C447",
});
supervisorItems.push({
path: "/hassio/dashboard",
icon_path: mdiPuzzle,
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.addon_dashboard"
),
});
if (addons) {
for (const addon of addons.filter((a) => a.version)) {
supervisorItems.push({
path: `/hassio/addon/${addon.slug}`,
image: addon.icon
? `/api/hassio/addons/${addon.slug}/icon`
: undefined,
if (apps) {
for (const app of apps.filter((a) => a.version)) {
appItems.push({
path: `/config/app/${app.slug}`,
image: app.icon ? `/api/hassio/addons/${app.slug}/icon` : undefined,
primary: hass.localize(
"ui.dialogs.quick-bar.commands.navigation.addon_info",
{ addon: addon.name }
"ui.dialogs.quick-bar.commands.navigation.app_info",
{ app: app.name }
),
secondary: hass.localize(
"ui.dialogs.quick-bar.commands.types.app_settings"
),
iconColor: "#F1C447",
});
}
}
@@ -201,7 +203,7 @@ export const generateNavigationCommands = (
return finalizeNavigationCommands(hass.localize, [
...panelItems,
...sectionItems,
...supervisorItems,
...appItems,
...additionalItems,
]);
};
@@ -265,16 +267,16 @@ const generateServerControlCommands = (
return serverActions.map((action, index) => {
const primary = hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action",
"ui.dialogs.quick-bar.commands.home_assistant_control.perform_action",
{
action: hass.localize(
`ui.dialogs.quick-bar.commands.server_control.${action}`
`ui.dialogs.quick-bar.commands.home_assistant_control.${action}`
),
}
);
const secondary = hass.localize(
"ui.dialogs.quick-bar.commands.types.server_control"
"ui.dialogs.quick-bar.commands.types.home_assistant_control"
);
return {

View File

@@ -7,7 +7,10 @@ import type { EntityNameItem } from "../common/entity/compute_entity_name_displa
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
import { isHelperDomain } from "../panels/config/helpers/const";
import type { UiAction } from "../panels/lovelace/components/hui-action-editor";
import type {
ActionRelatedContext,
UiAction,
} from "../panels/lovelace/components/hui-action-editor";
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
@@ -22,6 +25,7 @@ import type { EntitySources } from "./entity/entity_sources";
export type Selector =
| ActionSelector
| AddonSelector
| AppSelector
| AreaSelector
| AreasDisplaySelector
| AttributeSelector
@@ -65,6 +69,7 @@ export type Selector =
| TemplateSelector
| ThemeSelector
| TimeSelector
| TimezoneSelector
| TriggerSelector
| TTSSelector
| TTSVoiceSelector
@@ -80,7 +85,11 @@ export interface ActionSelector {
}
export interface AddonSelector {
addon: {
addon: AppSelector["app"];
}
export interface AppSelector {
app: {
name?: string;
slug?: string;
} | null;
@@ -293,6 +302,10 @@ export interface LanguageSelector {
} | null;
}
export interface TimezoneSelector {
timezone: {} | null;
}
export interface LocationSelector {
location: {
radius?: boolean;
@@ -332,7 +345,7 @@ export interface MediaSelectorValue {
}
export interface NavigationSelector {
navigation: {} | null;
navigation: ActionRelatedContext | null;
}
export interface NumberSelector {
@@ -460,7 +473,9 @@ export interface TargetSelector {
}
export interface TemplateSelector {
template: {} | null;
template: {
preview?: boolean;
} | null;
}
export interface ThemeSelector {

View File

@@ -2,7 +2,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../components/ha-attributes";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-assist-chat";
import "../../../components/ha-spinner";

View File

@@ -3,7 +3,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes";
import "../../../components/ha-icon-button-group";
import "../../../components/ha-icon-button-toggle";
import type { CoverEntity } from "../../../data/cover";
@@ -176,11 +175,6 @@ class MoreInfoCover extends LitElement {
}
</div>
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
extra-filters="current_position,current_tilt_position"
></ha-attributes>
`;
}

View File

@@ -1,7 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-attributes";
import type { HomeAssistant } from "../../../types";
@customElement("more-info-default")
@@ -15,10 +14,7 @@ class MoreInfoDefault extends LitElement {
return nothing;
}
return html`<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-attributes>`;
return nothing;
}
}

View File

@@ -3,7 +3,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-attributes";
import "../../../state-control/ha-state-control-toggle";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-state-header";
@@ -33,10 +32,6 @@ class MoreInfoInputBoolean extends LitElement {
.iconPathOff=${mdiPowerOff}
></ha-state-control-toggle>
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-attributes>
`;
}

View File

@@ -12,7 +12,6 @@ import { customElement, property, state } from "lit/decorators";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attribute-icon";
import "../../../components/ha-attributes";
import "../../../components/ha-control-select-menu";
import "../../../components/ha-icon-button-group";
import "../../../components/ha-icon-button-toggle";
@@ -299,11 +298,6 @@ class MoreInfoLight extends LitElement {
`
: nothing}
</ha-more-info-control-select-container>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
extra-filters="brightness,color_temp,color_temp_kelvin,white_value,effect_list,effect,hs_color,rgb_color,rgbw_color,rgbww_color,xy_color,min_mireds,max_mireds,min_color_temp_kelvin,max_color_temp_kelvin,entity_id,supported_color_modes,color_mode"
></ha-attributes>
</div>
`;
}

View File

@@ -5,7 +5,6 @@ import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import "../../../components/ha-outlined-icon-button";
@@ -151,11 +150,6 @@ class MoreInfoLock extends LitElement {
</ha-control-button-group>
`
: nothing}
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
extra-filters="code_format"
></ha-attributes>
</div>
`;
}
@@ -191,9 +185,6 @@ class MoreInfoLock extends LitElement {
max-width: 400px;
margin: 0 auto;
}
ha-control-button-group + ha-attributes:not([empty]) {
margin-top: var(--ha-space-4);
}
@keyframes pulse {
0% {
opacity: 1;

View File

@@ -3,7 +3,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-attributes";
import "../../../components/ha-button";
import "../../../components/map/ha-map";
import { showZoneEditor } from "../../../data/zone";
@@ -50,11 +49,6 @@ class MoreInfoPerson extends LitElement {
</div>
`
: ""}
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
extra-filters="id,user_id,editable,device_trackers"
></ha-attributes>
`;
}

View File

@@ -2,15 +2,12 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes";
import type { RemoteEntity } from "../../../data/remote";
import { REMOTE_SUPPORT_ACTIVITY } from "../../../data/remote";
import type { HomeAssistant } from "../../../types";
import "../../../components/ha-select";
import "../../../components/ha-list-item";
const filterExtraAttributes = "activity_list,current_activity";
@customElement("more-info-remote")
class MoreInfoRemote extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -51,12 +48,6 @@ class MoreInfoRemote extends LitElement {
</ha-select>
`
: nothing}
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
.extraFilters=${filterExtraAttributes}
></ha-attributes>
`;
}

View File

@@ -3,7 +3,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-attributes";
import "../../../state-control/ha-state-control-toggle";
import "../../../components/ha-button";
import type { HomeAssistant } from "../../../types";
@@ -60,10 +59,6 @@ class MoreInfoSiren extends LitElement {
</ha-button>`
: nothing}
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-attributes>
`;
}

View File

@@ -3,7 +3,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-attributes";
import "../../../state-control/ha-state-control-toggle";
import type { HomeAssistant } from "../../../types";
import "../components/ha-more-info-state-header";
@@ -33,10 +32,6 @@ class MoreInfoSwitch extends LitElement {
.iconPathOff=${mdiPowerOff}
></ha-state-control-toggle>
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-attributes>
`;
}

View File

@@ -1,6 +1,5 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-attributes";
import "../../../components/ha-button";
import type { TimerEntity } from "../../../data/timer";
import type { HomeAssistant } from "../../../types";
@@ -63,11 +62,6 @@ class MoreInfoTimer extends LitElement {
`
: ""}
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
extra-filters="remaining,restore"
></ha-attributes>
`;
}

View File

@@ -15,7 +15,6 @@ import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-attributes";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
@@ -110,9 +109,6 @@ class MoreInfoVacuum extends LitElement {
const stateObj = this.stateObj;
const filterExtraAttributes =
"fan_speed,fan_speed_list,status,battery_level,battery_icon";
return html`
${stateObj.state !== UNAVAILABLE
? html` <div class="flex-horizontal">
@@ -208,12 +204,6 @@ class MoreInfoVacuum extends LitElement {
</div>
`
: ""}
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
.extraFilters=${filterExtraAttributes}
></ha-attributes>
`;
}

View File

@@ -3,7 +3,6 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes";
import "../../../components/ha-icon-button-group";
import "../../../components/ha-icon-button-toggle";
import type { ValveEntity } from "../../../data/valve";
@@ -155,11 +154,6 @@ class MoreInfoValve extends LitElement {
}
</div>
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
extra-filters="current_position,current_tilt_position"
></ha-attributes>
`;
}

View File

@@ -0,0 +1,152 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import "../../components/ha-attribute-value";
import "../../components/ha-card";
import {
STATE_ATTRIBUTES,
STATE_ATTRIBUTES_DOMAIN_CLASS,
} from "../../data/entity/entity_attributes";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
interface AttributesViewParams {
entityId: string;
}
@customElement("ha-more-info-attributes")
class HaMoreInfoAttributes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public params?: AttributesViewParams;
@state() private _stateObj?: HassEntity;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("params") || changedProps.has("hass")) {
if (this.params?.entityId && this.hass) {
this._stateObj = this.hass.states[this.params.entityId];
}
}
}
private _computeDisplayAttributes(stateObj: HassEntity): string[] {
const domain = computeStateDomain(stateObj);
const filtersArray = STATE_ATTRIBUTES.concat(
(STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
stateObj.attributes?.device_class
] || []) as string[]
);
return Object.keys(stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
}
protected render() {
if (!this.params || !this._stateObj) {
return nothing;
}
const attributes = this._computeDisplayAttributes(this._stateObj);
return html`
<div class="content">
<ha-card>
<div class="card-content">
${attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.hass=${this.hass}
.attribute=${attribute}
.stateObj=${this._stateObj}
></ha-attribute-value>
</div>
</div>
`
)}
</div>
</ha-card>
${this._stateObj.attributes.attribution
? html`
<div class="attribution">
${this._stateObj.attributes.attribution}
</div>
`
: nothing}
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
padding: var(--ha-space-6);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-6));
}
ha-card {
direction: ltr;
}
.card-content {
padding: var(--ha-space-2) var(--ha-space-4);
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: var(--ha-space-2) 0;
border-bottom: 1px solid var(--divider-color);
}
.data-entry:last-of-type {
border-bottom: none;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
color: var(--secondary-text-color);
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
margin-top: var(--ha-space-4);
font-size: var(--ha-font-size-s);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-attributes": HaMoreInfoAttributes;
}
}

View File

@@ -4,6 +4,7 @@ import {
mdiCogOutline,
mdiDevices,
mdiDotsVertical,
mdiFormatListBulletedSquare,
mdiInformationOutline,
mdiPencil,
mdiPencilOff,
@@ -24,6 +25,7 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import {
computeEntityEntryName,
computeEntityName,
@@ -43,6 +45,10 @@ import type { HaDropdownItem } from "../../components/ha-dropdown-item";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-prev";
import "../../components/ha-related-items";
import {
STATE_ATTRIBUTES,
STATE_ATTRIBUTES_DOMAIN_CLASS,
} from "../../data/entity/entity_attributes";
import type {
EntityRegistryEntry,
ExtEntityRegistryEntry,
@@ -89,6 +95,7 @@ interface ChildView {
viewTitle?: string;
viewImport?: () => Promise<unknown>;
viewParams?: any;
keepHeader?: boolean;
}
declare global {
@@ -331,9 +338,41 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
case "info":
this._resetInitialView();
break;
case "attributes":
this._showAttributes();
break;
}
}
private _showAttributes(): void {
import("./ha-more-info-attributes");
this._childView = {
viewTag: "ha-more-info-attributes",
viewParams: { entityId: this._entityId },
keepHeader: true,
};
}
private _hasDisplayableAttributes(): boolean {
if (!this._entityId) {
return false;
}
const stateObj = this.hass.states[this._entityId];
if (!stateObj) {
return false;
}
const domain = computeStateDomain(stateObj);
const filtersArray = STATE_ATTRIBUTES.concat(
(STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
stateObj.attributes?.device_class
] || []) as string[]
);
const displayAttributes = Object.keys(stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
return displayAttributes.length > 0;
}
private _goToAddEntityTo(ev) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
@@ -366,12 +405,17 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
const deviceType =
(deviceId && this.hass.devices[deviceId].entry_type) || "device";
const isDefaultView = this._currView === DEFAULT_VIEW && !this._childView;
const isDefaultView =
this._currView === DEFAULT_VIEW &&
(!this._childView || this._childView.keepHeader);
const isSpecificInitialView =
this._initialView !== DEFAULT_VIEW && !this._childView;
this._initialView !== DEFAULT_VIEW &&
(!this._childView || this._childView.keepHeader);
const showCloseIcon =
(isDefaultView && this._parentEntityIds.length === 0) ||
isSpecificInitialView;
(isDefaultView &&
this._parentEntityIds.length === 0 &&
!this._childView) ||
(isSpecificInitialView && !this._childView);
const context = stateObj
? getEntityContext(
@@ -405,7 +449,12 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
const breadcrumb = [areaName, deviceName, entityName].filter(
(v): v is string => Boolean(v)
);
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
const title =
(this._childView && !this._childView.keepHeader
? this._childView.viewTitle
: undefined) ||
breadcrumb.pop() ||
entityId;
const isRTL = computeRTL(this.hass);
@@ -554,6 +603,19 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
"ui.dialogs.more_info_control.related"
)}
</ha-dropdown-item>
${this._hasDisplayableAttributes()
? html`
<ha-dropdown-item value="attributes">
<ha-svg-icon
slot="icon"
.path=${mdiFormatListBulletedSquare}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.attributes"
)}
</ha-dropdown-item>
`
: nothing}
${this._shouldShowAddEntityTo()
? html`
<ha-dropdown-item value="add_to">

View File

@@ -1,8 +1,10 @@
import { mdiDevices } from "@mdi/js";
import Fuse from "fuse.js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { NavigationFilterOptions } from "../../common/config/filter_navigation_pages";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
@@ -15,6 +17,7 @@ import "../../components/ha-icon";
import "../../components/ha-picker-combo-box";
import type {
HaPickerComboBox,
PickerComboBoxIndexSelectedDetail,
PickerComboBoxItem,
} from "../../components/ha-picker-combo-box";
import "../../components/ha-spinner";
@@ -48,8 +51,10 @@ import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../../resources/fuseMultiTerm";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { isIosApp } from "../../util/is_ios";
import { isMac } from "../../util/is_mac";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import type { QuickBarParams, QuickBarSection } from "./show-dialog-quick-bar";
@@ -64,7 +69,7 @@ export class QuickBar extends LitElement {
@state() private _loading = true;
@state() private _hint?: string;
@state() private _showHint = false;
@state() private _selectedSection?: QuickBarSection;
@@ -80,8 +85,12 @@ export class QuickBar extends LitElement {
private _addons?: HassioAddonInfo[];
private _navigationFilterOptions: NavigationFilterOptions = {};
private _translationsLoaded = false;
private _itemSelected = false;
// #region lifecycle
public async showDialog(params: QuickBarParams) {
if (!this._translationsLoaded) {
@@ -90,7 +99,7 @@ export class QuickBar extends LitElement {
}
this._initialize();
this._selectedSection = params.mode;
this._hint = params.hint;
this._showHint = params.showHint ?? false;
this._open = true;
}
@@ -104,6 +113,12 @@ export class QuickBar extends LitElement {
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
// Derive Bluetooth config entries status for navigation filtering
this._navigationFilterOptions = {
hasBluetoothConfigEntries: configEntries.some(
(entry) => entry.domain === "bluetooth"
),
};
} catch (err) {
// eslint-disable-next-line no-console
console.error("Error fetching config entries for quick bar", err);
@@ -152,15 +167,28 @@ export class QuickBar extends LitElement {
this._selectedSection = undefined;
this._opened = false;
this._open = false;
this._itemSelected = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
};
// fallback in case the closed event is not fired
private _dialogCloseStarted = () => {
setTimeout(
() => {
if (this._opened) {
this._dialogClosed();
}
},
350 // close animation timeout is 300ms
);
};
// #endregion lifecycle
// #region render
protected render() {
if (!this._open) {
if (!this._open && !this._opened) {
return nothing;
}
@@ -210,6 +238,7 @@ export class QuickBar extends LitElement {
hideActions
@wa-show=${this._showTriggered}
@wa-after-show=${this._dialogOpened}
@wa-hide=${this._dialogCloseStarted}
@closed=${this._dialogClosed}
>
${!this._loading && this._opened
@@ -230,9 +259,17 @@ export class QuickBar extends LitElement {
clearable
></ha-picker-combo-box>`
: nothing}
${this._hint
${this._showHint
? html`<ha-tip slot="footer" .hass=${this.hass}
>${this._hint}</ha-tip
>${this.hass.localize("ui.tips.key_shortcut_quick_search", {
keyboard_shortcut: html`<button
class="link"
@click=${this._openShortcutDialog}
>
${this.hass.localize("ui.tips.keyboard_shortcut")}
</button>`,
modifier: isMac ? "⌘" : "Ctrl",
})}</ha-tip
>`
: nothing}
</ha-adaptive-dialog>
@@ -281,6 +318,9 @@ export class QuickBar extends LitElement {
slot="start"
alt=${item.primary ?? "Unknown"}
.src=${item.image}
style=${"iconColor" in item && item.iconColor
? `background-color: ${item.iconColor}; padding: 4px; border-radius: var(--ha-border-radius-circle); width: 24px; height: 24px`
: ""}
/>
`
: item.icon
@@ -393,7 +433,8 @@ export class QuickBar extends LitElement {
if (!section || section === "navigate") {
let navigateItems = this._generateNavigationCommandsMemoized(
this.hass,
this._addons
this._addons,
this._navigationFilterOptions
).sort(this._sortBySortingLabel);
if (filter) {
@@ -559,7 +600,11 @@ export class QuickBar extends LitElement {
);
private _generateNavigationCommandsMemoized = memoizeOne(
generateNavigationCommands
(
hass: HomeAssistant,
apps: HassioAddonInfo[] | undefined,
filterOptions: NavigationFilterOptions
) => generateNavigationCommands(hass, apps, filterOptions)
);
private _generateActionCommandsMemoized = memoizeOne(generateActionCommands);
@@ -613,13 +658,29 @@ export class QuickBar extends LitElement {
// #region interaction
private async _handleItemSelected(ev: CustomEvent<{ index: number }>) {
if (this._comboBox && this._comboBox.virtualizerElement) {
const index = ev.detail.index;
private _navigate(path: string, newTab = false) {
if (newTab) {
window.open(path, "_blank", "noreferrer");
} else {
navigate(path);
}
}
private async _handleItemSelected(
ev: CustomEvent<PickerComboBoxIndexSelectedDetail>
) {
if (
!this._itemSelected &&
this._comboBox &&
this._comboBox.virtualizerElement
) {
const { index, newTab } = ev.detail;
const item = this._comboBox.virtualizerElement.items[
index
] as PickerComboBoxItem;
this._itemSelected = true;
// entity selected
if (item && "stateObj" in item) {
this.closeDialog();
@@ -631,15 +692,17 @@ export class QuickBar extends LitElement {
// device selected
if (item && item.id.startsWith(`device${SEPARATOR}`)) {
const path = `/config/devices/device/${item.id.split(SEPARATOR)[1]}`;
this.closeDialog();
navigate(`/config/devices/device/${item.id.split(SEPARATOR)[1]}`);
this._navigate(path, newTab);
return;
}
// area selected
if (item && item.id.startsWith(`area${SEPARATOR}`)) {
const path = `/config/areas/area/${item.id.split(SEPARATOR)[1]}`;
this.closeDialog();
navigate(`/config/areas/area/${item.id.split(SEPARATOR)[1]}`);
this._navigate(path, newTab);
return;
}
@@ -693,53 +756,65 @@ export class QuickBar extends LitElement {
return;
}
navigate((item as NavigationComboBoxItem).path);
const path = (item as NavigationComboBoxItem).path;
this._navigate(path, newTab);
}
}
}
private _openShortcutDialog(ev: Event): void {
ev.preventDefault();
showShortcutsDialog(this);
this.closeDialog();
}
// #endregion interaction
// #region styles
static styles = css`
:host {
--dialog-surface-margin-top: var(--ha-space-10);
--ha-dialog-min-height: 620px;
--ha-bottom-sheet-height: calc(
100vh - max(var(--safe-area-inset-top), 48px)
);
--ha-bottom-sheet-height: calc(
100dvh - max(var(--safe-area-inset-top), 48px)
);
--ha-bottom-sheet-max-height: calc(
100vh - max(var(--safe-area-inset-top), 48px)
);
--ha-bottom-sheet-max-height: calc(
100dvh - max(var(--safe-area-inset-top), 48px)
);
--dialog-content-padding: 0;
--safe-area-inset-bottom: 0px;
}
static get styles(): CSSResultGroup {
return [
buttonLinkStyle,
css`
:host {
--dialog-surface-margin-top: var(--ha-space-10);
--ha-dialog-min-height: 620px;
--ha-bottom-sheet-height: calc(
100vh - max(var(--safe-area-inset-top), 48px)
);
--ha-bottom-sheet-height: calc(
100dvh - max(var(--safe-area-inset-top), 48px)
);
--ha-bottom-sheet-max-height: calc(
100vh - max(var(--safe-area-inset-top), 48px)
);
--ha-bottom-sheet-max-height: calc(
100dvh - max(var(--safe-area-inset-top), 48px)
);
--dialog-content-padding: 0;
--safe-area-inset-bottom: 0px;
}
ha-tip {
display: flex;
justify-content: center;
align-items: center;
color: var(--secondary-text-color);
gap: var(--ha-space-1);
}
ha-tip {
display: flex;
justify-content: center;
align-items: center;
color: var(--secondary-text-color);
gap: var(--ha-space-1);
}
ha-tip a {
color: var(--primary-color);
}
ha-tip a {
color: var(--primary-color);
}
@media all and (max-width: 450px), all and (max-height: 690px) {
ha-tip {
display: none;
}
}
`;
@media all and (max-width: 450px), all and (max-height: 690px) {
ha-tip {
display: none;
}
}
`,
];
}
// #endregion styles
}

View File

@@ -1,4 +1,5 @@
import { fireEvent } from "../../common/dom/fire_event";
import { closeDialog } from "../make-dialog-manager";
export type QuickBarSection =
| "entity"
@@ -10,7 +11,7 @@ export type QuickBarSection =
export interface QuickBarParams {
entityFilter?: string;
mode?: QuickBarSection;
hint?: string;
showHint?: boolean;
}
export const loadQuickBar = () => import("./ha-quick-bar");
@@ -26,3 +27,7 @@ export const showQuickBar = (
addHistory: false,
});
};
export const closeQuickBar = (): void => {
closeDialog("ha-quick-bar");
};

View File

@@ -29,7 +29,6 @@ import {
getPanelIcon,
getPanelIconPath,
getPanelTitle,
SHOW_AFTER_SPACER_PANELS,
} from "../../data/panel";
import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box";
@@ -144,7 +143,6 @@ class DialogEditSidebar extends LitElement {
`${defaultPanel === panel.url_path ? " (default)" : ""}`,
icon: getPanelIcon(panel),
iconPath: getPanelIconPath(panel),
disableSorting: SHOW_AFTER_SPACER_PANELS.includes(panel.url_path),
disableHiding: panel.url_path === defaultPanel,
}));

View File

@@ -8,8 +8,10 @@ import { computeDomain } from "../../common/entity/compute_domain";
import { formatLanguageCode } from "../../common/language/format_language";
import "../../components/chips/ha-assist-chip";
import "../../components/ha-dialog";
import "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../components/ha-dropdown-item";
import { getLanguageOptions } from "../../components/ha-language-picker";
import "../../components/ha-md-button-menu";
import type { AssistSatelliteConfiguration } from "../../data/assist_satellite";
import { fetchAssistSatelliteConfiguration } from "../../data/assist_satellite";
import { getLanguageScores } from "../../data/conversation";
@@ -169,9 +171,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
>`
: this._step === STEP.PIPELINE
? this._language
? html`<ha-md-button-menu
? html`<ha-dropdown
slot="actionItems"
positioning="fixed"
@wa-select=${this._handlePickLanguage}
>
<ha-assist-chip
.label=${formatLanguageCode(
@@ -192,16 +194,14 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this.hass.locale
).map(
(lang) =>
html`<ha-md-menu-item
html`<ha-dropdown-item
.value=${lang.id}
@click=${this._handlePickLanguage}
@keydown=${this._handlePickLanguage}
.selected=${this._language === lang.id}
class=${this._language === lang.id ? "selected" : ""}
>
${lang.primary}
</ha-md-menu-item>`
</ha-dropdown-item>`
)}
</ha-md-button-menu>`
</ha-dropdown>`
: nothing
: nothing}
</ha-dialog-header>
@@ -328,10 +328,8 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
}
}
private _handlePickLanguage(ev) {
if (ev.type === "keydown" && ev.key !== "Enter" && ev.key !== " ") return;
this._language = ev.target.value;
private _handlePickLanguage(ev: CustomEvent<{ item: HaDropdownItem }>) {
this._language = ev.detail.item.value;
}
private _languageChanged(ev: CustomEvent) {
@@ -401,7 +399,7 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
margin: 24px;
display: block;
}
ha-md-button-menu {
ha-dropdown {
height: 48px;
display: flex;
align-items: center;
@@ -409,6 +407,13 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
margin-inline-end: 12px;
margin-inline-start: initial;
}
ha-dropdown-item.selected {
border: 1px solid var(--primary-color);
font-weight: var(--ha-font-weight-medium);
color: var(--primary-color);
background-color: var(--ha-color-fill-primary-quiet-resting);
--icon-primary-color: var(--primary-color);
}
`,
];
}

View File

@@ -8,6 +8,14 @@ export const demoPanels: Panels = {
config: { mode: "storage" },
url_path: "lovelace",
},
home: {
component_name: "home",
icon: "mdi:home",
title: "home",
default_visible: false,
config: null,
url_path: "home",
},
"dev-state": {
component_name: "dev-state",
icon: null,

View File

@@ -7,7 +7,6 @@ import { fireEvent } from "../common/dom/fire_event";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import { DEFAULT_PANEL } from "../data/panel";
import {
DateFormat,
FirstWeekday,
@@ -268,7 +267,9 @@ export const provideHass = (
name: "Demo User",
},
panelUrl: "lovelace",
defaultPanel: DEFAULT_PANEL,
systemData: {
default_panel: "lovelace",
},
language: localLanguage,
selectedLanguage: localLanguage,
locale: {
@@ -367,6 +368,7 @@ export const provideHass = (
areas: {},
devices: {},
entities: {},
floors: {},
formatEntityState: (stateObj, state) =>
(state !== null ? state : stateObj.state) ?? "",
formatEntityAttributeName: (_stateObj, attribute) => attribute,

View File

@@ -57,6 +57,8 @@ export class HassRouterPage extends ReactiveElement {
private _initialLoadDone = false;
private _showLoadingScreenTimeout?: number;
private _computeTail = memoizeOne(computeRouteTail);
protected createRenderRoot() {
@@ -143,7 +145,11 @@ export class HassRouterPage extends ReactiveElement {
? routeOptions.load()
: Promise.resolve();
let showLoadingScreenTimeout: undefined | number;
// Clear any existing loading screen timeout from previous navigation
if (this._showLoadingScreenTimeout) {
clearTimeout(this._showLoadingScreenTimeout);
this._showLoadingScreenTimeout = undefined;
}
// Check when loading the page source failed.
loadProm.catch((err) => {
@@ -160,8 +166,9 @@ export class HassRouterPage extends ReactiveElement {
this.removeChild(this.lastChild!);
}
if (showLoadingScreenTimeout) {
clearTimeout(showLoadingScreenTimeout);
if (this._showLoadingScreenTimeout) {
clearTimeout(this._showLoadingScreenTimeout);
this._showLoadingScreenTimeout = undefined;
}
// Show error screen
@@ -181,7 +188,7 @@ export class HassRouterPage extends ReactiveElement {
// That way we won't have a double fast flash on fast connections.
let created = false;
showLoadingScreenTimeout = window.setTimeout(() => {
this._showLoadingScreenTimeout = window.setTimeout(() => {
if (created || this._currentPage !== newPage) {
return;
}

View File

@@ -1,6 +1,7 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import { goBack } from "../common/navigate";
import "../components/ha-icon-button-arrow-prev";
@@ -27,7 +28,7 @@ class HassSubpage extends LitElement {
protected render(): TemplateResult {
return html`
<div class="toolbar">
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${this.mainPage || history.state?.root
? html`
@@ -132,7 +133,7 @@ class HassSubpage extends LitElement {
}
.main-title {
margin: var(--margin-title);
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-normal);
min-width: 0;
flex-grow: 1;
@@ -143,6 +144,9 @@ class HassSubpage extends LitElement {
overflow: hidden;
text-overflow: ellipsis;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
.content {
position: relative;

View File

@@ -131,7 +131,7 @@ class HassTabsSubpage extends LitElement {
);
const showTabs = tabs.length > 1;
return html`
<div class="toolbar">
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<slot name="toolbar">
<div class="toolbar-content">
${this.mainPage || (!this.backPath && history.state?.root)
@@ -320,7 +320,10 @@ class HassTabsSubpage extends LitElement {
max-height: var(--header-height);
line-height: var(--ha-line-height-normal);
color: var(--sidebar-text-color);
margin: var(--main-title-margin, var(--margin-title));
margin-inline-start: var(--main-title-margin, var(--ha-space-6));
}
.narrow .main-title {
margin-inline-start: var(--main-title-margin, var(--ha-space-2));
}
.content {

View File

@@ -106,6 +106,10 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
// Navigation
const updateRoute = (path = curPath()) => {
// Developer tools panel was moved to config in 2026.2
if (path.startsWith("/developer-tools")) {
path = path.replace("/developer-tools", "/config/developer-tools");
}
if (this._route && path === this._route.path) {
return;
}

View File

@@ -14,15 +14,13 @@ import { removeLaunchScreen } from "../util/launch-screen";
import type { RouteOptions, RouterOptions } from "./hass-router-page";
import { HassRouterPage } from "./hass-router-page";
const CACHE_URL_PATHS = ["lovelace", "developer-tools"];
const CACHE_URL_PATHS = ["lovelace", "home", "config"];
const COMPONENTS = {
app: () => import("../panels/app/ha-panel-app"),
energy: () => import("../panels/energy/ha-panel-energy"),
calendar: () => import("../panels/calendar/ha-panel-calendar"),
config: () => import("../panels/config/ha-panel-config"),
custom: () => import("../panels/custom/ha-panel-custom"),
"developer-tools": () =>
import("../panels/developer-tools/ha-panel-developer-tools"),
lovelace: () => import("../panels/lovelace/ha-panel-lovelace"),
history: () => import("../panels/history/ha-panel-history"),
iframe: () => import("../panels/iframe/ha-panel-iframe"),

View File

@@ -25,6 +25,7 @@ import { subscribeOne } from "../common/util/subscribe-one";
import "../components/ha-card";
import type { AuthUrlSearchParams } from "../data/auth";
import { hassUrl } from "../data/auth";
import { saveFrontendSystemData } from "../data/frontend";
import type { OnboardingResponses, OnboardingStep } from "../data/onboarding";
import {
fetchInstallationType,
@@ -406,6 +407,11 @@ class HaOnboarding extends litLocalizeLiteMixin(HassElement) {
),
};
await saveFrontendSystemData(this.hass!.connection, "core", {
onboarded_version: this.hass!.config.version,
onboarded_date: new Date().toISOString(),
});
let result: OnboardingResponses["integration"];
try {

View File

@@ -68,7 +68,7 @@ class OnboardingCoreConfig extends LitElement {
<ha-country-picker
class="flex"
.language=${this.hass.locale.language}
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.country"
) || "Country"}
@@ -76,8 +76,7 @@ class OnboardingCoreConfig extends LitElement {
.disabled=${this._working}
.value=${this._countryValue}
@value-changed=${this._handleCountryChanged}
>
</ha-country-picker>
></ha-country-picker>
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._working}>

View File

@@ -3,6 +3,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { createRef, ref } from "lit/directives/ref";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { navigate } from "../../common/navigate";
@@ -116,7 +117,7 @@ class HaPanelApp extends LitElement {
${!this._kioskMode &&
(this.narrow || this.hass.dockedSidebar === "always_hidden")
? html`
<div class="header">
<div class="header ${classMap({ narrow: this.narrow })}">
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
.path=${mdiMenu}
@@ -452,10 +453,13 @@ class HaPanelApp extends LitElement {
}
.main-title {
margin: var(--margin-title);
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-condensed);
flex-grow: 1;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
ha-icon-button {
pointer-events: auto;

View File

@@ -1,7 +1,8 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiChevronDown, mdiPlus, mdiRefresh } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { storage } from "../../common/decorators/storage";
@@ -16,19 +17,23 @@ import "../../components/ha-icon-button";
import "../../components/ha-list";
import "../../components/ha-list-item";
import "../../components/ha-menu-button";
import "../../components/ha-spinner";
import "../../components/ha-state-icon";
import "../../components/ha-svg-icon";
import "../../components/ha-two-pane-top-app-bar-fixed";
import type { Calendar, CalendarEvent } from "../../data/calendar";
import { fetchCalendarEvents, getCalendars } from "../../data/calendar";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { subscribeEntityRegistry } from "../../data/entity/entity_registry";
import { fetchIntegrationManifest } from "../../data/integration";
import { showConfigFlowDialog } from "../../dialogs/config-flow/show-dialog-config-flow";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import { haStyle } from "../../resources/styles";
import type { CalendarViewChanged, HomeAssistant } from "../../types";
import "./ha-full-calendar";
@customElement("ha-panel-calendar")
class PanelCalendar extends LitElement {
class PanelCalendar extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@@ -41,6 +46,8 @@ class PanelCalendar extends LitElement {
@state() private _error?: string = undefined;
@state() private _entityRegistry?: EntityRegistryEntry[];
@state()
@storage({
key: "deSelectedCalendars",
@@ -77,14 +84,46 @@ class PanelCalendar extends LitElement {
this.mobile = ev.matches;
};
public willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._calendars = getCalendars(this.hass, this);
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entityRegistry = entities;
// Refresh calendars when entity registry updates (includes color changes)
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
// Refetch events if view dates are available (handles both initial load and color updates)
if (this._start && this._end) {
this._fetchEvents(
this._start,
this._end,
this._selectedCalendars
).then((result) => {
this._events = result.events;
this._handleErrors(result.errors);
});
}
}),
];
}
protected render(): TemplateResult {
if (!this._entityRegistry) {
return html`
<ha-two-pane-top-app-bar-fixed .narrow=${this.narrow}>
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
<div slot="title">
${this.hass.localize("ui.components.calendar.my_calendars")}
</div>
<div class="loading">
<ha-spinner></ha-spinner>
</div>
</ha-two-pane-top-app-bar-fixed>
`;
}
const calendarItems = this._calendars.map(
(selCal) => html`
<ha-dropdown-item
@@ -220,7 +259,7 @@ class PanelCalendar extends LitElement {
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
dialogClosedCallback: ({ flowFinished }) => {
if (flowFinished) {
this._calendars = getCalendars(this.hass, this);
this._calendars = getCalendars(this.hass, this, this._entityRegistry);
}
},
});
@@ -301,6 +340,13 @@ class PanelCalendar extends LitElement {
:host([mobile]) {
padding-left: unset;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--ha-space-8);
min-height: 400px;
}
`,
];
}

View File

@@ -1,6 +1,7 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
@@ -12,8 +13,8 @@ import type { HomeAssistant } from "../../types";
import { generateLovelaceViewStrategy } from "../lovelace/strategies/get-strategy";
import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-container";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -95,7 +96,7 @@ class PanelClimate extends LitElement {
protected render() {
return html`
<div class="header">
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${
this._searchParms.has("historyBack")
@@ -175,7 +176,6 @@ class PanelClimate extends LitElement {
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
position: fixed;
top: 0;
width: calc(
@@ -220,15 +220,19 @@ class PanelClimate extends LitElement {
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
border-bottom: var(--app-header-border-bottom, none);
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin: var(--margin-title);
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
hui-view-container {
position: relative;
display: flex;

View File

@@ -16,8 +16,11 @@ import "../../../components/ha-labels-picker";
import "../../../components/ha-picture-upload";
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row";
import "../../../components/ha-suggest-with-ai-button";
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
import "../../../components/ha-textfield";
import "../../../components/ha-wa-dialog";
import type { GenDataTaskResult } from "../../../data/ai_task";
import type {
AreaRegistryEntry,
AreaRegistryEntryMutableParams,
@@ -32,6 +35,14 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import type { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
import { haStyleDialog } from "../../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../../types";
import {
type MetadataSuggestionInclude,
type MetadataSuggestionResult,
generateMetadataSuggestionTask,
processMetadataSuggestion,
} from "../common/suggest-metadata-ai";
import { fetchLabels } from "../common/suggest-metadata-helpers";
import { buildAreaMetadataInspirations } from "../common/suggest-metadata-inspirations";
import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry-detail";
const cropOptions: CropOptions = {
@@ -71,10 +82,16 @@ class DialogAreaDetail
@state() private _params?: AreaRegistryDetailDialogParams;
@state() private _submitting?: boolean;
@state() private _submitting = false;
@state() private _open = false;
@state() private _suggestionInclude: MetadataSuggestionInclude = {
name: true,
labels: true,
floor: true,
};
public async showDialog(
params: AreaRegistryDetailDialogParams
): Promise<void> {
@@ -242,6 +259,76 @@ class DialogAreaDetail
`;
}
private async _getLabelNames(): Promise<string[]> {
if (!this._labels.length) {
return [];
}
const labels = await fetchLabels(this.hass.connection);
return this._labels
.map((labelId) => labels[labelId])
.filter((name): name is string => Boolean(name));
}
private _generateTask = async (): Promise<SuggestWithAIGenerateTask> => {
this._suggestionInclude = {
...this._suggestionInclude,
name: this._name.trim() === "",
};
return generateMetadataSuggestionTask<{
name: string;
aliases: string[];
labels: string[];
floor: string | null;
temperature_entity: string | null;
humidity_entity: string | null;
}>(
this.hass.connection,
this.hass.language,
"area",
{
name: this._name,
aliases: this._aliases,
labels: await this._getLabelNames(),
floor: this._floor ? this.hass.floors?.[this._floor]?.name : null,
temperature_entity: this._temperatureEntity
? (this.hass.states[this._temperatureEntity]?.attributes
?.friendly_name ?? null)
: null,
humidity_entity: this._humidityEntity
? (this.hass.states[this._humidityEntity]?.attributes
?.friendly_name ?? null)
: null,
},
await buildAreaMetadataInspirations(this.hass.connection),
this._suggestionInclude
);
};
private async _handleSuggestion(
event: CustomEvent<GenDataTaskResult<MetadataSuggestionResult>>
) {
const result = event.detail;
const processed = await processMetadataSuggestion(
this.hass.connection,
"area",
result,
this._suggestionInclude
);
if (processed.name) {
this._name = processed.name;
}
if (processed.labels?.length) {
this._labels = processed.labels;
}
if (processed.floor) {
this._floor = processed.floor;
}
}
protected render() {
if (!this._params) {
return nothing;
@@ -259,6 +346,12 @@ class DialogAreaDetail
: this.hass.localize("ui.panel.config.areas.editor.create_area")}
@closed=${this._dialogClosed}
>
<ha-suggest-with-ai-button
slot="headerActionItems"
.hass=${this.hass}
.generateTask=${this._generateTask}
@suggestion=${this._handleSuggestion}
></ha-suggest-with-ai-button>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
@@ -285,7 +378,7 @@ class DialogAreaDetail
<ha-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || !!this._submitting}
.disabled=${nameInvalid || this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
@@ -423,13 +516,16 @@ class DialogAreaDetail
ha-picture-upload,
ha-expansion-panel {
display: block;
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
.content {
padding: 12px;
padding: var(--ha-space-3);
}
.description {
margin: 0 0 16px 0;
margin: 0 0 var(--ha-space-4) 0;
}
ha-suggest-with-ai-button {
margin: var(--ha-space-2) var(--ha-space-4);
}
`,
];

View File

@@ -239,9 +239,8 @@ class HaConfigAreaPage extends LitElement {
${this.hass.localize("ui.panel.config.areas.edit_settings")}
</ha-dropdown-item>
<ha-dropdown-item value="delete">
<ha-svg-icon class="warning" slot="icon" .path=${mdiDelete}>
</ha-svg-icon>
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon slot="icon" .path=${mdiDelete}> </ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.editor.delete")}
</ha-dropdown-item>
</ha-dropdown>

View File

@@ -224,9 +224,8 @@ export class HaConfigAreasDashboard extends LitElement {
"ui.panel.config.areas.picker.floor.edit_floor"
)}</ha-dropdown-item
>
<ha-dropdown-item value="delete" class="warning"
<ha-dropdown-item value="delete" variant="danger"
><ha-svg-icon
class="warning"
.path=${mdiDelete}
slot="icon"
></ha-svg-icon
@@ -727,9 +726,6 @@ export class HaConfigAreasDashboard extends LitElement {
align-items: center;
overflow-wrap: anywhere;
}
.warning {
color: var(--error-color);
}
`;
}

View File

@@ -33,10 +33,10 @@ import type {
} from "./show-dialog-automation-save";
import {
type MetadataSuggestionResult,
SUGGESTION_INCLUDE_ALL,
generateMetadataSuggestionTask,
processMetadataSuggestion,
} from "../../common/suggest-metadata-ai";
import { buildEntityMetadataInspirations } from "../../common/suggest-metadata-inspirations";
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@@ -341,10 +341,14 @@ class DialogAutomationSave extends LitElement implements HassDialog {
}
return generateMetadataSuggestionTask<AutomationConfig | ScriptConfig>(
this.hass.connection,
this.hass.states,
this.hass.language,
this._params.domain,
this._params.config
this._params.config,
await buildEntityMetadataInspirations(
this.hass.connection,
this.hass.states,
this._params.domain
)
);
};
@@ -358,11 +362,12 @@ class DialogAutomationSave extends LitElement implements HassDialog {
const processed = await processMetadataSuggestion(
this.hass.connection,
this._params.domain,
result,
SUGGESTION_INCLUDE_ALL
result
);
this._newName = processed.name;
if (processed.name) {
this._newName = processed.name;
}
if (processed.description) {
this._newDescription = processed.description;
@@ -432,7 +437,8 @@ class DialogAutomationSave extends LitElement implements HassDialog {
haStyleDialog,
css`
ha-wa-dialog {
--dialog-content-padding: 0 24px 24px 24px;
--dialog-content-padding: 0 var(--ha-space-6) var(--ha-space-6)
var(--ha-space-6);
}
ha-textfield,
@@ -448,15 +454,15 @@ class DialogAutomationSave extends LitElement implements HassDialog {
ha-labels-picker,
ha-area-picker,
ha-chip-set:has(> ha-assist-chip) {
margin-top: 16px;
margin-top: var(--ha-space-4);
}
ha-alert {
display: block;
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
ha-suggest-with-ai-button {
margin: 8px 16px;
margin: var(--ha-space-2) var(--ha-space-4);
}
`,
];

View File

@@ -84,31 +84,6 @@ export interface ConditionElement extends LitElement {
collapseAll?: () => void;
}
export const handleChangeEvent = (
element: ConditionElement,
ev: CustomEvent
) => {
ev.stopPropagation();
const name = (ev.currentTarget as any)?.name;
if (!name) {
return;
}
const newVal = ev.detail?.value || (ev.currentTarget as any)?.value;
if ((element.condition[name] || "") === newVal) {
return;
}
let newCondition: Condition;
if (!newVal) {
newCondition = { ...element.condition };
delete newCondition[name];
} else {
newCondition = { ...element.condition, [name]: newVal };
}
fireEvent(element, "value-changed", { value: newCondition });
};
@customElement("ha-automation-condition-row")
export default class HaAutomationConditionRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;

View File

@@ -1,9 +1,15 @@
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../../../components/ha-textarea";
import type { TemplateCondition } from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import { handleChangeEvent } from "../ha-automation-condition-row";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-form/ha-form";
import { fireEvent } from "../../../../../common/dom/fire_event";
const SCHEMA = [
{ name: "value_template", required: true, selector: { template: {} } },
] as const;
@customElement("ha-automation-condition-template")
export class HaTemplateCondition extends LitElement {
@@ -18,36 +24,30 @@ export class HaTemplateCondition extends LitElement {
}
protected render() {
const { value_template } = this.condition;
return html`
<p>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.template.value_template"
)}
*
</p>
<ha-code-editor
.name=${"value_template"}
mode="jinja2"
<ha-form
.hass=${this.hass}
.value=${value_template}
.readOnly=${this.disabled}
autocomplete-entities
.data=${this.condition}
.schema=${SCHEMA}
@value-changed=${this._valueChanged}
dir="ltr"
></ha-code-editor>
.computeLabel=${this._computeLabelCallback}
.disabled=${this.disabled}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
handleChangeEvent(this, ev);
ev.stopPropagation();
const newCondition = ev.detail.value;
fireEvent(this, "value-changed", { value: newCondition });
}
static styles = css`
p {
margin-top: 0;
}
`;
private _computeLabelCallback = (
schema: SchemaUnion<typeof SCHEMA>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.template.${schema.name}`
);
}
declare global {

View File

@@ -1,7 +1,7 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiCog,
mdiContentDuplicate,
mdiDelete,
@@ -50,6 +50,9 @@ import type {
} from "../../../components/data-table/ha-data-table";
import "../../../components/data-table/ha-data-table-labels";
import "../../../components/entity/ha-entity-toggle";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-filter-blueprints";
import "../../../components/ha-filter-categories";
@@ -59,7 +62,6 @@ import "../../../components/ha-filter-floor-areas";
import "../../../components/ha-filter-labels";
import "../../../components/ha-filter-voice-assistants";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-menu";
import type { HaMdMenu } from "../../../components/ha-md-menu";
import "../../../components/ha-md-menu-item";
@@ -87,9 +89,9 @@ import { fullEntitiesContext } from "../../../data/context";
import type { DataTableFilters } from "../../../data/data_table_filters";
import {
deserializeFilters,
serializeFilters,
isUsedFilter as isFilterUsed,
isUsedRelatedItemsFilter as isRelatedItemsFilterUsed,
serializeFilters,
} from "../../../data/data_table_filters";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type {
@@ -97,6 +99,7 @@ import type {
UpdateEntityRegistryEntryResult,
} from "../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../data/entity/entity_registry";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import type { LabelRegistryEntry } from "../../../data/label/label_registry";
import {
createLabelRegistryEntry,
@@ -118,13 +121,12 @@ import { showAssignCategoryDialog } from "../category/show-dialog-assign-categor
import { showCategoryRegistryDetailDialog } from "../category/show-dialog-category-registry-detail";
import { configSections } from "../ha-panel-config";
import { showLabelDetailDialog } from "../labels/show-dialog-label-detail";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
import { getEntityVoiceAssistantsIds } from "../../../data/expose";
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import {
getAssistantsTableColumn,
getAssistantsSortableKey,
getAssistantsTableColumn,
} from "../voice-assistants/expose/assistants-table-column";
import { getAvailableAssistants } from "../voice-assistants/expose/available-assistants";
import { showNewAutomationDialog } from "./show-dialog-new-automation";
type AutomationItem = AutomationEntity & {
name: string;
@@ -441,103 +443,6 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
}
protected render(): TemplateResult {
const categoryItems = html`${this._categories?.map(
(category) =>
html`<ha-md-menu-item
.value=${category.category_id}
.clickAction=${this._handleBulkCategory}
>
${category.icon
? html`<ha-icon slot="start" .icon=${category.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTag}></ha-svg-icon>`}
<div slot="headline">${category.name}</div>
</ha-md-menu-item>`
)}
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkCategory}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.no_category"
)}
</div>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._bulkCreateCategory}>
<div slot="headline">
${this.hass.localize("ui.panel.config.category.editor.add")}
</div>
</ha-md-menu-item>`;
const labelItems = html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
const partial =
!selected &&
this._selected.some((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
return html`<ha-md-menu-item
.value=${label.label_id}
.action=${selected ? "remove" : "add"}
@click=${this._handleBulkLabel}
keep-open
>
<ha-checkbox
slot="start"
.checked=${selected}
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
${label.name}
</ha-label>
</ha-md-menu-item>`;
})}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._bulkCreateLabel}>
<div slot="headline">
${this.hass.localize("ui.panel.config.labels.add_label")}
</div></ha-md-menu-item
>`;
const areaItems = html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-md-menu-item
.value=${area.area_id}
.clickAction=${this._handleBulkArea}
>
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<div slot="headline">${area.name}</div>
</ha-md-menu-item>`
)}
<ha-md-menu-item .value=${null} .clickAction=${this._handleBulkArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</div>
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item .clickAction=${this._bulkCreateArea}>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</div>
</ha-md-menu-item>`;
const areasInOverflow =
(this._sizeController.value && this._sizeController.value < 900) ||
(!this._sizeController.value && this.hass.dockedSidebar === "docked");
@@ -558,9 +463,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
.backPath=${this._searchParms.has("historyBack")
? undefined
: "/config"}
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
@@ -572,16 +477,14 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.selected=${this._selected.length}
@selection-changed=${this._handleSelectionChanged}
has-filters
.filters=${
Object.values(this._filters).filter((filter) =>
Array.isArray(filter.value)
? filter.value.length
: filter.value &&
Object.values(filter.value).some((val) =>
Array.isArray(val) ? val.length : val
)
).length
}
.filters=${Object.values(this._filters).filter((filter) =>
Array.isArray(filter.value)
? filter.value.length
: filter.value &&
Object.values(filter.value).some((val) =>
Array.isArray(val) ? val.length : val
)
).length}
.columns=${this._columns(
this.narrow,
this.hass.localize,
@@ -684,13 +587,34 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.narrow=${this.narrow}
@expanded-changed=${this._filterExpanded}
></ha-filter-blueprints>
${
!this.narrow
? html`<ha-md-button-menu slot="selection-bar">
${!this.narrow
? html`<ha-dropdown
slot="selection-bar"
@wa-select=${this._handleBulkCategory}
>
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.move_category"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${this._renderCategoryItems()}
</ha-dropdown>
${labelsInOverflow
? nothing
: html`<ha-dropdown
slot="selection-bar"
@wa-select=${this._handleBulkLabel}
>
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.move_category"
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
>
<ha-svg-icon
@@ -698,179 +622,119 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${categoryItems}
</ha-md-button-menu>
${labelsInOverflow
? nothing
: html`<ha-md-button-menu slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${labelItems}
</ha-md-button-menu>`}
${areasInOverflow
? nothing
: html`<ha-md-button-menu slot="selection-bar">
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>
${areaItems}
</ha-md-button-menu>`}`
: nothing
}
<ha-md-button-menu has-overflow slot="selection-bar">
${
this.narrow
? html`<ha-assist-chip
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
slot="trigger"
${this._renderLabelItems()}
</ha-dropdown>`}
${areasInOverflow
? nothing
: html`<ha-dropdown
slot="selection-bar"
@wa-select=${this._handleBulkArea}
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>`
: html`<ha-icon-button
.path=${mdiDotsVertical}
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
slot="trigger"
></ha-icon-button>`
}
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon
></ha-assist-chip>
${
this.narrow
? html`<ha-sub-menu>
<ha-md-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.move_category"
)}
</div>
<ha-assist-chip
slot="trigger"
.label=${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu slot="menu">${categoryItems}</ha-md-menu>
</ha-sub-menu>`
: nothing
}
${
this.narrow || labelsInOverflow
? html`<ha-sub-menu>
<ha-md-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu slot="menu">${labelItems}</ha-md-menu>
</ha-sub-menu>`
: nothing
}
${
this.narrow || areasInOverflow
? html`<ha-sub-menu>
<ha-md-menu-item slot="item">
<div slot="headline">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
</div>
<ha-svg-icon
slot="end"
.path=${mdiChevronRight}
></ha-svg-icon>
</ha-md-menu-item>
<ha-md-menu slot="menu">${areaItems}</ha-md-menu>
</ha-sub-menu>`
: nothing
}
<ha-md-menu-item .clickAction=${this._handleBulkEnable}>
<ha-svg-icon slot="start" .path=${mdiToggleSwitch}></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.enable"
</ha-assist-chip>
${this._renderAreaItems()}
</ha-dropdown>`}`
: nothing}
<ha-dropdown slot="selection-bar" @wa-select=${this._handleBulkAction}>
${this.narrow
? html`<ha-assist-chip
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._handleBulkDisable}>
<ha-svg-icon
slot="start"
.path=${mdiToggleSwitchOffOutline}
></ha-svg-icon>
<div slot="headline">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.disable"
slot="trigger"
>
<ha-svg-icon
slot="trailing-icon"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-assist-chip>`
: html`<ha-icon-button
.path=${mdiDotsVertical}
.label=${this.hass.localize(
"ui.panel.config.automation.picker.bulk_action"
)}
</div>
</ha-md-menu-item>
</ha-md-button-menu>
${
!this.automations.length
? html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiRobotHappy}></ha-svg-icon>
<h1>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_header"
)}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_text_1"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_text_2",
{ user: this.hass.user?.name || "Alice" }
)}
</p>
<ha-button
href=${documentationUrl(
this.hass,
"/docs/automation/editor/"
)}
target="_blank"
appearance="plain"
rel="noreferrer"
size="small"
>
${this.hass.localize("ui.panel.config.common.learn_more")}
<ha-svg-icon slot="end" .path=${mdiOpenInNew}> </ha-svg-icon>
</ha-button>
</div>`
: nothing
}
slot="trigger"
></ha-icon-button>`}
${this.narrow
? html`<ha-dropdown-item>
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.move_category"
)}
${this._renderCategoryItems("submenu")}
</ha-dropdown-item>`
: nothing}
${this.narrow || labelsInOverflow
? html`<ha-dropdown-item>
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.add_label"
)}
${this._renderLabelItems("submenu")}
</ha-dropdown-item>`
: nothing}
${this.narrow || areasInOverflow
? html`<ha-dropdown-item>
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.move_area"
)}
${this._renderAreaItems("submenu")}
</ha-dropdown-item>`
: nothing}
<ha-dropdown-item value="enable">
<ha-svg-icon slot="icon" .path=${mdiToggleSwitch}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.enable"
)}
</ha-dropdown-item>
<ha-dropdown-item value="disable">
<ha-svg-icon
slot="icon"
.path=${mdiToggleSwitchOffOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.disable"
)}
</ha-dropdown-item>
</ha-dropdown>
${!this.automations.length
? html`<div class="empty" slot="empty">
<ha-svg-icon .path=${mdiRobotHappy}></ha-svg-icon>
<h1>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_header"
)}
</h1>
<p>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_text_1"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.automation.picker.empty_text_2",
{ user: this.hass.user?.name || "Alice" }
)}
</p>
<ha-button
href=${documentationUrl(this.hass, "/docs/automation/editor/")}
target="_blank"
appearance="plain"
rel="noreferrer"
size="small"
>
${this.hass.localize("ui.panel.config.common.learn_more")}
<ha-svg-icon slot="end" .path=${mdiOpenInNew}> </ha-svg-icon>
</ha-button>
</div>`
: nothing}
<ha-fab
slot="fab"
.label=${this.hass.localize(
@@ -932,21 +796,15 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._toggle}>
<ha-svg-icon
.path=${
this._overflowAutomation?.state === "off"
? mdiToggleSwitch
: mdiToggleSwitchOffOutline
}
.path=${this._overflowAutomation?.state === "off"
? mdiToggleSwitch
: mdiToggleSwitchOffOutline}
slot="start"
></ha-svg-icon>
<div slot="headline">
${
this._overflowAutomation?.state === "off"
? this.hass.localize("ui.panel.config.automation.editor.enable")
: this.hass.localize(
"ui.panel.config.automation.editor.disable"
)
}
${this._overflowAutomation?.state === "off"
? this.hass.localize("ui.panel.config.automation.editor.enable")
: this.hass.localize("ui.panel.config.automation.editor.disable")}
</div>
</ha-md-menu-item>
<ha-md-menu-item .clickAction=${this._deleteConfirm} class="warning">
@@ -1282,12 +1140,22 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
}
}
private _handleBulkCategory = async (item) => {
const category = item.value;
this._bulkAddCategory(category);
private _handleBulkCategory = (ev: CustomEvent<{ item: HaDropdownItem }>) => {
const value = ev.detail.item.value;
if (value === "category_create") {
this._bulkCreateCategory();
return;
}
if (value === "category_none") {
this._bulkAddCategory(null);
return;
}
if (value?.startsWith("category_")) {
this._bulkAddCategory(value.substring(9));
}
};
private async _bulkAddCategory(category: string) {
private async _bulkAddCategory(category: string | null) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
@@ -1312,11 +1180,20 @@ ${rejected
}
}
private async _handleBulkLabel(ev) {
const label = ev.currentTarget.value;
const action = ev.currentTarget.action;
this._bulkLabel(label, action);
}
private _handleBulkLabel = (ev) => {
ev.preventDefault(); // keep menu open
const item = ev.detail.item;
const value = item.value;
if (value === "label_create") {
this._bulkCreateLabel();
return;
}
if (value?.startsWith("label_")) {
const action = item.action;
this._bulkLabel(value.substring(6), action);
}
};
private async _bulkLabel(label: string, action: "add" | "remove") {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
@@ -1348,12 +1225,23 @@ ${rejected
}
}
private _handleBulkArea = (item) => {
const area = item.value;
this._bulkAddArea(area);
private _handleBulkArea = (ev) => {
const value = ev.detail.item.value;
if (value === "area_create") {
this._bulkCreateArea();
return;
}
if (value === "area_none") {
this._bulkAddArea(null);
return;
}
if (value?.startsWith("area_")) {
this._bulkAddArea(value.substring(5));
}
};
private async _bulkAddArea(area: string) {
private async _bulkAddArea(area: string | null) {
const promises: Promise<UpdateEntityRegistryEntryResult>[] = [];
this._selected.forEach((entityId) => {
promises.push(
@@ -1454,6 +1342,148 @@ ${rejected
});
};
private _renderCategoryItems = (slot = "") =>
html`${this._categories?.map(
(category) =>
html`<ha-dropdown-item
.slot=${slot}
.value=${`category_${category.category_id}`}
>
${category.icon
? html`<ha-icon slot="icon" .icon=${category.icon}></ha-icon>`
: html`<ha-svg-icon slot="icon" .path=${mdiTag}></ha-svg-icon>`}
${category.name}
</ha-dropdown-item>`
)}
<ha-dropdown-item .slot=${slot} value="category_none">
${this.hass.localize(
"ui.panel.config.automation.picker.bulk_actions.no_category"
)}
</ha-dropdown-item>
<wa-divider .slot=${slot}></wa-divider>
<ha-dropdown-item .slot=${slot} value="category_create">
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-dropdown-item>`;
private _renderLabelItems = (slot = "") =>
html`${this._labels?.map((label) => {
const color = label.color ? computeCssColor(label.color) : undefined;
const selected = this._selected.every((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
const partial =
!selected &&
this._selected.some((entityId) =>
this.hass.entities[entityId]?.labels.includes(label.label_id)
);
return html`<ha-dropdown-item
.slot=${slot}
.value=${`label_${label.label_id}`}
.action=${selected ? "remove" : "add"}
>
<ha-checkbox
slot="icon"
.checked=${selected}
.indeterminate=${partial}
reducedTouchTarget
></ha-checkbox>
<ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`
: nothing}
${label.name}
</ha-label>
</ha-dropdown-item>`;
})}
<wa-divider .slot=${slot}></wa-divider>
<ha-dropdown-item .slot=${slot} value="label_create">
${this.hass.localize("ui.panel.config.labels.add_label")}
</ha-dropdown-item>`;
private _renderAreaItems = (slot = "") =>
html`${Object.values(this.hass.areas).map(
(area) =>
html`<ha-dropdown-item .slot=${slot} .value=${`area_${area.area_id}`}>
${area.icon
? html`<ha-icon slot="icon" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="icon"
.path=${mdiTextureBox}
></ha-svg-icon>`}
${area.name}
</ha-dropdown-item>`
)}
<ha-dropdown-item .slot=${slot} value="area_none">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.no_area"
)}
</ha-dropdown-item>
<wa-divider .slot=${slot}></wa-divider>
<ha-dropdown-item .slot=${slot} value="area_create">
${this.hass.localize(
"ui.panel.config.devices.picker.bulk_actions.add_area"
)}
</ha-dropdown-item>`;
private _handleBulkAction = (ev) => {
const item = ev.detail.item;
const value = item.value;
if (!value) {
return;
}
if (value === "enable") {
this._handleBulkEnable();
return;
}
if (value === "disable") {
this._handleBulkDisable();
return;
}
if (value.startsWith("category_")) {
if (value === "category_create") {
this._bulkCreateCategory();
return;
}
if (value === "category_none") {
this._bulkAddCategory(null);
return;
}
this._bulkAddCategory(value.substring(9));
return;
}
if (value.startsWith("label_")) {
if (value === "label_create") {
this._bulkCreateLabel();
return;
}
const action = item.action;
this._bulkLabel(value.substring(6), action);
return;
}
if (value.startsWith("area_")) {
if (value === "area_create") {
this._bulkCreateArea();
return;
}
if (value === "area_none") {
this._bulkAddArea(null);
return;
}
this._bulkAddArea(value.substring(5));
}
};
private _handleSortingChanged(ev: CustomEvent) {
this._activeSorting = ev.detail;
}
@@ -1498,7 +1528,11 @@ ${rejected
ha-assist-chip {
--ha-assist-chip-container-shape: 10px;
}
ha-md-button-menu ha-assist-chip {
ha-dropdown::part(menu),
ha-dropdown::part(submenu) {
--auto-size-available-width: calc(50vw - var(--ha-space-4));
}
ha-dropdown ha-assist-chip {
--md-assist-chip-trailing-space: 8px;
}
ha-label {

View File

@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { firstWeekdayIndex } from "../../../../../common/datetime/first_weekday";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
@@ -11,7 +12,6 @@ import type { TimeTrigger } from "../../../../../data/automation";
import type { FrontendLocaleData } from "../../../../../data/translation";
import type { HomeAssistant } from "../../../../../types";
import type { TriggerElement } from "../ha-automation-trigger-row";
import { computeDomain } from "../../../../../common/entity/compute_domain";
const MODE_TIME = "time";
const MODE_ENTITY = "entity";

View File

@@ -123,7 +123,7 @@ class HaConfigBackupDetails extends LitElement {
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
${this.hass.localize("ui.common.download")}
</ha-dropdown-item>
<ha-dropdown-item value="delete" class="warning">
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
${this.hass.localize("ui.common.delete")}
</ha-dropdown-item>
@@ -385,12 +385,6 @@ class HaConfigBackupDetails extends LitElement {
--mdc-icon-size: 48px;
color: var(--primary-text-color);
}
.warning {
color: var(--error-color);
}
.warning ha-svg-icon {
color: var(--error-color);
}
ha-button.danger {
--mdc-theme-primary: var(--error-color);
}

View File

@@ -1,163 +1,84 @@
import { dump } from "js-yaml";
import { computeDomain } from "../../../common/entity/compute_domain";
import { subscribeOne } from "../../../common/util/subscribe-one";
import type { AITaskStructure, GenDataTaskResult } from "../../../data/ai_task";
import { fetchCategoryRegistry } from "../../../data/category_registry";
import {
subscribeEntityRegistry,
type EntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { subscribeLabelRegistry } from "../../../data/label/label_registry";
import type { HomeAssistant } from "../../../types";
import type { SuggestWithAIGenerateTask } from "../../../components/ha-suggest-with-ai-button";
import {
fetchCategories,
fetchFloors,
fetchLabels,
} from "./suggest-metadata-helpers";
export interface MetadataSuggestionResult {
name: string;
name?: string;
description?: string;
category?: string;
labels?: string[];
floor?: string;
}
export type MetadataSuggestionDomain = "automation" | "script" | "scene";
export type MetadataSuggestionDomain =
| "automation"
| "script"
| "scene"
| "area";
export interface MetadataSuggestionInclude {
name: boolean;
description?: boolean;
categories?: boolean;
labels?: boolean;
floor?: boolean;
}
type Categories = Record<string, string>;
type Entities = Record<string, EntityRegistryEntry>;
type Labels = Record<string, string>;
export const SUGGESTION_INCLUDE_ALL: MetadataSuggestionInclude = {
export const SUGGESTION_INCLUDE_DEFAULT: MetadataSuggestionInclude = {
name: true,
description: true,
categories: true,
labels: true,
} as const;
const tryCatchEmptyObject = <T>(promise: Promise<T>): Promise<T> =>
promise.catch((err) => {
// eslint-disable-next-line no-console
console.error("Error fetching data for suggestion: ", err);
return {} as T;
});
const fetchCategories = (
connection: HomeAssistant["connection"],
domain: MetadataSuggestionDomain
): Promise<Categories> =>
tryCatchEmptyObject<Categories>(
fetchCategoryRegistry(connection, domain).then((cats) =>
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
)
);
const fetchEntities = (
connection: HomeAssistant["connection"]
): Promise<Entities> =>
tryCatchEmptyObject<Entities>(
subscribeOne(connection, subscribeEntityRegistry).then((ents) =>
Object.fromEntries(ents.map((ent) => [ent.entity_id, ent]))
)
);
const fetchLabels = (
connection: HomeAssistant["connection"]
): Promise<Labels> =>
tryCatchEmptyObject<Labels>(
subscribeOne(connection, subscribeLabelRegistry).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
)
);
function buildMetadataInspirations(
domain: MetadataSuggestionDomain,
states: HomeAssistant["states"],
entities: Entities,
categories?: Categories,
labels?: Labels
): string[] {
const inspirations: string[] = [];
for (const entityId of Object.keys(entities)) {
const entityEntry = entities[entityId];
if (!entityEntry || computeDomain(entityId) !== domain) {
continue;
}
const entity = states[entityId];
if (
!entity ||
entity.attributes.restored ||
!entity.attributes.friendly_name
) {
continue;
}
let inspiration = `- ${entity.attributes.friendly_name}`;
// Get the category for this domain
if (categories && categories[entityEntry.categories[domain]]) {
inspiration += ` (category: ${categories[entityEntry.categories[domain]]})`;
}
if (labels && entityEntry.labels.length) {
inspiration += ` (labels: ${entityEntry.labels
.map((label) => labels[label])
.join(", ")})`;
}
inspirations.push(inspiration);
}
return inspirations;
}
// Always English to format lists in the prompt
const PROMPT_LIST_FORMAT = new Intl.ListFormat("en", {
style: "long",
type: "conjunction",
});
/**
* Generates an AI task for suggesting metadata
* for automations or scripts based on their configuration.
* Generates an AI task for suggesting metadata based on their configuration.
*
* @param connection - Home Assistant connection
* @param states - Current state objects
* @param language - User's language preference
* @param domain - The domain to suggest metadata for (automation, script)
* @param domain - The domain to suggest metadata for
* @param config - The configuration to suggest metadata for
* @param inspirations - Existing entries to use as inspiration
* @param include - The metadata fields to include in the suggestion
* @returns Promise resolving to the AI task structure
*/
export async function generateMetadataSuggestionTask<T>(
connection: HomeAssistant["connection"],
states: HomeAssistant["states"],
language: HomeAssistant["language"],
domain: MetadataSuggestionDomain,
config: T,
include = SUGGESTION_INCLUDE_ALL
inspirations: string[] = [],
include = SUGGESTION_INCLUDE_DEFAULT
): Promise<SuggestWithAIGenerateTask> {
const [categories, entities, labels] = await Promise.all([
const [categories, floors] = await Promise.all([
include.categories
? fetchCategories(connection, domain)
: Promise.resolve(undefined),
fetchEntities(connection),
include.labels ? fetchLabels(connection) : Promise.resolve(undefined),
include.floor ? fetchFloors(connection) : Promise.resolve(undefined),
]);
const inspirations = buildMetadataInspirations(
domain,
states,
entities,
categories,
labels
);
const structure: AITaskStructure = {
name: {
description: `The name of the ${domain}`,
required: true,
selector: {
text: {},
...(include.name && {
name: {
description: `The name of the ${domain}`,
required: true,
selector: {
text: {},
},
},
},
}),
...(include.description && {
description: {
description: `A short description of the ${domain}`,
@@ -193,49 +114,83 @@ export async function generateMetadataSuggestionTask<T>(
},
},
}),
...(include.floor &&
floors && {
floor: {
description: `The floor of the ${domain}`,
required: false,
selector: {
select: {
options: Object.values(floors).map((floor) => ({
value: floor.floor_id,
label: floor.name,
})),
},
},
},
}),
};
const categoryLabelText: string[] = [];
if (include.categories) {
categoryLabelText.push("category");
}
if (include.labels) {
categoryLabelText.push("labels");
}
const categoryLabelString =
categoryLabelText.length > 0 ? `, ${categoryLabelText.join(" and ")}` : "";
const requestedParts = [
include.name ? "a name" : null,
include.description ? "a description" : null,
include.categories ? "a category" : null,
include.labels ? "labels" : null,
include.floor ? "a floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabels: string[] = [
include.categories ? "category" : null,
include.labels ? "labels" : null,
include.floor ? "floor" : null,
].filter((entry): entry is string => entry !== null);
const categoryLabelsText = PROMPT_LIST_FORMAT.format(categoryLabels);
const requestedPartsText = requestedParts.length
? PROMPT_LIST_FORMAT.format(requestedParts)
: "suggestions";
return {
type: "data",
task: {
task_name: `frontend__${domain}__save`,
instructions: `Suggest in language "${language}" a name${include.description ? ", description" : ""}${categoryLabelString} for the following Home Assistant ${domain}.
The name should be relevant to the ${domain}'s purpose.
${
inspirations.length
? `The name should be in same style and sentence capitalization as existing ${domain}s.${
include.categories || include.labels
? `
Suggest ${categoryLabelText.join(" and ")} if relevant to the ${domain}'s purpose.
Only suggest ${categoryLabelText.join(" and ")} that are already used by existing ${domain}s.`
: ""
}`
: `The name should be short, descriptive, sentence case, and written in the language ${language}.`
}${
include.description
? `
If the ${domain} contains 5+ steps, include a short description.`
: ""
}
For inspiration, here are existing ${domain}s:
${inspirations.join("\n")}
The ${domain} configuration is as follows:
${dump(config)}
`,
instructions: [
`Suggest in language "${language}" ${requestedPartsText} for the following Home Assistant ${domain}.`,
"",
include.name
? `The name should be relevant to the ${domain}'s purpose.`
: `The suggestions should be relevant to the ${domain}'s purpose.`,
...(inspirations.length
? [
...(include.name
? [
`The name should be in same style and sentence capitalization as existing ${domain}s.`,
]
: []),
...(include.categories || include.labels || include.floor
? [
`Suggest ${categoryLabelsText} if relevant to the ${domain}'s purpose.`,
`Only suggest ${categoryLabelsText} that are already used by existing ${domain}s.`,
]
: []),
]
: include.name
? [
`The name should be short, descriptive, sentence case, and written in the language ${language}.`,
]
: []),
...(include.description
? [`If the ${domain} contains 5+ steps, include a short description.`]
: []),
"",
`For inspiration, here are existing ${domain}s:`,
inspirations.join("\n"),
"",
`The ${domain} configuration is as follows:`,
"",
`${dump(config)}`,
].join("\n"),
structure,
},
};
@@ -243,7 +198,7 @@ ${dump(config)}
/**
* Processes the result of an AI task for suggesting metadata
* for automations or scripts based on their configuration.
* based on their configuration.
*
* @param connection - Home Assistant connection
* @param domain - The domain of the ${domain}
@@ -255,17 +210,18 @@ export async function processMetadataSuggestion(
connection: HomeAssistant["connection"],
domain: MetadataSuggestionDomain,
result: GenDataTaskResult<MetadataSuggestionResult>,
include: MetadataSuggestionInclude
include = SUGGESTION_INCLUDE_DEFAULT
): Promise<MetadataSuggestionResult> {
const [categories, labels] = await Promise.all([
const [categories, labels, floors] = await Promise.all([
include.categories
? fetchCategories(connection, domain)
: Promise.resolve(undefined),
include.labels ? fetchLabels(connection) : Promise.resolve(undefined),
include.floor ? fetchFloors(connection) : Promise.resolve(undefined),
]);
const processed: MetadataSuggestionResult = {
name: result.data.name,
name: include.name ? result.data.name : undefined,
description: include.description ? result.data.description : undefined,
};
@@ -302,5 +258,17 @@ export async function processMetadataSuggestion(
}
}
if (include.floor && floors && result.data.floor) {
const floorId =
result.data.floor in floors
? result.data.floor
: Object.entries(floors).find(
([, floor]) => floor.name === result.data.floor
)?.[0];
if (floorId) {
processed.floor = floorId;
}
}
return processed;
}

View File

@@ -0,0 +1,72 @@
import { subscribeOne } from "../../../common/util/subscribe-one";
import { subscribeAreaRegistry } from "../../../data/area/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import { fetchCategoryRegistry } from "../../../data/category_registry";
import {
subscribeEntityRegistry,
type EntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { subscribeFloorRegistry } from "../../../data/ws-floor_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import { subscribeLabelRegistry } from "../../../data/label/label_registry";
import type { HomeAssistant } from "../../../types";
import type { MetadataSuggestionDomain } from "./suggest-metadata-ai";
export type Categories = Record<string, string>;
export type Entities = Record<string, EntityRegistryEntry>;
export type Labels = Record<string, string>;
export type Floors = Record<string, FloorRegistryEntry>;
export type Areas = Record<string, AreaRegistryEntry>;
const tryCatchEmptyObject = <T>(promise: Promise<T>): Promise<T> =>
promise.catch((err) => {
// eslint-disable-next-line no-console
console.error("Error fetching data for suggestion: ", err);
return {} as T;
});
export const fetchCategories = (
connection: HomeAssistant["connection"],
domain: MetadataSuggestionDomain
): Promise<Categories> =>
tryCatchEmptyObject<Categories>(
fetchCategoryRegistry(connection, domain).then((cats) =>
Object.fromEntries(cats.map((cat) => [cat.category_id, cat.name]))
)
);
export const fetchLabels = (
connection: HomeAssistant["connection"]
): Promise<Labels> =>
tryCatchEmptyObject<Labels>(
subscribeOne(connection, subscribeLabelRegistry).then((labs) =>
Object.fromEntries(labs.map((lab) => [lab.label_id, lab.name]))
)
);
export const fetchFloors = (
connection: HomeAssistant["connection"]
): Promise<Floors> =>
tryCatchEmptyObject<Floors>(
subscribeOne(connection, subscribeFloorRegistry).then((floors) =>
Object.fromEntries(floors.map((floor) => [floor.floor_id, floor]))
)
);
export const fetchAreas = (
connection: HomeAssistant["connection"]
): Promise<Areas> =>
tryCatchEmptyObject<Areas>(
subscribeOne(connection, subscribeAreaRegistry).then((areas) =>
Object.fromEntries(areas.map((area) => [area.area_id, area]))
)
);
export const fetchEntities = (
connection: HomeAssistant["connection"]
): Promise<Entities> =>
tryCatchEmptyObject<Entities>(
subscribeOne(connection, subscribeEntityRegistry).then((ents) =>
Object.fromEntries(ents.map((ent) => [ent.entity_id, ent]))
)
);

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