Compare commits

...

127 Commits

Author SHA1 Message Date
Aidan Timson
e6e8a0a2d7 Restore 2025-11-25 15:49:43 +00:00
Aidan Timson
c2f8b81e15 Add close button when not confirmation/alert dialog 2025-11-25 15:44:02 +00:00
Aidan Timson
133f3d4cc9 Migrate generic dialog-box to ha-wa-dialog 2025-11-25 15:44:02 +00:00
Jan-Philipp Benecke
fe50c1212a Add undo/redo functionality to dashboard editor (#27259)
* Add undo/redo functionality to dashboard editor

* Use controller and move toast to undo stack

* Store location and navigate to view

* Await and catch errors

* Process code review
2025-11-25 16:09:13 +02:00
Petar Petrov
c01fbf5f47 Revert "Use entity name for activity/logbook renderer" (#28098) 2025-11-25 13:24:51 +01:00
Aidan Timson
5c8da28b61 Standardise scrollable area fade (#28074)
* Extract fade CSS into shared file

* Switch to linear gradient instead of box shadow

* Refactor

* Refactor

* Scrollbar

* Fix

* Create mixin

* Move styles into mixin

* Dont replace mixin styles

* Reuse bar-box-shadow

* Move position
2025-11-25 13:04:08 +02:00
Rahul Harpal
bbb3c0208b Remove defaultHidden property from area configuration in automation, scene, and script pickers (#28096) 2025-11-25 10:55:12 +00:00
Aidan Timson
82a3db39fe Use entity name for activity/logbook renderer (#28083) 2025-11-25 12:46:57 +02:00
Paul Bottein
254857b53f Improve sidebar and dashboard management logic (#28019)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-25 10:39:07 +00:00
Simon Lamon
1648be6b83 Rename DialogLovelaceDashboardConfigureStrategy (#28092) 2025-11-25 12:22:18 +02:00
Jan Layola
c4c774c217 Add total consumption to energy usage graph card (#28086)
* Add total consumption to energy usage graph card

* Add "used" suffix to energy usage graph card total

* Apply code review feedback

* Update src/panels/lovelace/cards/energy/hui-energy-usage-graph-card.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 10:18:29 +00:00
Bram Kragten
6a0aab2088 Add dynamic condition support (#28058)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 11:17:53 +01:00
Aidan Timson
5a5593ec5b Add labels to device info card on device/service/hub page (#28082)
* Fix typo

* Add labels to device info card

* Lock

* Use token

* Add same class

* Apply suggestions from code review

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

* Match codebase style

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-25 08:47:35 +02:00
Paulus Schoutsen
2d39afdeac Render full chat log in voice debug (#27678) 2025-11-24 22:19:09 -05:00
Paul Bottein
974ac31277 Winter is coming (#28036)
* Winter is coming

* Set max to 30 on mobile

* Change storage key

* Use core

* Add browser level switch for winter mode

* Simplify logic
2025-11-24 22:11:38 +01:00
Petar Petrov
47e98d532d Fix energy distribution card not advancing to next day (#28081) 2025-11-24 20:44:22 +01:00
Jan Bouwhuis
2efc513221 Bump home-assistant-js-websocket to 9.6.0 (#28091) 2025-11-24 20:25:33 +01:00
Aidan Timson
5d820e3046 Fix color of dialog header and expansion panel (#28087) 2025-11-24 17:57:10 +01:00
Wendelin
c3cff3bcd3 Fix add to list item (#28073)
* Fix add to list item

* Remove unused css
2025-11-24 17:55:53 +02:00
Bram Kragten
e65a8a6b66 Add device database toggle to analytics (#27948)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 16:30:26 +01:00
Aidan Timson
318452d6f6 Reset automation sidebar width on double click (#28076) 2025-11-24 14:37:11 +01:00
Wendelin
6890823c31 Make login button full width (#28072) 2025-11-24 10:49:13 +01:00
Simon Lamon
ba9bab38c9 Migrate updates dropdown to ha-dropdown (#28039)
* Migrate upgrade dropdown

* Apply suggestion from @MindFreeze

* Update src/panels/config/core/ha-config-section-updates.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 09:06:55 +00:00
Wendelin
085b3884af Fix add to selected action (#28062) 2025-11-24 11:01:58 +02:00
Simon Lamon
a9c816ed9c Migrate analytics dropdown to ha-dropdown (#28038)
* Migrate analytics dropdown

* Apply suggestion from @MindFreeze

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-24 08:52:22 +00:00
Nico Wiedemann
c3201eecf3 Show blueprint usage count in overview (#28054)
* Show blueprint usage count in overview

* feat(blueprint): add usage count functionality and update table rendering

* feat(blueprint): enhance usage count display with interactive chip

* fix(blueprint): convert _handleUsageClick to an arrow function for consistent context binding

* Update src/panels/config/blueprint/ha-blueprint-overview.ts

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 07:07:04 +00:00
dependabot[bot]
bd67a3b90a Bump actions/checkout from 5.0.0 to 6.0.0 (#28056) 2025-11-24 07:28:51 +01:00
dependabot[bot]
e59ab45e60 Bump github/codeql-action from 4.31.3 to 4.31.4 (#28057) 2025-11-24 07:13:43 +01:00
Franck Nijhof
4674384f7d Allow deleting helpers directly from helpers panel (#28044)
* Allow deleting helpers directly from helpers panel

* Fix error handling in helper deletion logic

Address review feedback by improving the logic in _deleteHelper:
- Throw error when entity is not found in registry or component not loaded
- Move return statement outside of nested condition to ensure proper control flow
- Prevent fallthrough to config entry deletion for old-style helpers

Addresses review comment from @MindFreeze

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-23 15:20:20 +01:00
steinmn
2e7c95a8cc Translate dashboard panel url error message (#27306)
* Translate dashboard panel url error message

* Use backend provided translations
2025-11-23 14:54:21 +01:00
Simon Lamon
9630b175a1 Remove open from ha-dropdown (#28037) 2025-11-22 14:30:06 +02:00
Petar Petrov
be319503f7 Update color scheme in ZHA network visualization (#28032) 2025-11-21 19:09:37 +01:00
Paul Bottein
9b1fe28018 Rename defaultPanel to default_panel (#28035) 2025-11-21 16:24:34 +01:00
Paul Bottein
0595f722f3 Add basic editor to edit favorites entities for home panel (#28028)
* Add basic editor to edit favorites entities for home panel

* Rename favorites

* Rename favorites

* Feedbacks
2025-11-21 16:19:39 +02:00
Petar Petrov
1c0315854a Hide echarts toolbox better (#28030) 2025-11-21 14:52:20 +02:00
Bram Kragten
3b73d7c298 Dont add store token for external auth flows (#28026)
* Dont add store token for external auth flows

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 12:01:02 +00:00
Aidan Timson
2955cb4956 Make use of documentationUrl over hardcoded docs links (#28022)
Make use of documentationUrl over hardcoded docs link
2025-11-21 13:43:46 +02:00
Franck Nijhof
c679e312a0 Add delete option to reauth cards on integrations dashboard (#28020)
* Add delete option to reauth cards on integrations dashboard

Users can now delete config entries directly from the reauth card that appears at the top of the integrations dashboard, instead of having to scroll down to find the original integration card.

The delete option:
- Appears in the three-dot menu on reauth cards
- Shows a confirmation dialog before deletion
- Handles application credentials cleanup
- Shows restart notifications when required
- Uses the same styling and localization as the integration entry delete

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

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

* Apply suggestions from code review

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 13:39:01 +02:00
Aidan Timson
37e12a83be Fix scene review switch action overflow issue (#28024)
* Fix scene review switch action overflow issue

* Be more specific
2025-11-21 10:33:49 +00:00
Aidan Timson
755c6dbb93 Add design tokens to labs feature (#28023) 2025-11-21 11:21:33 +01:00
Silas Krause
4a90331ac7 Add markdown support for assist messages (#27957)
* Add markdown support for assist messages

* Improve styles

* Refactor code

* Fix white space

* Move code

* Make css compiler happy

* Wait for render to complete before scrolling

* Revert changes

* Refactor ha-markdown to render in chunks

* Refactor and adapt scroll logic

* Fix imports

* Update styles

* Render into renderRoot

* Fix query selector

* Fix broken image style

* Implement PR feedback

* Remove unnecessary css

* Fix cache issue

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 08:03:47 +00:00
Aidan Timson
7b264ae338 Standardise fixed top ha-dialog usages and fix safe areas (#27997)
* Add default padding

* Use vars

* Restore

* Use container padding

* Move fixed top styles to shared styles

* Use fixed styles and adjust for safe areas on media browse dialog

* Use vars

* Reduce

* Reuse

* Add to strategy editor dialog

* Reuse for editors

* Reuse for media manager

* Remove redundant code

* Reuse for quick bar

* Add

* Use vars

* Fix

* Fix

* Adjust for safe area

* Fix

* Fix

* Default

* Fix

* Extra space at bottom

* Remove override

* Remove

* Fix

* Fix

* Remove

* Fix

* Fix

* Use y inset

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

* Format

* Update src/components/media-player/dialog-media-player-browse.ts

* Format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-21 08:24:14 +02:00
Franck Nijhof
bb5fefce2b Introduce Home Assistant Labs (#27989)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-11-20 21:22:14 +01:00
Aidan Timson
5703de9616 Update LLM instructions to recent codebase changes (#28017)
* Update dialog instructions

* Add button styling and dialog sizing

* Document spacing tokens

* Format

* Explicit spacing token documentation

* View transitions

* Add vt notes

* Keyboard shortcuts

* Tooltip

* Link to the gallery for guidelines

* Add AGENTS.md symlink for newer tools

* Use more sane spacing tokens
2025-11-20 17:59:56 +00:00
Petar Petrov
eee2c1e8fd Add power graphs in energy view (#28010) 2025-11-20 18:45:04 +01:00
Petar Petrov
d4c1642ccc Fix network graph panning (#28015) 2025-11-20 18:37:33 +01:00
Aidan Timson
0d693e692a Update dialogs to use space tokens (#28018) 2025-11-20 18:35:08 +01:00
Aidan Timson
c6091971b6 Fix overflowing content issue in states view (#28009) 2025-11-20 12:32:32 +00:00
hanwg
60229ceba0 Add markdown to parameter descriptions for actions (#27944)
add markdown to parameter descriptions for actions
2025-11-20 08:39:27 +02:00
Paul Bottein
e45b631e27 Allow to reorder areas and floors (#27986)
* Add websocket commands

* Add area reordering

* Reorder floors

* Order areas and floors everywhere

* Use right area order in area floor picker

* Add error handling

* Refactor
2025-11-19 16:01:02 +01:00
Petar Petrov
ea798cda90 Bump glob to 12.0.0 (#27999) 2025-11-19 15:18:15 +01:00
Petar Petrov
259f4421db Add detail option to trend card feature (#27993) 2025-11-19 15:17:03 +01:00
Paul Bottein
1ac3cf199f Save default panel at user and system level (#27899)
* Save default panel in user data

* Change logic for default panel

* Fix types

* Fix typings

* Fix user and local storage

* Use user data and system data

* Update url path and update dashboard settings

* Fix tests

* Wait for panels and user/system data to be loaded

* Update comment

* Update comment

* Set empty object instead of null

* Update comment

* Feedbacks

* Apply suggestions from code review

* format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-19 15:03:20 +01:00
Aidan Timson
8d96679cc3 Swap to margins for narrow safe areas in ha-md-dialog (#27994)
Swap to margins for narrow layouts and safe areas
2025-11-19 15:56:10 +02:00
karwosts
ba9c7f3012 Expose location for calendar events (#27983)
* Expose location for calendar events

* from review
2025-11-19 15:40:19 +02:00
Petar Petrov
f8923ed648 Split Energy panel into overview and electricity view (#27534) 2025-11-19 14:11:27 +01:00
Petar Petrov
20d0548d33 Add min/max options to bar gauge feature (#27933) 2025-11-19 11:20:06 +01:00
Aidan Timson
04aaae20f5 Create keyboard shortcuts helper (tinykeys) (#27176) 2025-11-19 11:19:01 +01:00
karwosts
852dbbeee0 Fix keyboard for integration page overflow menus (#27991) 2025-11-19 08:44:51 +02:00
Ezra Freedman
d57367f62e Fix selection state not updating after item deletion (#27972)
* Fix selection state not updating after item deletion

* only update selected list if script is found
2025-11-19 08:20:12 +02:00
Petar Petrov
47a107dd85 Add Power Sankey card (#27966) 2025-11-18 19:52:52 +01:00
renovate[bot]
3573e823e4 Update Yarn to v4.11.0 (#27978)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 17:05:36 +00:00
Ingolf Becker
1a8319a3ab Create mobile column gap variable in hui-sections-view (#27949)
Update mobile column gap variable in hui-sections-view
2025-11-18 14:56:28 +02:00
dependabot[bot]
ac23ce6300 Bump glob from 11.0.3 to 11.1.0 (#27976)
Bumps [glob](https://github.com/isaacs/node-glob) from 11.0.3 to 11.1.0.
- [Changelog](https://github.com/isaacs/node-glob/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/node-glob/compare/v11.0.3...v11.1.0)

---
updated-dependencies:
- dependency-name: glob
  dependency-version: 11.1.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-18 06:27:19 +01:00
karwosts
fc38365958 Fix keyboard for ha-date-input (#27968)
* Fix keyboard for ha-date-input

* Update src/components/ha-date-input.ts

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-17 14:22:36 +00:00
koostamas
b93bf3bc4b Remote stream playing in picture-in-picture fix (#27958)
* Remote stream playing in picture-in-picture fix

* Update src/components/ha-hls-player.ts

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

* Update src/components/ha-web-rtc-player.ts

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-17 15:44:24 +02:00
Raad Altaie
869ab6ffc4 disable browser autofill on search inputs (#27963) 2025-11-17 12:58:40 +00:00
dependabot[bot]
effba9b918 Bump github/codeql-action from 4.31.2 to 4.31.3 (#27965) 2025-11-17 07:19:27 +01:00
Yosi Levy
c848673b1f Various RTL fixes (#27886) 2025-11-16 14:42:09 +02:00
renovate[bot]
074095d3dc Update dependency js-yaml to v4.1.1 [SECURITY] (#27955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-15 15:42:16 +02:00
Aidan Timson
1bd1e015ff Migrate dialog-lovelace-resource-detail to ha-wa-dialog (#27939) 2025-11-14 08:32:41 +02:00
Aidan Timson
7588490419 Migrate dialog-config-entry-system-options to ha-wa-dialog (#27938) 2025-11-14 08:27:17 +02:00
Petar Petrov
2e80a3ddab Add configurable chart modes in energy devices graph card (#27937) 2025-11-14 08:16:36 +02:00
Bram Kragten
332694549c Add support for triggers.yaml (#27379) 2025-11-13 23:31:40 +01:00
karwosts
396ddef722 Expose completed timestamp for TodoItem (#27943) 2025-11-13 22:40:56 +01:00
Aidan Timson
d02804449a Merge media selectors for index.html.template (#27941) 2025-11-13 22:33:30 +01:00
Simon Lamon
4ab24cdc72 Rspack: Deprecated layers (#27942) 2025-11-13 22:32:37 +01:00
Aidan Timson
81c27090d2 Create withViewTransition wrapper function (#27918)
* Create withViewTransition wrapper function

* Add missing space

* Remove function, check for view transition, add param

* Document
2025-11-13 17:32:15 +02:00
karwosts
09bdfd3ad7 Fix incorrect (Disabled) string in trigger (#27935) 2025-11-13 14:36:05 +00:00
karwosts
97e49f751c Fix media image on dashboard-level background (#27934) 2025-11-13 15:43:27 +02:00
Aidan Timson
e0d241a2db Move unimplemented base animations to theme styles (#27920) 2025-11-13 15:37:13 +02:00
Petar Petrov
83e065ae98 Power sources chart (#27501)
* Add power configuration to Energy dashboard

* update translation

* Update src/translations/en.json

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

* Update src/panels/config/energy/dialogs/dialog-energy-grid-flow-settings.ts

Co-authored-by: Aidan Timson <aidan@timmo.dev>

* Power graph card

* Single stat for bidirectional power

* Rename power graph to power sources graph

* remove debug code

* tweak

* update translations

* remove unused code

* Separate grid power from energy

* update translation

* update translation

* update data format

* Apply suggestions from code review

Co-authored-by: Aidan Timson <aidan@timmo.dev>

* Renamed stat_power to stat_rate

* translation tweak

* rename to stat_rate

* Add a line depicting used power

* Typescript improvements

* Add comment

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-11-13 09:26:49 +00:00
Paulus Schoutsen
a6ee670682 Fix bad minification (#27926) 2025-11-12 23:29:58 -05:00
Wendelin
c457f92826 Upgrade WA to 3.0.0 (#27919) 2025-11-12 17:39:56 +01:00
karwosts
c73bd96a1f Fix fields without selectors (#27917) 2025-11-12 17:26:48 +02:00
Wendelin
711f8e2fc3 Fix ha-dropdown and add shadow tokens (#27916)
* Fix dropdown select handle in refresh tokens

* Use semantic shadows for dropdown

* Fix token names
2025-11-12 16:52:05 +02:00
Aidan Timson
91a0066544 Add dashboard time visibility condition (#27790)
* Add time-based conditional visibility for cards

* Move clearTimeout outside of scheduleUpdate

* Add time string validation

* Add time string validation

* Remove runtime validation as config shouldnt allow bad values

* Fix for midnight crossing

* Cap timeout to 32-bit signed integer

* Add listener tests

* Additional tests

* Format
2025-11-12 15:55:59 +02:00
Aidan Timson
aee7b8b8d4 Setup base animation styles, add fade out to launch screen (#27829)
* Setup base animation styles

* Add fade out to launch screen

* Cleanup

* Set opacity before removing element

* Remove

* Final

* Use computed duration for timeout

* Add skip animation prop

* Swap

* Use common function and fix issue
2025-11-12 11:54:53 +02:00
Aidan Timson
d38d770e1a Refactor ConditionalListenerMixin and extract shared utilities (#27858)
* Refactor ConditionalListenerMixin and extract shared utilities

* Remove

* Use proper type

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

* Import

* Fix typing

* Docstrings

* Use generic types and refactor visibility handling

* Fix function signature and handle other keys separately

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-12 09:50:38 +00:00
Petar Petrov
0036679553 Fix target picker displaying blank (#27910) 2025-11-12 09:49:37 +01:00
Simon Lamon
2b85108242 Introduce ha-dropdown (#27417)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
2025-11-12 09:23:31 +01:00
Petar Petrov
c74320cb82 Add power configuration to Energy dashboard (#27373)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-11-12 09:21:52 +01:00
renovate[bot]
8ebe6e24d2 Update dependency marked to v17 (#27885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-12 09:24:17 +02:00
Paul Bottein
d3182da587 Create dedicated panel for home dashboard (#27861)
* Create dedicated panel for home dashboard

* Don't use state if only one view

* Prettier

* Use hui-root

* Add alert for edit mode

* Remove no edit

* Add home panel to dashboard list
2025-11-12 09:09:46 +02:00
Petar Petrov
41fbc5e44b Increase ZHA reconfiguration dialog width for details view (#27909) 2025-11-11 20:07:10 +01:00
Tobias Bieniek
3fea41eb0e Add scenes category to home dashboard area views (#27712)
This is similar to the "Automations" category that was added in 52eb3d8, but for "Scenes" this time. It is positioned after the other summary sections.
2025-11-11 11:19:02 +02:00
ildar170975
9a627bdea7 Data tables: remove unneeded "direction: asc" lines (#27903)
* remove unneeded "direction: asc"

* remove unneeded "direction: asc"

* remove unneeded "direction: asc"
2025-11-11 08:14:03 +02:00
Norbert Rittel
d9a67f603d Fix grammar in new_automation_setup_failed_text (#27898)
* Fix grammar in `new_automation_setup_failed_text`

* Remove excessive comma
2025-11-10 18:48:26 +01:00
Wendelin
5f37f8c0ab Use generic picker for target-picker (#27850)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 17:15:16 +01:00
Petar Petrov
f222702abf Fix entity name in statistics chart (#27896) 2025-11-10 15:08:33 +01:00
Copilot
2107b7c267 Fix doubled tooltips on timeline charts for mobile devices (#27888)
* Initial plan

* Fix doubled date popups in timeline charts on mobile

Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>

* Add comment explaining triggerTooltip fix

* Actual fix

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MindFreeze <5219205+MindFreeze@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 14:31:30 +01:00
Petar Petrov
1c05afebd7 Smooth sensor card more when "Show more detail" is disabled (#27891)
* Smooth sensor card more when "Show more detail" is disabled

* Set minimum sample points to 10
2025-11-10 14:23:26 +01:00
Paul Bottein
7179bb2d26 Assume default visible true for panels (#27894) 2025-11-10 15:04:14 +02:00
Wendelin
95cf1fdcf7 Fix target picker for entity_id: none (#27893)
Fix notFound condition to exclude 'none' in ha-target-picker-item-row
2025-11-10 12:23:16 +00:00
renovate[bot]
9617956cc6 Update vitest monorepo to v4.0.8 (#27892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 14:15:36 +02:00
karwosts
65df464731 Fix entity editor with non-existant entity (#27875) 2025-11-10 14:09:44 +02:00
Wendelin
bd4e9a3d05 Use ha-ripple in ha-md-list-item (#27889) 2025-11-10 14:04:30 +02:00
ildar170975
963fc13a99 relative_time: increase thresholds (#27870)
* increase thresholds

* restored for days & hours
2025-11-10 12:58:13 +02:00
renovate[bot]
ff614918d4 Update vaadinWebComponents monorepo to v24.9.5 (#27884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 12:55:03 +02:00
ildar170975
48aa5fb970 hui-generic-entity-row: add tooltips for relative-time (#27871)
* add tooltips for relative-rime

* lint

* fix import

* prettier

* move a call of uid() to a private property

* some test change
2025-11-10 12:54:26 +02:00
ildar170975
190af65756 Display tooltips for labels (#27613)
* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description fo ha-label

* add a description for ha-label

* add ha-tooltip for ha-input-chip

* add ha-tooltip

* replace() -> replaceAll()

* replace() -> replaceAll()

* prettier

* fix styles to enlarge an "active tooltip area"

* additional check for null for "description"

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* simplify a check for description

* call uid() in constructor

* fix a check for null

* attempting to bypass insecure randomness

* move a call of uid() into constructor

* uid generation tweak

* Apply suggestions from code review

* prettier

* simplify

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-10 10:04:31 +00:00
Petar Petrov
76911e0e0d Dynamic total energy for pie chart (#27883) 2025-11-10 10:20:26 +01:00
Petar Petrov
b8ec7c2e72 Fix chart label outline color (#27882) 2025-11-10 08:53:18 +01:00
dependabot[bot]
10e20c2272 Bump softprops/action-gh-release from 2.4.1 to 2.4.2 (#27879) 2025-11-09 22:14:19 -08:00
dependabot[bot]
2ec05aac2f Bump relative-ci/agent-action from 3.1.0 to 3.2.0 (#27880) 2025-11-09 22:12:30 -08:00
renovate[bot]
61fbe5b53c Update dependency marked to v16.4.2 (#27877)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-10 06:57:39 +01:00
Yuksel Beyti
5f3f1c7139 Fix malformed HTML tags in backup backups component (#27872) 2025-11-09 13:56:43 +02:00
ildar170975
48f37b1b1e "Expand" tooltips: remove a trailing dot (#27869)
remove trailing dot
2025-11-09 09:00:28 +01:00
karwosts
9091df9db5 Fix sequence action copy-paste (#27652) 2025-11-08 15:50:38 +01:00
renovate[bot]
1cd7a1cd78 Update dependency @rsdoctor/rspack-plugin to v1.3.8 (#27867)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:48:08 +01:00
renovate[bot]
ae5c7026b9 Update dependency @rspack/core to v1.6.1 (#27864)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:16:38 +01:00
renovate[bot]
3900f3995a Update vitest monorepo to v4.0.7 (#27862)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-08 13:16:16 +01:00
Paul Bottein
237f974ee8 Only show panel with default visible flag in sidebar (#27838) 2025-11-08 13:15:43 +01:00
Paul Bottein
b2ec4b7d2c Fix backup download and delete actions (#27851) 2025-11-07 13:43:00 +02:00
Aidan Timson
b4c83d7877 Migrate dialog-repairs-issue to ha-wa-dialog (#27667)
* Migrate dialog-repairs-issue to ha-wa-dialog

* Allow for custom slots for header title and subtitle, using boolean to enable

* Fix typing

* Cleanup

* Refactor header slot logic

* [workaround] pass aria attributes to dialog

* Update src/components/ha-wa-dialog.ts

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

* Remove unused (by wa) aira attrs

* Fix imports

* Revert "Remove unused (by wa) aira attrs"

This reverts commit ce97bebce4.

* Remove workaround

* Remove

* Format

* Fix subtitle margin

* Use spacing token

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-11-07 13:42:07 +02:00
Paul Bottein
22c53b6859 Add area context in zha, zwave and bluetooth graph (#27849)
* Add area context in zha, zwave and bluetooth graph

* Fix undefined device

* Fix typing

* Add context to find area
2025-11-07 13:34:44 +02:00
Paul Bottein
750b1e5e16 Avoid cropping in base graph (#27848)
* Avoid cropping in base graph

* Add bottom margin
2025-11-07 11:25:35 +01:00
289 changed files with 14885 additions and 4565 deletions

View File

@@ -2,6 +2,8 @@
You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control. You are an assistant helping with development of the Home Assistant frontend. The frontend is built using Lit-based Web Components and TypeScript, providing a responsive and performant interface for home automation control.
**Note**: This file contains high-level guidelines and references to implementation patterns. For detailed component documentation, API references, and usage examples, refer to the `gallery/` directory.
## Table of Contents ## Table of Contents
- [Quick Reference](#quick-reference) - [Quick Reference](#quick-reference)
@@ -151,6 +153,10 @@ try {
### Styling Guidelines ### Styling Guidelines
- **Use CSS custom properties**: Leverage the theme system - **Use CSS custom properties**: Leverage the theme system
- **Use spacing tokens**: Prefer `--ha-space-*` tokens over hardcoded values for consistent spacing
- Spacing scale: `--ha-space-0` (0px) through `--ha-space-20` (80px) in 4px increments
- Defined in `src/resources/theme/core.globals.ts`
- Common values: `--ha-space-2` (8px), `--ha-space-4` (16px), `--ha-space-8` (32px)
- **Mobile-first responsive**: Design for mobile, enhance for desktop - **Mobile-first responsive**: Design for mobile, enhance for desktop
- **Follow Material Design**: Use Material Web Components where appropriate - **Follow Material Design**: Use Material Web Components where appropriate
- **Support RTL**: Ensure all layouts work in RTL languages - **Support RTL**: Ensure all layouts work in RTL languages
@@ -159,21 +165,68 @@ try {
static get styles() { static get styles() {
return css` return css`
:host { :host {
--spacing: 16px; padding: var(--ha-space-4);
padding: var(--spacing);
color: var(--primary-text-color); color: var(--primary-text-color);
background-color: var(--card-background-color); background-color: var(--card-background-color);
} }
.content {
gap: var(--ha-space-2);
}
@media (max-width: 600px) { @media (max-width: 600px) {
:host { :host {
--spacing: 8px; padding: var(--ha-space-2);
} }
} }
`; `;
} }
``` ```
### View Transitions
The View Transitions API creates smooth animations between DOM state changes. When implementing view transitions:
**Core Resources:**
- **Utility wrapper**: `src/common/util/view-transition.ts` - `withViewTransition()` function with graceful fallback
- **Real-world example**: `src/util/launch-screen.ts` - Launch screen fade pattern with browser support detection
- **Animation keyframes**: `src/resources/theme/animations.globals.ts` - Global `fade-in`, `fade-out`, `scale` animations
- **Animation duration**: `src/resources/theme/core.globals.ts` - `--ha-animation-base-duration` (350ms, respects `prefers-reduced-motion`)
**Implementation Guidelines:**
1. Always use `withViewTransition()` wrapper for automatic fallback
2. Keep transitions simple (subtle crossfades and fades work best)
3. Use `--ha-animation-base-duration` CSS variable for consistent timing
4. Assign unique `view-transition-name` to elements (must be unique at any given time)
5. For Lit components: Override `performUpdate()` or use `::part()` for internal elements
**Default Root Transition:**
By default, `:root` receives `view-transition-name: root`, creating a full-page crossfade. Target with [`::view-transition-group(root)`](https://developer.mozilla.org/en-US/docs/Web/CSS/::view-transition-group) to customize the default page transition.
**Important Constraints:**
- Each `view-transition-name` must be unique at any given time
- Only one view transition can run at a time
- **Shadow DOM incompatibility**: View transitions operate at document level and do not work within Shadow DOM due to style isolation ([spec discussion](https://github.com/w3c/csswg-drafts/issues/10303)). For web components, set `view-transition-name` on the `:host` element or use document-level transitions
**Current Usage & Planned Applications:**
- Launch screen fade out (implemented)
- Automation sidebar transitions (planned - #27238)
- More info dialog content changes (planned - #27672)
- Toolbar navigation, ha-spinner transitions (planned)
**Specification & Documentation:**
For browser support, API details, and current specifications, refer to these authoritative sources (note: check publication dates as specs evolve):
- [MDN: View Transition API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transition_API) - Comprehensive API reference
- [Chrome for Developers: View Transitions](https://developer.chrome.com/docs/web-platform/view-transitions) - Implementation guide and examples
- [W3C Draft Specification](https://drafts.csswg.org/css-view-transitions/) - Official specification (evolving)
### Performance Best Practices ### Performance Best Practices
- **Code split**: Split code at the panel/dialog level - **Code split**: Split code at the panel/dialog level
@@ -195,8 +248,9 @@ static get styles() {
**Available Dialog Types:** **Available Dialog Types:**
- `ha-md-dialog` - Preferred for new code (Material Design 3) - `ha-wa-dialog` - Preferred for new dialogs (Web Awesome based)
- `ha-dialog` - Legacy component still widely used - `ha-md-dialog` - Material Design 3 dialog component
- `ha-dialog` - Legacy component (still widely used)
**Opening Dialogs (Fire Event Pattern - Recommended):** **Opening Dialogs (Fire Event Pattern - Recommended):**
@@ -211,15 +265,45 @@ fireEvent(this, "show-dialog", {
**Dialog Implementation Requirements:** **Dialog Implementation Requirements:**
- Implement `HassDialog<T>` interface - Implement `HassDialog<T>` interface
- Use `createCloseHeading()` for standard headers - Use `@state() private _open = false` to control dialog visibility
- Import `haStyleDialog` for consistent styling - Set `_open = true` in `showDialog()`, `_open = false` in `closeDialog()`
- Return `nothing` when no params (loading state) - Return `nothing` when no params (loading state)
- Fire `dialog-closed` event when closing - Fire `dialog-closed` event in `_dialogClosed()` handler
- Add `dialogInitialFocus` for accessibility - Use `header-title` attribute for simple titles
- Use `header-subtitle` attribute for simple subtitles
- Use slots for custom content where the standard attributes are not enough
- Use `ha-dialog-footer` with `primaryAction`/`secondaryAction` slots for footer content
- Add `autofocus` to first focusable element (e.g., `<ha-form autofocus>`). The component may need to forward this attribute internally.
```` **Dialog Sizing:**
- Use `width` attribute with predefined sizes: `"small"` (320px), `"medium"` (560px - default), `"large"` (720px), or `"full"`
- Custom sizing is NOT recommended - use the standard width presets
- Example: `<ha-wa-dialog width="small">` for alert/confirmation dialogs
**Button Appearance Guidelines:**
- **Primary action buttons**: Default appearance (no appearance attribute) or omit for standard styling
- **Secondary action buttons**: Use `appearance="plain"` for cancel/dismiss actions
- **Destructive actions**: Use `appearance="filled"` for delete/remove operations (combined with appropriate semantic styling)
- **Button sizes**: Use `size="small"` (32px height) or default/medium (40px height)
- Always place primary action in `slot="primaryAction"` and secondary in `slot="secondaryAction"` within `ha-dialog-footer`
**Recent Examples:**
See these files for current patterns:
- `src/panels/config/repairs/dialog-repairs-issue.ts`
- `src/dialogs/restart/dialog-restart.ts`
- `src/panels/config/lovelace/resources/dialog-lovelace-resource-detail.ts`
**Gallery Documentation:**
- `gallery/src/pages/components/ha-wa-dialog.markdown`
- `gallery/src/pages/components/ha-dialogs.markdown`
### Form Component (ha-form) ### Form Component (ha-form)
- Schema-driven using `HaFormSchema[]` - Schema-driven using `HaFormSchema[]`
- Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors - Supports entity, device, area, target, number, boolean, time, action, text, object, select, icon, media, location selectors
- Built-in validation with error display - Built-in validation with error display
@@ -235,7 +319,11 @@ fireEvent(this, "show-dialog", {
.computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)} .computeLabel=${(schema) => this.hass.localize(`ui.panel.${schema.name}`)}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
```` ```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-form.markdown`
### Alert Component (ha-alert) ### Alert Component (ha-alert)
@@ -249,6 +337,35 @@ fireEvent(this, "show-dialog", {
<ha-alert alert-type="success" dismissable>Success message</ha-alert> <ha-alert alert-type="success" dismissable>Success message</ha-alert>
``` ```
**Gallery Documentation:**
- `gallery/src/pages/components/ha-alert.markdown`
### Keyboard Shortcuts (ShortcutManager)
The `ShortcutManager` class provides a unified way to register keyboard shortcuts with automatic input field protection.
**Key Features:**
- Automatically blocks shortcuts when input fields are focused
- Prevents shortcuts during text selection (configurable via `allowWhenTextSelected`)
- Supports both character-based and KeyCode-based shortcuts (for non-latin keyboards)
**Implementation:**
- **Class definition**: `src/common/keyboard/shortcuts.ts`
- **Real-world example**: `src/state/quick-bar-mixin.ts` - Global shortcuts (e, c, d, m, a, Shift+?) with non-latin keyboard fallbacks
### Tooltip Component (ha-tooltip)
The `ha-tooltip` component wraps Web Awesome tooltip with Home Assistant theming. Use for providing contextual help text on hover.
**Implementation:**
- **Component definition**: `src/components/ha-tooltip.ts`
- **Usage example**: `src/components/ha-label.ts`
- **Gallery documentation**: `gallery/src/pages/components/ha-tooltip.markdown`
## Common Patterns ## Common Patterns
### Creating a Panel ### Creating a Panel
@@ -289,11 +406,19 @@ export class DialogMyFeature
@state() @state()
private _params?: MyDialogParams; private _params?: MyDialogParams;
@state()
private _open = false;
public async showDialog(params: MyDialogParams): Promise<void> { public async showDialog(params: MyDialogParams): Promise<void> {
this._params = params; this._params = params;
this._open = true;
} }
public closeDialog(): void { public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined; this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName }); fireEvent(this, "dialog-closed", { dialog: this.localName });
} }
@@ -304,23 +429,27 @@ export class DialogMyFeature
} }
return html` return html`
<ha-dialog <ha-wa-dialog
open .hass=${this.hass}
@closed=${this.closeDialog} .open=${this._open}
.heading=${createCloseHeading(this.hass, this._params.title)} header-title=${this._params.title}
header-subtitle=${this._params.subtitle}
@closed=${this._dialogClosed}
> >
<!-- Dialog content --> <p>Dialog content</p>
<ha-button <ha-dialog-footer slot="footer">
appearance="plain" <ha-button
@click=${this.closeDialog} slot="secondaryAction"
slot="secondaryAction" appearance="plain"
> @click=${this.closeDialog}
${this.hass.localize("ui.common.cancel")} >
</ha-button> ${this.hass.localize("ui.common.cancel")}
<ha-button @click=${this._submit} slot="primaryAction"> </ha-button>
${this.hass.localize("ui.common.save")} <ha-button slot="primaryAction" @click=${this._submit}>
</ha-button> ${this.hass.localize("ui.common.save")}
</ha-dialog> </ha-button>
</ha-dialog-footer>
</ha-wa-dialog>
`; `;
} }

View File

@@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: dev ref: dev
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: master ref: master

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
@@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
@@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:

View File

@@ -23,7 +23,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
# We must fetch at least the immediate parents so that if this is # We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head. # a pull request then we can checkout the head.
@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/init@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # 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) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/autobuild@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4
# Command-line programs to run using the OS shell. # Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl # 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release # make release
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0499de31b99561a6d14a36a5f662c2a54f91beee # v4.31.2 uses: github/codeql-action/analyze@e12f0178983d466f2f6028f5cc7a6d786fd97f4b # v4.31.4

View File

@@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: dev ref: dev
@@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with: with:
ref: master ref: master

View File

@@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }} url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0

View File

@@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview') if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps: steps:
- name: Check out files from GitHub - name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0

View File

@@ -20,7 +20,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Send bundle stats and build information to RelativeCI - name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0 uses: relative-ci/agent-action@feb19ddc698445db27401f1490f6ac182da0816f # v3.2.0
with: with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }} token: ${{ github.token }}

View File

@@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }} - name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
@@ -55,7 +55,7 @@ jobs:
script/release script/release
- name: Upload release assets - name: Upload release assets
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with: with:
files: | files: |
dist/*.whl dist/*.whl
@@ -91,7 +91,7 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
@@ -108,7 +108,7 @@ jobs:
- name: Tar folder - name: Tar folder
run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist . run: tar -czf landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz -C landing-page/dist .
- name: Upload release asset - name: Upload release asset
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with: with:
files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz files: landing-page/home_assistant_frontend_landingpage-${{ github.event.release.tag_name }}.tar.gz
@@ -120,7 +120,7 @@ jobs:
contents: write # Required to upload release assets contents: write # Required to upload release assets
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with: with:
@@ -137,6 +137,6 @@ jobs:
- name: Tar folder - name: Tar folder
run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build . run: tar -czf hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz -C hassio/build .
- name: Upload release asset - name: Upload release asset
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090 # v2.4.1 uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with: with:
files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz files: hassio/home_assistant_frontend_supervisor-${{ github.event.release.tag_name }}.tar.gz

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Upload Translations - name: Upload Translations
run: | run: |

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.10.3.cjs yarnPath: .yarn/releases/yarn-4.11.0.cjs

1
AGENTS.md Symbolic link
View File

@@ -0,0 +1 @@
.github/copilot-instructions.md

View File

@@ -260,7 +260,6 @@ const createRspackConfig = ({
), ),
}, },
experiments: { experiments: {
layers: true,
outputModule: true, outputModule: true,
}, },
}; };

View File

@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import "../../../../src/components/ha-card"; import "../../../../src/components/ha-card";
import "../../../../src/components/ha-yaml-editor"; import "../../../../src/components/ha-yaml-editor";
import type { Trigger } from "../../../../src/data/automation"; import type { LegacyTrigger } from "../../../../src/data/automation";
import { describeTrigger } from "../../../../src/data/automation_i18n"; import { describeTrigger } from "../../../../src/data/automation_i18n";
import { getEntity } from "../../../../src/fake_data/entity"; import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass"; import { provideHass } from "../../../../src/fake_data/provide_hass";
@@ -66,7 +66,7 @@ const triggers = [
}, },
]; ];
const initialTrigger: Trigger = { const initialTrigger: LegacyTrigger = {
trigger: "state", trigger: "state",
entity_id: "light.kitchen", entity_id: "light.kitchen",
}; };

View File

@@ -0,0 +1,55 @@
---
title: Dropdown
---
# Dropdown `<ha-dropdown>`
## Implementation
A compact, accessible dropdown menu for choosing actions or settings. `ha-dropdown` supports composed menu items (`<ha-dropdown-item>`) for icons, submenus, checkboxes, disabled entries, and destructive variants. Use composition with `slot="trigger"` to control the trigger button and use `<ha-dropdown-item>` for rich item content.
### Example usage (composition)
```html
<ha-dropdown>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCut" slot="icon"></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon .path="mdiContentCopy" slot="icon"></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon .path="mdiContentPaste" slot="icon"></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show all images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Emoji shortcuts</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked>Word wrap</ha-dropdown-item>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path="mdiDelete" slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
```
### API
This component is based on the webawesome dropdown component.
Check the [webawesome documentation](https://webawesome.com/docs/components/dropdown/) for more details.

View File

@@ -0,0 +1,133 @@
import "@home-assistant/webawesome/dist/components/button/button";
import "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import "@home-assistant/webawesome/dist/components/icon/icon";
import "@home-assistant/webawesome/dist/components/popup/popup";
import {
mdiContentCopy,
mdiContentCut,
mdiContentPaste,
mdiDelete,
} from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dropdown";
import "../../../../src/components/ha-dropdown-item";
import "../../../../src/components/ha-icon-button";
import "../../../../src/components/ha-svg-icon";
@customElement("demo-components-ha-dropdown")
export class DemoHaDropdown extends LitElement {
protected render(): TemplateResult {
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-button in ${mode}">
<div class="card-content">
<ha-dropdown>
<ha-button slot="trigger" with-caret>Dropdown</ha-button>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCut}
slot="icon"
></ha-svg-icon>
Cut
</ha-dropdown-item>
<ha-dropdown-item>
<ha-svg-icon
.path=${mdiContentCopy}
slot="icon"
></ha-svg-icon>
Copy
</ha-dropdown-item>
<ha-dropdown-item disabled>
<ha-svg-icon
.path=${mdiContentPaste}
slot="icon"
></ha-svg-icon>
Paste
</ha-dropdown-item>
<ha-dropdown-item>
Show images
<ha-dropdown-item slot="submenu" value="show-all-images"
>Show All Images</ha-dropdown-item
>
<ha-dropdown-item slot="submenu" value="show-thumbnails"
>Show Thumbnails</ha-dropdown-item
>
</ha-dropdown-item>
<ha-dropdown-item type="checkbox" checked
>Emoji Shortcuts</ha-dropdown-item
>
<ha-dropdown-item type="checkbox" checked
>Word Wrap</ha-dropdown-item
>
<ha-dropdown-item variant="danger">
<ha-svg-icon .path=${mdiDelete} slot="icon"></ha-svg-icon>
Delete
</ha-dropdown-item>
</ha-dropdown>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
}
.button {
padding: unset;
}
ha-card {
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.card-content div {
display: flex;
gap: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-dropdown": DemoHaDropdown;
}
}

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19", "@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19", "@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19", "@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.7", "@home-assistant/webawesome": "3.0.0",
"@lezer/highlight": "1.2.3", "@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9", "@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6", "@lit-labs/observers": "2.0.6",
@@ -89,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1", "@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0", "@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.9.4", "@vaadin/combo-box": "24.9.5",
"@vaadin/vaadin-themable-mixin": "24.9.4", "@vaadin/vaadin-themable-mixin": "24.9.5",
"@vibrant/color": "4.0.0", "@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0", "@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -112,17 +112,17 @@
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2", "gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.14", "hls.js": "1.6.14",
"home-assistant-js-websocket": "9.5.0", "home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.2",
"intl-messageformat": "10.7.18", "intl-messageformat": "10.7.18",
"js-yaml": "4.1.0", "js-yaml": "4.1.1",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3", "leaflet.markercluster": "1.5.3",
"lit": "3.3.1", "lit": "3.3.1",
"lit-html": "3.3.1", "lit-html": "3.3.1",
"luxon": "3.7.2", "luxon": "3.7.2",
"marked": "16.4.1", "marked": "17.0.0",
"memoize-one": "6.0.0", "memoize-one": "6.0.0",
"node-vibrant": "4.0.3", "node-vibrant": "4.0.3",
"object-hash": "3.0.0", "object-hash": "3.0.0",
@@ -157,8 +157,8 @@
"@octokit/auth-oauth-device": "8.0.3", "@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3", "@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1", "@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.7", "@rsdoctor/rspack-plugin": "1.3.8",
"@rspack/core": "1.6.0", "@rspack/core": "1.6.1",
"@rspack/dev-server": "1.1.4", "@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22", "@types/chromecast-caf-receiver": "6.0.22",
@@ -178,7 +178,7 @@
"@types/tar": "6.1.13", "@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.6", "@vitest/coverage-v8": "4.0.8",
"babel-loader": "10.0.0", "babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
@@ -194,7 +194,7 @@
"eslint-plugin-wc": "3.0.2", "eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.3.2", "fs-extra": "11.3.2",
"glob": "11.0.3", "glob": "12.0.0",
"gulp": "5.0.1", "gulp": "5.0.1",
"gulp-brotli": "3.0.0", "gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0", "gulp-json-transform": "0.5.0",
@@ -219,7 +219,7 @@
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.46.3", "typescript-eslint": "8.46.3",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.6", "vitest": "4.0.8",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -233,9 +233,10 @@
"@fullcalendar/daygrid": "6.1.19", "@fullcalendar/daygrid": "6.1.19",
"globals": "16.5.0", "globals": "16.5.0",
"tslib": "2.8.1", "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" "@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
}, },
"packageManager": "yarn@4.10.3", "packageManager": "yarn@4.11.0",
"volta": { "volta": {
"node": "22.21.1" "node": "22.21.1"
} }

View File

@@ -2,8 +2,8 @@
import { genClientId } from "home-assistant-js-websocket"; import { genClientId } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { keyed } from "lit/directives/keyed";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert"; import "../components/ha-alert";
import "../components/ha-button"; import "../components/ha-button";
@@ -59,7 +59,8 @@ export class HaAuthFlow extends LitElement {
willUpdate(changedProps: PropertyValues) { willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
if (!this.hasUpdated) { if (!this.hasUpdated && this.clientId === genClientId()) {
// Preselect store token when logging in to own instance
this._storeToken = this.initStoreToken; this._storeToken = this.initStoreToken;
} }
@@ -117,6 +118,9 @@ export class HaAuthFlow extends LitElement {
display: block; display: block;
margin-top: 16px; margin-top: 16px;
} }
.action ha-button {
width: 100%;
}
</style> </style>
<form>${this._renderForm()}</form> <form>${this._renderForm()}</form>
`; `;

View File

@@ -0,0 +1,53 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
export interface AreasFloorHierarchy {
floors: {
id: string;
areas: string[];
}[];
areas: string[];
}
export const getAreasFloorHierarchy = (
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[]
): AreasFloorHierarchy => {
const floorAreas = new Map<string, string[]>();
const unassignedAreas: string[] = [];
for (const area of areas) {
if (area.floor_id) {
if (!floorAreas.has(area.floor_id)) {
floorAreas.set(area.floor_id, []);
}
floorAreas.get(area.floor_id)!.push(area.area_id);
} else {
unassignedAreas.push(area.area_id);
}
}
const hierarchy: AreasFloorHierarchy = {
floors: floors.map((floor) => ({
id: floor.floor_id,
areas: floorAreas.get(floor.floor_id) || [],
})),
areas: unassignedAreas,
};
return hierarchy;
};
export const getAreasOrder = (hierarchy: AreasFloorHierarchy): string[] => {
const order: string[] = [];
for (const floor of hierarchy.floors) {
order.push(...floor.areas);
}
order.push(...hierarchy.areas);
return order;
};
export const getFloorOrder = (hierarchy: AreasFloorHierarchy): string[] =>
hierarchy.floors.map((floor) => floor.id);

View File

@@ -0,0 +1,36 @@
import type {
Condition,
TimeCondition,
} from "../../panels/lovelace/common/validate-condition";
/**
* Extract media queries from conditions recursively
*/
export function extractMediaQueries(conditions: Condition[]): string[] {
return conditions.reduce<string[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractMediaQueries(c.conditions));
}
if (c.condition === "screen" && c.media_query) {
array.push(c.media_query);
}
return array;
}, []);
}
/**
* Extract time conditions from conditions recursively
*/
export function extractTimeConditions(
conditions: Condition[]
): TimeCondition[] {
return conditions.reduce<TimeCondition[]>((array, c) => {
if ("conditions" in c && c.conditions) {
array.push(...extractTimeConditions(c.conditions));
}
if (c.condition === "time") {
array.push(c);
}
return array;
}, []);
}

View File

@@ -0,0 +1,89 @@
import { listenMediaQuery } from "../dom/media_query";
import type { HomeAssistant } from "../../types";
import type { Condition } from "../../panels/lovelace/common/validate-condition";
import { checkConditionsMet } from "../../panels/lovelace/common/validate-condition";
import { extractMediaQueries, extractTimeConditions } from "./extract";
import { calculateNextTimeUpdate } from "./time-calculator";
/** Maximum delay for setTimeout (2^31 - 1 milliseconds, ~24.8 days)
* Values exceeding this will overflow and execute immediately
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/Window/setTimeout#maximum_delay_value
*/
const MAX_TIMEOUT_DELAY = 2147483647;
/**
* Helper to setup media query listeners for conditional visibility
*/
export function setupMediaQueryListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const mediaQueries = extractMediaQueries(conditions);
if (mediaQueries.length === 0) return;
// Optimization for single media query
const hasOnlyMediaQuery =
conditions.length === 1 &&
conditions[0].condition === "screen" &&
!!conditions[0].media_query;
mediaQueries.forEach((mediaQuery) => {
const unsub = listenMediaQuery(mediaQuery, (matches) => {
if (hasOnlyMediaQuery) {
onUpdate(matches);
} else {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
});
addListener(unsub);
});
}
/**
* Helper to setup time-based listeners for conditional visibility
*/
export function setupTimeListeners(
conditions: Condition[],
hass: HomeAssistant,
addListener: (unsub: () => void) => void,
onUpdate: (conditionsMet: boolean) => void
): void {
const timeConditions = extractTimeConditions(conditions);
if (timeConditions.length === 0) return;
timeConditions.forEach((timeCondition) => {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scheduleUpdate = () => {
const delay = calculateNextTimeUpdate(hass, timeCondition);
if (delay === undefined) return;
// Cap delay to prevent setTimeout overflow
const cappedDelay = Math.min(delay, MAX_TIMEOUT_DELAY);
timeoutId = setTimeout(() => {
if (delay <= MAX_TIMEOUT_DELAY) {
const conditionsMet = checkConditionsMet(conditions, hass);
onUpdate(conditionsMet);
}
scheduleUpdate();
}, cappedDelay);
};
// Register cleanup function once, outside of scheduleUpdate
addListener(() => {
if (timeoutId !== undefined) {
clearTimeout(timeoutId);
}
});
scheduleUpdate();
});
}

View File

@@ -0,0 +1,73 @@
import { TZDate } from "@date-fns/tz";
import {
startOfDay,
addDays,
addMinutes,
differenceInMilliseconds,
} from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { parseTimeString } from "../datetime/check_time";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Calculate milliseconds until next time boundary for a time condition
* @param hass Home Assistant object
* @param timeCondition Time condition to calculate next update for
* @returns Milliseconds until next boundary, or undefined if no boundaries
*/
export function calculateNextTimeUpdate(
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): number | undefined {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
const updates: Date[] = [];
// Calculate next occurrence of after time
if (after) {
let afterDate = parseTimeString(after, timezone);
if (afterDate <= now) {
// If time has passed today, schedule for tomorrow
afterDate = addDays(afterDate, 1);
}
updates.push(afterDate);
}
// Calculate next occurrence of before time
if (before) {
let beforeDate = parseTimeString(before, timezone);
if (beforeDate <= now) {
// If time has passed today, schedule for tomorrow
beforeDate = addDays(beforeDate, 1);
}
updates.push(beforeDate);
}
// If weekdays are specified, check for midnight (weekday transition)
if (weekdays && weekdays.length > 0 && weekdays.length < 7) {
// Calculate next midnight using startOfDay + addDays
const tomorrow = addDays(now, 1);
const midnight = startOfDay(tomorrow);
updates.push(midnight);
}
if (updates.length === 0) {
return undefined;
}
// Find the soonest update time
const nextUpdate = updates.reduce((soonest, current) =>
current < soonest ? current : soonest
);
// Add 1 minute buffer to ensure we're past the boundary
const updateWithBuffer = addMinutes(nextUpdate, 1);
// Calculate difference in milliseconds
return differenceInMilliseconds(updateWithBuffer, now);
}

View File

@@ -0,0 +1,131 @@
import { TZDate } from "@date-fns/tz";
import { isBefore, isAfter, isWithinInterval } from "date-fns";
import type { HomeAssistant } from "../../types";
import { TimeZone } from "../../data/translation";
import { WEEKDAY_MAP } from "./weekday";
import type { TimeCondition } from "../../panels/lovelace/common/validate-condition";
/**
* Validate a time string format and value ranges without creating Date objects
* @param timeString Time string to validate (HH:MM or HH:MM:SS)
* @returns true if valid, false otherwise
*/
export function isValidTimeString(timeString: string): boolean {
// Reject empty strings
if (!timeString || timeString.trim() === "") {
return false;
}
const parts = timeString.split(":");
if (parts.length < 2 || parts.length > 3) {
return false;
}
// Ensure each part contains only digits (and optional leading zeros)
// This prevents "8:00 AM" from passing validation
if (!parts.every((part) => /^\d+$/.test(part))) {
return false;
}
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) {
return false;
}
return (
hours >= 0 &&
hours <= 23 &&
minutes >= 0 &&
minutes <= 59 &&
seconds >= 0 &&
seconds <= 59
);
}
/**
* Parse a time string (HH:MM or HH:MM:SS) and set it on today's date in the given timezone
*
* Note: This function assumes the time string has already been validated by
* isValidTimeString() at configuration time. It does not re-validate at runtime
* for consistency with other condition types (screen, user, location, etc.)
*
* @param timeString The time string to parse (must be pre-validated)
* @param timezone The timezone to use
* @returns The Date object
*/
export const parseTimeString = (timeString: string, timezone: string): Date => {
const parts = timeString.split(":");
const hours = parseInt(parts[0], 10);
const minutes = parseInt(parts[1], 10);
const seconds = parts.length === 3 ? parseInt(parts[2], 10) : 0;
const now = new TZDate(new Date(), timezone);
const dateWithTime = new TZDate(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes,
seconds,
0,
timezone
);
return new Date(dateWithTime.getTime());
};
/**
* Check if the current time matches the time condition (after/before/weekday)
* @param hass Home Assistant object
* @param timeCondition Time condition to check
* @returns true if current time matches the condition
*/
export const checkTimeInRange = (
hass: HomeAssistant,
{ after, before, weekdays }: Omit<TimeCondition, "condition">
): boolean => {
const timezone =
hass.locale.time_zone === TimeZone.server
? hass.config.time_zone
: Intl.DateTimeFormat().resolvedOptions().timeZone;
const now = new TZDate(new Date(), timezone);
// Check weekday condition
if (weekdays && weekdays.length > 0) {
const currentWeekday = WEEKDAY_MAP[now.getDay()];
if (!weekdays.includes(currentWeekday)) {
return false;
}
}
// Check time conditions
if (!after && !before) {
return true;
}
const afterDate = after ? parseTimeString(after, timezone) : undefined;
const beforeDate = before ? parseTimeString(before, timezone) : undefined;
if (afterDate && beforeDate) {
if (isBefore(beforeDate, afterDate)) {
// Crosses midnight (e.g., 22:00 to 06:00)
return !isBefore(now, afterDate) || !isAfter(now, beforeDate);
}
return isWithinInterval(now, { start: afterDate, end: beforeDate });
}
if (afterDate) {
return !isBefore(now, afterDate);
}
if (beforeDate) {
return !isAfter(now, beforeDate);
}
return true;
};

View File

@@ -1,18 +1,7 @@
import { getWeekStartByLocale } from "weekstart"; import { getWeekStartByLocale } from "weekstart";
import type { FrontendLocaleData } from "../../data/translation"; import type { FrontendLocaleData } from "../../data/translation";
import { FirstWeekday } from "../../data/translation"; import { FirstWeekday } from "../../data/translation";
import { WEEKDAYS_LONG, type WeekdayIndex } from "./weekday";
export const weekdays = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const;
type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => { export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
if (locale.first_weekday === FirstWeekday.language) { if (locale.first_weekday === FirstWeekday.language) {
@@ -23,12 +12,12 @@ export const firstWeekdayIndex = (locale: FrontendLocaleData): WeekdayIndex => {
} }
return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex; return (getWeekStartByLocale(locale.language) % 7) as WeekdayIndex;
} }
return weekdays.includes(locale.first_weekday) return WEEKDAYS_LONG.includes(locale.first_weekday)
? (weekdays.indexOf(locale.first_weekday) as WeekdayIndex) ? (WEEKDAYS_LONG.indexOf(locale.first_weekday) as WeekdayIndex)
: 1; : 1;
}; };
export const firstWeekday = (locale: FrontendLocaleData) => { export const firstWeekday = (locale: FrontendLocaleData) => {
const index = firstWeekdayIndex(locale); const index = firstWeekdayIndex(locale);
return weekdays[index]; return WEEKDAYS_LONG[index];
}; };

View File

@@ -0,0 +1,59 @@
export type WeekdayIndex = 0 | 1 | 2 | 3 | 4 | 5 | 6;
export type WeekdayShort =
| "sun"
| "mon"
| "tue"
| "wed"
| "thu"
| "fri"
| "sat";
export type WeekdayLong =
| "sunday"
| "monday"
| "tuesday"
| "wednesday"
| "thursday"
| "friday"
| "saturday";
export const WEEKDAYS_SHORT = [
"sun",
"mon",
"tue",
"wed",
"thu",
"fri",
"sat",
] as const satisfies readonly WeekdayShort[];
export const WEEKDAYS_LONG = [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
] as const satisfies readonly WeekdayLong[];
export const WEEKDAY_MAP = {
0: "sun",
1: "mon",
2: "tue",
3: "wed",
4: "thu",
5: "fri",
6: "sat",
} as const satisfies Record<WeekdayIndex, WeekdayShort>;
export const WEEKDAY_SHORT_TO_LONG = {
sun: "sunday",
mon: "monday",
tue: "tuesday",
wed: "wednesday",
thu: "thursday",
fri: "friday",
sat: "saturday",
} as const satisfies Record<WeekdayShort, WeekdayLong>;

View File

@@ -1,5 +1,6 @@
import type { ThemeVars } from "../../data/ws-themes"; import type { ThemeVars } from "../../data/ws-themes";
import { darkColorVariables } from "../../resources/theme/color"; import { darkColorVariables } from "../../resources/theme/color";
import { darkSemanticVariables } from "../../resources/theme/semantic.globals";
import { derivedStyles } from "../../resources/theme/theme"; import { derivedStyles } from "../../resources/theme/theme";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { import {
@@ -52,7 +53,7 @@ export const applyThemesOnElement = (
if (themeToApply && darkMode) { if (themeToApply && darkMode) {
cacheKey = `${cacheKey}__dark`; cacheKey = `${cacheKey}__dark`;
themeRules = { ...darkColorVariables }; themeRules = { ...darkSemanticVariables, ...darkColorVariables };
} }
if (themeToApply === "default") { if (themeToApply === "default") {

View File

@@ -0,0 +1,67 @@
import { tinykeys } from "tinykeys";
import { canOverrideAlphanumericInput } from "../dom/can-override-input";
/**
* A function to handle a keyboard shortcut.
*/
export type ShortcutHandler = (event: KeyboardEvent) => void;
/**
* Configuration for a keyboard shortcut.
*/
export interface ShortcutConfig {
handler: ShortcutHandler;
/**
* If true, allows shortcuts even when text is selected.
* Default is false to avoid interrupting copy/paste.
*/
allowWhenTextSelected?: boolean;
}
/**
* Register keyboard shortcuts using tinykeys.
* Automatically blocks shortcuts in input fields and during text selection.
*/
function registerShortcuts(
shortcuts: Record<string, ShortcutConfig>
): () => void {
const wrappedShortcuts: Record<string, ShortcutHandler> = {};
Object.entries(shortcuts).forEach(([key, config]) => {
wrappedShortcuts[key] = (event: KeyboardEvent) => {
if (!canOverrideAlphanumericInput(event.composedPath())) {
return;
}
if (!config.allowWhenTextSelected && window.getSelection()?.toString()) {
return;
}
config.handler(event);
};
});
return tinykeys(window, wrappedShortcuts);
}
/**
* Manages keyboard shortcuts registration and cleanup.
*/
export class ShortcutManager {
private _disposer?: () => void;
/**
* Register keyboard shortcuts.
* Uses tinykeys syntax: https://github.com/jamiebuilds/tinykeys#usage
*/
public add(shortcuts: Record<string, ShortcutConfig>) {
this._disposer?.();
this._disposer = registerShortcuts(shortcuts);
}
/**
* Remove all registered shortcuts.
*/
public remove() {
this._disposer?.();
this._disposer = undefined;
}
}

View File

@@ -0,0 +1,36 @@
/**
* Parses a CSS duration string (e.g., "300ms", "3s") and returns the duration in milliseconds.
*
* @param duration - A CSS duration string (e.g., "300ms", "3s", "0.5s")
* @returns The duration in milliseconds, or 0 if the input is invalid
*
* @example
* parseAnimationDuration("300ms") // Returns 300
* parseAnimationDuration("3s") // Returns 3000
* parseAnimationDuration("0.5s") // Returns 500
* parseAnimationDuration("invalid") // Returns 0
*/
export const parseAnimationDuration = (duration: string): number => {
const trimmed = duration.trim();
let value: number;
let multiplier: number;
if (trimmed.endsWith("ms")) {
value = parseFloat(trimmed.slice(0, -2));
multiplier = 1;
} else if (trimmed.endsWith("s")) {
value = parseFloat(trimmed.slice(0, -1));
multiplier = 1000;
} else {
// No recognized unit, try parsing as number (assume ms)
value = parseFloat(trimmed);
multiplier = 1;
}
if (!isFinite(value) || value < 0) {
return 0;
}
return value * multiplier;
};

View File

@@ -119,8 +119,8 @@ type Thresholds = Record<
>; >;
export const DEFAULT_THRESHOLDS: Thresholds = { export const DEFAULT_THRESHOLDS: Thresholds = {
second: 45, // seconds to minute second: 59, // seconds to minute
minute: 45, // minutes to hour minute: 59, // minutes to hour
hour: 22, // hour to day hour: 22, // hour to day
day: 5, // day to week day: 5, // day to week
week: 4, // week to months week: 4, // week to months

View File

@@ -0,0 +1,30 @@
/**
* Executes a callback within a View Transition if supported, otherwise runs it directly.
*
* @param callback - Function to execute. Can be synchronous or return a Promise. The callback will be passed a boolean indicating whether the view transition is available.
* @returns Promise that resolves when the transition completes (or immediately if not supported)
*
* @example
* ```typescript
* // Synchronous callback
* withViewTransition(() => {
* this.large = !this.large;
* });
*
* // Async callback
* await withViewTransition(async () => {
* await this.updateData();
* });
* ```
*/
export const withViewTransition = (
callback: (viewTransitionAvailable: boolean) => void | Promise<void>
): Promise<void> => {
if (document.startViewTransition) {
return document.startViewTransition(() => callback(true)).finished;
}
// Fallback: Execute callback directly without transition
const result = callback(false);
return result instanceof Promise ? result : Promise.resolve();
};

View File

@@ -6,7 +6,8 @@ export function downSampleLineData<
data: T[] | undefined, data: T[] | undefined,
maxDetails: number, maxDetails: number,
minX?: number, minX?: number,
maxX?: number maxX?: number,
useMean = false
): T[] { ): T[] {
if (!data) { if (!data) {
return []; return [];
@@ -17,15 +18,13 @@ export function downSampleLineData<
const min = minX ?? getPointData(data[0]!)[0]; const min = minX ?? getPointData(data[0]!)[0];
const max = maxX ?? getPointData(data[data.length - 1]!)[0]; const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.ceil((max - min) / Math.floor(maxDetails)); const step = Math.ceil((max - min) / Math.floor(maxDetails));
const frames = new Map<
number,
{
min: { point: (typeof data)[number]; x: number; y: number };
max: { point: (typeof data)[number]; x: number; y: number };
}
>();
// Group points into frames // Group points into frames
const frames = new Map<
number,
{ point: (typeof data)[number]; x: number; y: number }[]
>();
for (const point of data) { for (const point of data) {
const pointData = getPointData(point); const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue; if (!Array.isArray(pointData)) continue;
@@ -36,28 +35,53 @@ export function downSampleLineData<
const frameIndex = Math.floor((x - min) / step); const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex); const frame = frames.get(frameIndex);
if (!frame) { if (!frame) {
frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } }); frames.set(frameIndex, [{ point, x, y }]);
} else { } else {
if (frame.min.y > y) { frame.push({ point, x, y });
frame.min = { point, x, y };
}
if (frame.max.y < y) {
frame.max = { point, x, y };
}
} }
} }
// Convert frames back to points // Convert frames back to points
const result: T[] = []; const result: T[] = [];
for (const [_i, frame] of frames) {
// Use min/max points to preserve visual accuracy if (useMean) {
// The order of the data must be preserved so max may be before min // Use mean values for each frame
if (frame.min.x > frame.max.x) { for (const [_i, framePoints] of frames) {
result.push(frame.max.point); const sumY = framePoints.reduce((acc, p) => acc + p.y, 0);
const meanY = sumY / framePoints.length;
const sumX = framePoints.reduce((acc, p) => acc + p.x, 0);
const meanX = sumX / framePoints.length;
const firstPoint = framePoints[0].point;
const pointData = getPointData(firstPoint);
const meanPoint = (
Array.isArray(pointData) ? [meanX, meanY] : { value: [meanX, meanY] }
) as T;
result.push(meanPoint);
} }
result.push(frame.min.point); } else {
if (frame.min.x < frame.max.x) { // Use min/max values for each frame
result.push(frame.max.point); for (const [_i, framePoints] of frames) {
let minPoint = framePoints[0];
let maxPoint = framePoints[0];
for (const p of framePoints) {
if (p.y < minPoint.y) {
minPoint = p;
}
if (p.y > maxPoint.y) {
maxPoint = p;
}
}
// The order of the data must be preserved so max may be before min
if (minPoint.x > maxPoint.x) {
result.push(maxPoint.point);
}
result.push(minPoint.point);
if (minPoint.x < maxPoint.x) {
result.push(maxPoint.point);
}
} }
} }

View File

@@ -427,6 +427,7 @@ export class HaChartBase extends LitElement {
...axis.axisPointer?.handle, ...axis.axisPointer?.handle,
show: true, show: true,
}, },
label: { show: false },
}, },
} }
: axis : axis
@@ -596,10 +597,15 @@ export class HaChartBase extends LitElement {
aria: { show: true }, aria: { show: true },
dataZoom: this._getDataZoomConfig(), dataZoom: this._getDataZoomConfig(),
toolbox: { toolbox: {
top: Infinity, top: Number.MAX_SAFE_INTEGER,
left: Infinity, left: Number.MAX_SAFE_INTEGER,
feature: { feature: {
dataZoom: { show: true, yAxisIndex: false, filterMode: "none" }, dataZoom: {
show: true,
yAxisIndex: false,
filterMode: "none",
showTitle: false,
},
}, },
iconStyle: { opacity: 0 }, iconStyle: { opacity: 0 },
}, },
@@ -627,6 +633,10 @@ export class HaChartBase extends LitElement {
} }
private _createTheme(style: CSSStyleDeclaration) { private _createTheme(style: CSSStyleDeclaration) {
const textBorderColor =
style.getPropertyValue("--ha-card-background") ||
style.getPropertyValue("--card-background-color");
const textBorderWidth = 2;
return { return {
color: getAllGraphColors(style), color: getAllGraphColors(style),
backgroundColor: "transparent", backgroundColor: "transparent",
@@ -650,22 +660,22 @@ export class HaChartBase extends LitElement {
graph: { graph: {
label: { label: {
color: style.getPropertyValue("--primary-text-color"), color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"), textBorderColor,
textBorderWidth: 2, textBorderWidth,
}, },
}, },
pie: { pie: {
label: { label: {
color: style.getPropertyValue("--primary-text-color"), color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"), textBorderColor,
textBorderWidth: 2, textBorderWidth,
}, },
}, },
sankey: { sankey: {
label: { label: {
color: style.getPropertyValue("--primary-text-color"), color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"), textBorderColor,
textBorderWidth: 2, textBorderWidth,
}, },
}, },
categoryAxis: { categoryAxis: {

View File

@@ -2,7 +2,10 @@ import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts"; import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators"; import { customElement, property, state, query } from "lit/decorators";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared"; import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query"; import { listenMediaQuery } from "../../common/dom/media_query";
@@ -16,6 +19,7 @@ import { deepEqual } from "../../common/util/deep-equal";
export interface NetworkNode { export interface NetworkNode {
id: string; id: string;
name?: string; name?: string;
context?: string;
category?: number; category?: number;
value?: number; value?: number;
symbolSize?: number; symbolSize?: number;
@@ -184,10 +188,30 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
layout: physicsEnabled ? "force" : "none", layout: physicsEnabled ? "force" : "none",
draggable: true, draggable: true,
roam: true, roam: true,
roamTrigger: "global",
selectedMode: "single", selectedMode: "single",
label: { label: {
show: showLabels, show: showLabels,
position: "right", position: "right",
formatter: (params: CallbackDataParams) => {
const node = params.data as NetworkNode;
if (node.context) {
return `{primary|${node.name ?? ""}}\n{secondary|${node.context}}`;
}
return node.name ?? "";
},
rich: {
primary: {
fontSize: 12,
},
secondary: {
fontSize: 12,
color: getComputedStyle(document.body).getPropertyValue(
"--secondary-text-color"
),
lineHeight: 16,
},
},
}, },
emphasis: { emphasis: {
focus: isMobile ? "none" : "adjacency", focus: isMobile ? "none" : "adjacency",
@@ -225,6 +249,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
({ ({
id: node.id, id: node.id,
name: node.name, name: node.name,
context: node.context,
category: node.category, category: node.category,
value: node.value, value: node.value,
symbolSize: node.symbolSize || 30, symbolSize: node.symbolSize || 30,

View File

@@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip {
var(--rgb-primary-text-color), var(--rgb-primary-text-color),
0.15 0.15
); );
--_label-text-font: var(--ha-font-family-body);
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
} }
`, `,

View File

@@ -62,6 +62,7 @@ class HaDataTableLabels extends LitElement {
@click=${clickAction ? this._labelClicked : undefined} @click=${clickAction ? this._labelClicked : undefined}
@keydown=${clickAction ? this._labelClicked : undefined} @keydown=${clickAction ? this._labelClicked : undefined}
style=${color ? `--color: ${color}` : ""} style=${color ? `--color: ${color}` : ""}
.description=${label.description}
> >
${label?.icon ${label?.icon
? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>` ? html`<ha-icon slot="icon" .icon=${label.icon}></ha-icon>`

View File

@@ -298,6 +298,18 @@ export class HaDataTable extends LitElement {
} }
if (properties.has("data")) { if (properties.has("data")) {
// Clean up checked rows that no longer exist in the data
if (this._checkedRows.length) {
const validIds = new Set(this.data.map((row) => String(row[this.id])));
const validCheckedRows = this._checkedRows.filter((id) =>
validIds.has(id)
);
if (validCheckedRows.length !== this._checkedRows.length) {
this._checkedRows = validCheckedRows;
this._checkedRowsChanged();
}
}
this._checkableRowsCount = this.data.filter( this._checkableRowsCount = this.data.filter(
(row) => row.selectable !== false (row) => row.selectable !== false
).length; ).length;

View File

@@ -197,9 +197,6 @@ export class HaDevicePicker extends LitElement {
const placeholder = const placeholder =
this.placeholder ?? this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder"); this.hass.localize("ui.components.device-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.device-picker.no_match"
);
const valueRenderer = this._valueRenderer(this._configEntryLookup); const valueRenderer = this._valueRenderer(this._configEntryLookup);
@@ -209,7 +206,10 @@ export class HaDevicePicker extends LitElement {
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.device-picker.no_devices"
)}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
@@ -233,6 +233,11 @@ export class HaDevicePicker extends LitElement {
this.value = value; this.value = value;
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.device-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -269,9 +269,6 @@ export class HaEntityPicker extends LitElement {
const placeholder = const placeholder =
this.placeholder ?? this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder"); this.hass.localize("ui.components.entity.entity-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.entity.entity-picker.no_match"
);
return html` return html`
<ha-generic-picker <ha-generic-picker
@@ -282,7 +279,7 @@ export class HaEntityPicker extends LitElement {
.label=${this.label} .label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.searchLabel=${this.searchLabel} .searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.addButton ? undefined : this.value} .value=${this.addButton ? undefined : this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
@@ -356,6 +353,11 @@ export class HaEntityPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -21,7 +21,6 @@ import "../ha-combo-box-item";
import "../ha-generic-picker"; import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker"; import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button"; import "../ha-icon-button";
import "../ha-input-helper-text";
import type { import type {
PickerComboBoxItem, PickerComboBoxItem,
PickerComboBoxSearchFn, PickerComboBoxSearchFn,
@@ -271,7 +270,6 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ "); .join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`; const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({ output.push({
@@ -279,7 +277,6 @@ export class HaStatisticPicker extends LitElement {
statistic_id: id, statistic_id: id,
primary, primary,
secondary, secondary,
a11y_label: a11yLabel,
stateObj: stateObj, stateObj: stateObj,
type: "entity", type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"), sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
@@ -458,9 +455,6 @@ export class HaStatisticPicker extends LitElement {
const placeholder = const placeholder =
this.placeholder ?? this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder"); this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html` return html`
<ha-generic-picker <ha-generic-picker
@@ -468,7 +462,10 @@ export class HaStatisticPicker extends LitElement {
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity} .allowCustomValue=${this.allowCustomEntity}
.label=${this.label} .label=${this.label}
.notFoundLabel=${notFoundLabel} .notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.statistic-picker.no_statistics"
)}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.rowRenderer=${this._rowRenderer} .rowRenderer=${this._rowRenderer}
@@ -477,6 +474,7 @@ export class HaStatisticPicker extends LitElement {
.hideClearIcon=${this.hideClearIcon} .hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn} .searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer} .valueRenderer=${this._valueRenderer}
.helper=${this.helper}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
> >
</ha-generic-picker> </ha-generic-picker>
@@ -521,6 +519,11 @@ export class HaStatisticPicker extends LitElement {
await this.updateComplete; await this.updateComplete;
await this._picker?.open(); await this._picker?.open();
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.statistic-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -369,9 +369,10 @@ export class HaAreaPicker extends LitElement {
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.helper=${this.helper} .helper=${this.helper}
.notFoundLabel=${this.hass.localize( .notFoundLabel=${this._notFoundLabel}
"ui.components.area-picker.no_match" .emptyLabel=${this.hass.localize("ui.components.area-picker.no_areas")}
)} .disabled=${this.disabled}
.required=${this.required}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
@@ -425,6 +426,11 @@ export class HaAreaPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.area-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -17,6 +17,7 @@ import type { HomeAssistant } from "../types";
import { AudioRecorder } from "../util/audio-recorder"; import { AudioRecorder } from "../util/audio-recorder";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
import "./ha-alert"; import "./ha-alert";
import "./ha-markdown";
import "./ha-textfield"; import "./ha-textfield";
import type { HaTextField } from "./ha-textfield"; import type { HaTextField } from "./ha-textfield";
@@ -40,7 +41,11 @@ export class HaAssistChat extends LitElement {
@query("#message-input") private _messageInput!: HaTextField; @query("#message-input") private _messageInput!: HaTextField;
@query("#scroll-container") private _scrollContainer!: HTMLDivElement; @query(".message:last-child")
private _lastChatMessage!: LitElement;
@query(".message:last-child img:last-of-type")
private _lastChatMessageImage: HTMLImageElement | undefined;
@state() private _conversation: AssistMessage[] = []; @state() private _conversation: AssistMessage[] = [];
@@ -92,10 +97,7 @@ export class HaAssistChat extends LitElement {
public disconnectedCallback() { public disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
this._audioRecorder?.close(); this._audioRecorder?.close();
this._audioRecorder = undefined;
this._unloadAudio(); this._unloadAudio();
this._conversation = [];
this._conversationId = null;
} }
protected render(): TemplateResult { protected render(): TemplateResult {
@@ -112,7 +114,7 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech; const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html` return html`
<div class="messages" id="scroll-container"> <div class="messages">
${controlHA ${controlHA
? nothing ? nothing
: html` : html`
@@ -124,11 +126,18 @@ export class HaAssistChat extends LitElement {
`} `}
<div class="spacer"></div> <div class="spacer"></div>
${this._conversation!.map( ${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html` (message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div> <ha-markdown
` class="message ${classMap({
error: !!message.error,
[message.who]: true,
})}"
breaks
cache
.content=${message.text}
>
</ha-markdown>
`
)} )}
</div> </div>
<div class="input" slot="primaryAction"> <div class="input" slot="primaryAction">
@@ -189,12 +198,28 @@ export class HaAssistChat extends LitElement {
`; `;
} }
private _scrollMessagesBottom() { private async _scrollMessagesBottom() {
const scrollContainer = this._scrollContainer; const lastChatMessage = this._lastChatMessage;
if (!scrollContainer) { if (!lastChatMessage.hasUpdated) {
return; await lastChatMessage.updateComplete;
}
if (
this._lastChatMessageImage &&
!this._lastChatMessageImage.naturalHeight
) {
try {
await this._lastChatMessageImage.decode();
} catch (err: any) {
// eslint-disable-next-line no-console
console.warn("Failed to decode image:", err);
}
}
const isLastMessageFullyVisible =
lastChatMessage.getBoundingClientRect().y <
this.getBoundingClientRect().top + 24;
if (!isLastMessageFullyVisible) {
lastChatMessage.scrollIntoView({ behavior: "smooth", block: "start" });
} }
scrollContainer.scrollTo(0, scrollContainer.scrollHeight);
} }
private _handleKeyUp(ev: KeyboardEvent) { private _handleKeyUp(ev: KeyboardEvent) {
@@ -586,42 +611,31 @@ export class HaAssistChat extends LitElement {
flex: 1; flex: 1;
} }
.message { .message {
white-space: pre-line;
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
clear: both; clear: both;
max-width: -webkit-fill-available;
overflow-wrap: break-word;
scroll-margin-top: 24px;
margin: 8px 0; margin: 8px 0;
padding: 8px; padding: 8px;
border-radius: var(--ha-border-radius-xl); border-radius: var(--ha-border-radius-xl);
} }
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
.message { .message {
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
} }
} }
.message p {
margin: 0;
}
.message p:not(:last-child) {
margin-bottom: 8px;
}
.message.user { .message.user {
margin-left: 24px; margin-left: 24px;
margin-inline-start: 24px; margin-inline-start: 24px;
margin-inline-end: initial; margin-inline-end: initial;
align-self: flex-end; align-self: flex-end;
text-align: right;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
--markdown-link-color: var(--text-primary-color);
background-color: var(--chat-background-color-user, var(--primary-color)); background-color: var(--chat-background-color-user, var(--primary-color));
color: var(--text-primary-color); color: var(--text-primary-color);
direction: var(--direction); direction: var(--direction);
} }
.message.hass { .message.hass {
margin-right: 24px; margin-right: 24px;
margin-inline-end: 24px; margin-inline-end: 24px;
@@ -636,20 +650,21 @@ export class HaAssistChat extends LitElement {
color: var(--primary-text-color); color: var(--primary-text-color);
direction: var(--direction); direction: var(--direction);
} }
.message.user a {
color: var(--text-primary-color);
}
.message.hass a {
color: var(--primary-text-color);
}
.message.error { .message.error {
background-color: var(--error-color); background-color: var(--error-color);
color: var(--text-primary-color); color: var(--text-primary-color);
} }
ha-markdown {
--markdown-image-border-radius: calc(var(--ha-border-radius-xl) / 2);
--markdown-table-border-color: var(--divider-color);
--markdown-code-background-color: var(--primary-background-color);
--markdown-code-text-color: var(--primary-text-color);
&:not(:has(ha-markdown-element)) {
min-height: 1lh;
min-width: 1lh;
flex-shrink: 0;
}
}
.bouncer { .bouncer {
width: 48px; width: 48px;
height: 48px; height: 48px;

View File

@@ -0,0 +1,84 @@
import {
mdiAmpersand,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiGateOr,
mdiIdentifier,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
export const CONDITION_ICONS = {
device: mdiDevices,
and: mdiAmpersand,
or: mdiGateOr,
not: mdiNotEqualVariant,
state: mdiStateMachine,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
template: mdiCodeBraces,
time: mdiClockOutline,
trigger: mdiIdentifier,
zone: mdiMapMarkerRadius,
};
@customElement("ha-condition-icon")
export class HaConditionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.condition) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = conditionIcon(this.hass, this.condition).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
const domain = computeDomain(this.condition!);
return html`
<ha-svg-icon
.path=${CONDITION_ICONS[this.condition!] ||
FALLBACK_DOMAIN_ICONS[domain]}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-condition-icon": HaConditionIcon;
}
}

View File

@@ -94,6 +94,12 @@ export class HaDateInput extends LitElement {
} }
private _keyDown(ev: KeyboardEvent) { private _keyDown(ev: KeyboardEvent) {
if (["Space", "Enter"].includes(ev.code)) {
ev.preventDefault();
ev.stopPropagation();
this._openDialog();
return;
}
if (!this.canClear) { if (!this.canClear) {
return; return;
} }

View File

@@ -75,11 +75,15 @@ export class HaDialogHeader extends LitElement {
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
color: var(--ha-dialog-header-title-color, var(--primary-text-color));
} }
.header-subtitle { .header-subtitle {
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal); line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color); color: var(
--ha-dialog-header-subtitle-color,
var(--secondary-text-color)
);
} }
@media all and (min-width: 450px) and (min-height: 500px) { @media all and (min-width: 450px) and (min-height: 500px) {
.header-bar { .header-bar {

View File

@@ -90,7 +90,8 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__actions { .mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end); justify-content: var(--justify-action-buttons, flex-end);
padding: 12px 16px 16px 16px; padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
} }
.mdc-dialog__actions span:nth-child(1) { .mdc-dialog__actions span:nth-child(1) {
flex: var(--secondary-action-button-flex, unset); flex: var(--secondary-action-button-flex, unset);
@@ -100,22 +101,24 @@ export class HaDialog extends DialogBase {
} }
.mdc-dialog__container { .mdc-dialog__container {
align-items: var(--vertical-align-dialog, center); align-items: var(--vertical-align-dialog, center);
padding: var(--dialog-container-padding, var(--ha-space-0));
} }
.mdc-dialog__title { .mdc-dialog__title {
padding: 16px 16px 0 16px; padding: var(--ha-space-4) var(--ha-space-4) var(--ha-space-0)
var(--ha-space-4);
} }
.mdc-dialog__title:has(span) { .mdc-dialog__title:has(span) {
padding: 12px 12px 0; padding: var(--ha-space-3) var(--ha-space-3) var(--ha-space-0);
} }
.mdc-dialog__title::before { .mdc-dialog__title::before {
content: unset; content: unset;
} }
.mdc-dialog .mdc-dialog__content { .mdc-dialog .mdc-dialog__content {
position: var(--dialog-content-position, relative); position: var(--dialog-content-position, relative);
padding: var(--dialog-content-padding, 24px); padding: var(--dialog-content-padding, var(--ha-space-6));
} }
:host([hideactions]) .mdc-dialog .mdc-dialog__content { :host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: var(--dialog-content-padding, 24px); padding-bottom: var(--dialog-content-padding, var(--ha-space-6));
} }
.mdc-dialog .mdc-dialog__surface { .mdc-dialog .mdc-dialog__surface {
position: var(--dialog-surface-position, relative); position: var(--dialog-surface-position, relative);
@@ -133,7 +136,7 @@ export class HaDialog extends DialogBase {
--ha-dialog-surface-background, --ha-dialog-surface-background,
var(--mdc-theme-surface, #fff) var(--mdc-theme-surface, #fff)
); );
padding: var(--dialog-surface-padding); padding: var(--dialog-surface-padding, var(--ha-space-0));
} }
:host([flexContent]) .mdc-dialog .mdc-dialog__content { :host([flexContent]) .mdc-dialog .mdc-dialog__content {
display: flex; display: flex;
@@ -150,22 +153,22 @@ export class HaDialog extends DialogBase {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
display: block; display: block;
padding-left: 4px; padding-left: var(--ha-space-1);
padding-right: 4px; padding-right: var(--ha-space-1);
margin-right: 12px; margin-right: var(--ha-space-3);
margin-inline-end: 12px; margin-inline-end: var(--ha-space-3);
margin-inline-start: initial; margin-inline-start: initial;
} }
.header_button { .header_button {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
inset-inline-start: initial; inset-inline-start: initial;
inset-inline-end: -12px; inset-inline-end: calc(var(--ha-space-3) * -1);
direction: var(--direction); direction: var(--direction);
} }
.dialog-actions { .dialog-actions {
inset-inline-start: initial !important; inset-inline-start: initial !important;
inset-inline-end: 0px !important; inset-inline-end: var(--ha-space-0) !important;
direction: var(--direction); direction: var(--direction);
} }
`, `,

View File

@@ -0,0 +1,41 @@
import DropdownItem from "@home-assistant/webawesome/dist/components/dropdown-item/dropdown-item";
import { css, type CSSResultGroup } from "lit";
import { customElement } from "lit/decorators";
/**
* Home Assistant dropdown item component
*
* @element ha-dropdown-item
* @extends {DropdownItem}
*
* @summary
* A stylable dropdown item component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown item.
*
*/
@customElement("ha-dropdown-item")
export class HaDropdownItem extends DropdownItem {
static get styles(): CSSResultGroup {
return [
DropdownItem.styles,
css`
:host {
min-height: var(--ha-space-10);
}
#icon ::slotted(*) {
color: var(--ha-color-on-neutral-normal);
}
:host([variant="danger"]) #icon ::slotted(*) {
color: var(--ha-color-on-danger-quiet);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown-item": HaDropdownItem;
}
}

View File

@@ -0,0 +1,45 @@
import Dropdown from "@home-assistant/webawesome/dist/components/dropdown/dropdown";
import { css, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
/**
* Home Assistant dropdown component
*
* @element ha-dropdown
* @extends {Dropdown}
*
* @summary
* A stylable dropdown component supporting Home Assistant theming, variants, and appearances based on webawesome dropdown.
*
*/
@customElement("ha-dropdown")
export class HaDropdown extends Dropdown {
@property({ attribute: false }) dropdownTag = "ha-dropdown";
@property({ attribute: false }) dropdownItemTag = "ha-dropdown-item";
static get styles(): CSSResultGroup {
return [
Dropdown.styles,
css`
:host {
font-size: var(--ha-dropdown-font-size, var(--ha-font-size-m));
--wa-color-surface-raised: var(
--card-background-color,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
}
#menu {
padding: var(--ha-space-1);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dropdown": HaDropdown;
}
}

View File

@@ -209,6 +209,7 @@ export class HaExpansionPanel extends LitElement {
::slotted([slot="header"]) { ::slotted([slot="header"]) {
flex: 1; flex: 1;
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: var(--primary-text-color);
} }
.container { .container {

View File

@@ -109,7 +109,10 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
.selected=${(this.value || []).includes(label.label_id)} .selected=${(this.value || []).includes(label.label_id)}
hasMeta hasMeta
> >
<ha-label style=${color ? `--color: ${color}` : ""}> <ha-label
style=${color ? `--color: ${color}` : ""}
.description=${label.description}
>
${label.icon ${label.icon
? html`<ha-icon ? html`<ha-icon
slot="icon" slot="icon"

View File

@@ -383,8 +383,9 @@ export class HaFloorPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this.hass.localize( .notFoundLabel=${this._notFoundLabel}
"ui.components.floor-picker.no_match" .emptyLabel=${this.hass.localize(
"ui.components.floor-picker.no_floors"
)} )}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
@@ -444,6 +445,11 @@ export class HaFloorPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.floor-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -25,9 +25,6 @@ import "./ha-svg-icon";
export class HaGenericPicker extends LitElement { export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@@ -49,8 +46,11 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean }) @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false; public hideClearIcon = false;
@property({ attribute: false, type: Array }) @property({ attribute: false })
public getItems?: () => PickerComboBoxItem[]; public getItems?: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -64,8 +64,11 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String }) @property({ attribute: false })
public notFoundLabel?: string; public notFoundLabel?: string | ((search: string) => string);
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: "popover-placement" }) @property({ attribute: "popover-placement" })
public popoverPlacement: public popoverPlacement:
@@ -85,6 +88,25 @@ export class HaGenericPicker extends LitElement {
/** If set picker shows an add button instead of textbox when value isn't set */ /** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string; @property({ attribute: "add-button-label" }) public addButtonLabel?: string;
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
id: string;
label: string;
}
| "separator"
)[];
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => string | undefined;
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query(".container") private _containerElement?: HTMLDivElement; @query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox; @query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -97,6 +119,11 @@ export class HaGenericPicker extends LitElement {
@state() private _openedNarrow = false; @state() private _openedNarrow = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
private _narrow = false; private _narrow = false;
// helper to set new value after closing picker, to avoid flicker // helper to set new value after closing picker, to avoid flicker
@@ -189,16 +216,19 @@ export class HaGenericPicker extends LitElement {
<ha-picker-combo-box <ha-picker-combo-box
.hass=${this.hass} .hass=${this.hass}
.allowCustomValue=${this.allowCustomValue} .allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ?? .label=${this.searchLabel}
(this.hass?.localize("ui.common.search") || "Search")}
.value=${this.value} .value=${this.value}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer} .rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel} .notFoundLabel=${this.notFoundLabel}
.emptyLabel=${this.emptyLabel}
.getItems=${this.getItems} .getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems} .getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn} .searchFn=${this.searchFn}
.mode=${dialogMode ? "dialog" : "popover"} .mode=${dialogMode ? "dialog" : "popover"}
.sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection}
></ha-picker-combo-box> ></ha-picker-combo-box>
`; `;
} }

View File

@@ -60,6 +60,10 @@ class HaHLSPlayer extends LitElement {
private static streamCount = 0; private static streamCount = 0;
private _handleVisibilityChange = () => { private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) { if (document.hidden) {
this._cleanUp(); this._cleanUp();
} else { } else {

View File

@@ -66,7 +66,7 @@ export class HaIconOverflowMenu extends LitElement {
.path=${item.path} .path=${item.path}
></ha-svg-icon> ></ha-svg-icon>
${item.label} ${item.label}
</ha-md-menu-item> ` </ha-md-menu-item>`
)} )}
</ha-md-button-menu>` </ha-md-button-menu>`
: html` : html`
@@ -103,6 +103,7 @@ export class HaIconOverflowMenu extends LitElement {
:host { :host {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
cursor: initial;
} }
div[role="separator"] { div[role="separator"] {
border-right: 1px solid var(--divider-color); border-right: 1px solid var(--divider-color);

View File

@@ -27,6 +27,7 @@ export interface DisplayItem {
label: string; label: string;
description?: string; description?: string;
disableSorting?: boolean; disableSorting?: boolean;
disableHiding?: boolean;
} }
export interface DisplayValue { export interface DisplayValue {
@@ -101,6 +102,7 @@ export class HaItemDisplayEditor extends LitElement {
icon, icon,
iconPath, iconPath,
disableSorting, disableSorting,
disableHiding,
} = item; } = item;
return html` return html`
<ha-md-list-item <ha-md-list-item
@@ -155,18 +157,21 @@ export class HaItemDisplayEditor extends LitElement {
</div> </div>
` `
: nothing} : nothing}
<ha-icon-button ${!isVisible || !disableHiding
.path=${isVisible ? mdiEye : mdiEyeOff} ? html`<ha-icon-button
slot="end" .path=${isVisible ? mdiEye : mdiEyeOff}
.label=${this.hass.localize( slot="end"
`ui.components.items-display-editor.${isVisible ? "hide" : "show"}`, .label=${this.hass.localize(
{ `ui.components.items-display-editor.${isVisible ? "hide" : "show"}`,
label: label, {
} label: label,
)} }
.value=${value} )}
@click=${this._toggle} .value=${value}
></ha-icon-button> @click=${this._toggle}
.disabled=${disableHiding || false}
></ha-icon-button>`
: nothing}
${isVisible && !disableSorting ${isVisible && !disableSorting
? html` ? html`
<ha-svg-icon <ha-svg-icon

View File

@@ -224,8 +224,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this.hass.localize( .notFoundLabel=${this._notFoundLabel}
"ui.components.label-picker.no_match" .emptyLabel=${this.hass.localize(
"ui.components.label-picker.no_labels"
)} )}
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")} .addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
.placeholder=${placeholder} .placeholder=${placeholder}
@@ -288,6 +289,11 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
fireEvent(this, "change"); fireEvent(this, "change");
}, 0); }, 0);
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.label-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -1,17 +1,32 @@
import type { CSSResultGroup, TemplateResult } from "lit"; import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { uid } from "../common/util/uid";
import "./ha-tooltip";
@customElement("ha-label") @customElement("ha-label")
class HaLabel extends LitElement { class HaLabel extends LitElement {
@property({ type: Boolean, reflect: true }) dense = false; @property({ type: Boolean, reflect: true }) dense = false;
@property({ attribute: "description" })
public description?: string;
private _elementId = "label-" + uid();
protected render(): TemplateResult { protected render(): TemplateResult {
return html` return html`
<span class="content"> <ha-tooltip
<slot name="icon"></slot> .for=${this._elementId}
<slot></slot> .disabled=${!this.description?.trim()}
</span> >
${this.description}
</ha-tooltip>
<div class="container" .id=${this._elementId}>
<span class="content">
<slot name="icon"></slot>
<slot></slot>
</span>
</div>
`; `;
} }
@@ -36,9 +51,7 @@ class HaLabel extends LitElement {
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);
letter-spacing: 0.1px; letter-spacing: 0.1px;
vertical-align: middle;
height: 32px; height: 32px;
padding: 0 16px;
border-radius: var(--ha-border-radius-xl); border-radius: var(--ha-border-radius-xl);
color: var(--ha-label-text-color); color: var(--ha-label-text-color);
--mdc-icon-size: 12px; --mdc-icon-size: 12px;
@@ -66,15 +79,24 @@ class HaLabel extends LitElement {
display: flex; display: flex;
} }
.container {
display: flex;
position: relative;
height: 100%;
padding: 0 16px;
}
span { span {
display: inline-flex; display: inline-flex;
} }
:host([dense]) { :host([dense]) {
height: 20px; height: 20px;
padding: 0 12px;
border-radius: var(--ha-border-radius-md); border-radius: var(--ha-border-radius-md);
} }
:host([dense]) .container {
padding: 0 12px;
}
:host([dense]) ::slotted([slot="icon"]) { :host([dense]) ::slotted([slot="icon"]) {
margin-right: 4px; margin-right: 4px;
margin-left: -4px; margin-left: -4px;

View File

@@ -21,6 +21,7 @@ import "./chips/ha-input-chip";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-label-picker"; import "./ha-label-picker";
import type { HaLabelPicker } from "./ha-label-picker"; import type { HaLabelPicker } from "./ha-label-picker";
import "./ha-tooltip";
@customElement("ha-labels-picker") @customElement("ha-labels-picker")
export class HaLabelsPicker extends SubscribeMixin(LitElement) { export class HaLabelsPicker extends SubscribeMixin(LitElement) {
@@ -142,9 +143,17 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
const color = label?.color const color = label?.color
? computeCssColor(label.color) ? computeCssColor(label.color)
: undefined; : undefined;
const elementId = "label-" + label.label_id;
return html` return html`
<ha-tooltip
.for=${elementId}
.disabled=${!label?.description?.trim()}
>
${label?.description}
</ha-tooltip>
<ha-input-chip <ha-input-chip
.item=${label} .item=${label}
.id=${elementId}
@remove=${this._removeItem} @remove=${this._removeItem}
@click=${this._openDetail} @click=${this._openDetail}
.disabled=${this.disabled} .disabled=${this.disabled}

View File

@@ -125,9 +125,10 @@ export class HaLanguagePicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
popover-placement="bottom-end" popover-placement="bottom-end"
.notFoundLabel=${this.hass?.localize( .notFoundLabel=${this._notFoundLabel}
"ui.components.language-picker.no_match" .emptyLabel=${this.hass?.localize(
)} "ui.components.language-picker.no_languages"
) || "No languages available"}
.placeholder=${this.label ?? .placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") || (this.hass?.localize("ui.components.language-picker.language") ||
"Language")} "Language")}
@@ -172,6 +173,15 @@ export class HaLanguagePicker extends LitElement {
this.value = ev.detail.value; this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.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.language-picker.no_match", {
term,
})
: html`No languages found for ${term}`;
};
} }
declare global { declare global {

View File

@@ -1,11 +1,15 @@
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit"; import { ReactiveElement, render, html } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
// eslint-disable-next-line import/extensions
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import hash from "object-hash"; import hash from "object-hash";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { renderMarkdown } from "../resources/render-markdown"; import { renderMarkdown } from "../resources/render-markdown";
import { CacheManager } from "../util/cache-manager"; import { CacheManager } from "../util/cache-manager";
const h = (template: ReturnType<typeof unsafeHTML>) => html`${template}`;
const markdownCache = new CacheManager<string>(1000); const markdownCache = new CacheManager<string>(1000);
const _gitHubMarkdownAlerts = { const _gitHubMarkdownAlerts = {
@@ -48,18 +52,26 @@ class HaMarkdownElement extends ReactiveElement {
return this; return this;
} }
private _renderPromise: ReturnType<typeof this._render> = Promise.resolve();
protected update(changedProps) { protected update(changedProps) {
super.update(changedProps); super.update(changedProps);
if (this.content !== undefined) { if (this.content !== undefined) {
this._render(); this._renderPromise = this._render();
} }
} }
protected async getUpdateComplete(): Promise<boolean> {
await super.getUpdateComplete();
await this._renderPromise;
return true;
}
protected willUpdate(_changedProperties: PropertyValues): void { protected willUpdate(_changedProperties: PropertyValues): void {
if (!this.innerHTML && this.cache) { if (!this.innerHTML && this.cache) {
const key = this._computeCacheKey(); const key = this._computeCacheKey();
if (markdownCache.has(key)) { if (markdownCache.has(key)) {
this.innerHTML = markdownCache.get(key)!; render(markdownCache.get(key)!, this.renderRoot);
this._resize(); this._resize();
} }
} }
@@ -75,7 +87,7 @@ class HaMarkdownElement extends ReactiveElement {
} }
private async _render() { private async _render() {
this.innerHTML = await renderMarkdown( const elements = await renderMarkdown(
String(this.content), String(this.content),
{ {
breaks: this.breaks, breaks: this.breaks,
@@ -87,6 +99,11 @@ class HaMarkdownElement extends ReactiveElement {
} }
); );
render(
elements.map((e) => h(unsafeHTML(e))),
this.renderRoot
);
this._resize(); this._resize();
const walker = document.createTreeWalker( const walker = document.createTreeWalker(

View File

@@ -1,5 +1,12 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit"; import {
import { customElement, property } from "lit/decorators"; css,
html,
LitElement,
nothing,
type ReactiveElement,
type CSSResultGroup,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import "./ha-markdown-element"; import "./ha-markdown-element";
@customElement("ha-markdown") @customElement("ha-markdown")
@@ -18,6 +25,14 @@ export class HaMarkdown extends LitElement {
@property({ type: Boolean }) public cache = false; @property({ type: Boolean }) public cache = false;
@query("ha-markdown-element") private _markdownElement!: ReactiveElement;
protected async getUpdateComplete() {
const result = await super.getUpdateComplete();
await this._markdownElement.updateComplete;
return result;
}
protected render() { protected render() {
if (!this.content) { if (!this.content) {
return nothing; return nothing;
@@ -53,19 +68,46 @@ export class HaMarkdown extends LitElement {
margin: var(--ha-space-1) 0; margin: var(--ha-space-1) 0;
} }
a { a {
color: var(--primary-color); color: var(--markdown-link-color, var(--primary-color));
} }
img { img {
background-color: rgba(10, 10, 10, 0.15);
border-radius: var(--markdown-image-border-radius);
max-width: 100%; max-width: 100%;
min-height: 2lh;
height: auto;
width: auto;
text-indent: 4px;
transition: height 0.2s ease-in-out;
}
p:first-child > img:first-child {
vertical-align: top;
}
p:first-child > img:last-child {
vertical-align: top;
}
ol,
ul {
list-style-position: inside;
padding-inline-start: 0;
}
li {
&:has(input[type="checkbox"]) {
list-style: none;
& > input[type="checkbox"] {
margin-left: 0;
}
}
}
svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
} }
code, code,
pre { pre {
background-color: var(--markdown-code-background-color, none); background-color: var(--markdown-code-background-color, none);
border-radius: var(--ha-border-radius-sm); border-radius: var(--ha-border-radius-sm);
} color: var(--markdown-code-text-color, inherit);
svg {
background-color: var(--markdown-svg-background-color, none);
color: var(--markdown-svg-color, none);
} }
code { code {
font-size: var(--ha-font-size-s); font-size: var(--ha-font-size-s);
@@ -97,6 +139,24 @@ export class HaMarkdown extends LitElement {
border-bottom: none; border-bottom: none;
margin: var(--ha-space-4) 0; margin: var(--ha-space-4) 0;
} }
table {
border-collapse: collapse;
display: block;
overflow-x: auto;
}
th {
text-align: start;
}
td,
th {
border: 1px solid var(--markdown-table-border-color, transparent);
padding: 0.25em 0.5em;
}
blockquote {
border-left: 4px solid var(--divider-color);
margin-inline: 0;
padding-inline: 1em;
}
` as CSSResultGroup; ` as CSSResultGroup;
} }

View File

@@ -175,10 +175,10 @@ export class HaMdDialog extends Dialog {
} }
.container { .container {
padding-top: var(--safe-area-inset-top); margin-top: var(--safe-area-inset-top, var(--ha-space-0));
padding-bottom: var(--safe-area-inset-bottom); margin-bottom: var(--safe-area-inset-bottom, var(--ha-space-0));
padding-left: var(--safe-area-inset-left); margin-left: var(--safe-area-inset-left, var(--ha-space-0));
padding-right: var(--safe-area-inset-right); margin-right: var(--safe-area-inset-right, var(--ha-space-0));
} }
} }
@@ -187,7 +187,7 @@ export class HaMdDialog extends Dialog {
} }
slot[name="actions"]::slotted(*) { slot[name="actions"]::slotted(*) {
padding: 16px; padding: var(--ha-space-4);
} }
.scroller { .scroller {
@@ -195,7 +195,7 @@ export class HaMdDialog extends Dialog {
} }
slot[name="content"]::slotted(*) { slot[name="content"]::slotted(*) {
padding: var(--dialog-content-padding, 24px); padding: var(--dialog-content-padding, var(--ha-space-6));
} }
.scrim { .scrim {
z-index: 10; /* overlay navigation */ z-index: 10; /* overlay navigation */

View File

@@ -1,7 +1,8 @@
import { ListItemEl } from "@material/web/list/internal/listitem/list-item"; import { ListItemEl } from "@material/web/list/internal/listitem/list-item";
import { styles } from "@material/web/list/internal/listitem/list-item-styles"; import { styles } from "@material/web/list/internal/listitem/list-item-styles";
import { css } from "lit"; import { css, html, nothing, type TemplateResult } from "lit";
import { customElement } from "lit/decorators"; import { customElement } from "lit/decorators";
import "./ha-ripple";
export const haMdListStyles = [ export const haMdListStyles = [
styles, styles,
@@ -25,6 +26,18 @@ export const haMdListStyles = [
@customElement("ha-md-list-item") @customElement("ha-md-list-item")
export class HaMdListItem extends ListItemEl { export class HaMdListItem extends ListItemEl {
static override styles = haMdListStyles; static override styles = haMdListStyles;
protected renderRipple(): TemplateResult | typeof nothing {
if (this.type === "text") {
return nothing;
}
return html`<ha-ripple
part="ripple"
for="item"
?disabled=${this.disabled && this.type !== "link"}
></ha-ripple>`;
}
} }
declare global { declare global {

View File

@@ -36,6 +36,11 @@ export class HaMdMenuItem extends MenuItemEl {
::slotted([slot="headline"]) { ::slotted([slot="headline"]) {
text-wrap: nowrap; text-wrap: nowrap;
} }
:host([disabled]) {
opacity: 1;
--md-menu-item-label-text-color: var(--disabled-text-color);
--md-menu-item-leading-icon-color: var(--disabled-text-color);
}
`, `,
]; ];
} }

View File

@@ -6,6 +6,7 @@ import { fireEvent } from "../common/dom/fire_event";
import { titleCase } from "../common/string/title-case"; import { titleCase } from "../common/string/title-case";
import { fetchConfig } from "../data/lovelace/config/types"; import { fetchConfig } from "../data/lovelace/config/types";
import type { LovelaceViewRawConfig } from "../data/lovelace/config/view"; import type { LovelaceViewRawConfig } from "../data/lovelace/config/view";
import { getPanelIcon, getPanelTitle } from "../data/panel";
import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types"; import type { HomeAssistant, PanelInfo, ValueChangedEvent } from "../types";
import "./ha-combo-box"; import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box"; import type { HaComboBox } from "./ha-combo-box";
@@ -42,13 +43,8 @@ const createViewNavigationItem = (
const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({ const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`, path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard", icon: getPanelIcon(panel) || "mdi:view-dashboard",
title: title: getPanelTitle(hass, panel) || "",
panel.url_path === hass.defaultPanel
? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) ||
panel.title ||
(panel.url_path ? titleCase(panel.url_path) : ""),
}); });
@customElement("ha-navigation-picker") @customElement("ha-navigation-picker")

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer"; import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify } from "@mdi/js"; import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import {
@@ -14,11 +14,12 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys"; import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaFuse } from "../resources/fuse"; import { HaFuse } from "../resources/fuse";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer"; import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-icon"; import "./ha-icon";
import "./ha-textfield"; import "./ha-textfield";
@@ -27,28 +28,18 @@ import type { HaTextField } from "./ha-textfield";
export interface PickerComboBoxItem { export interface PickerComboBoxItem {
id: string; id: string;
primary: string; primary: string;
a11y_label?: string;
secondary?: string; secondary?: string;
search_labels?: string[]; search_labels?: string[];
sorting_label?: string; sorting_label?: string;
icon_path?: string; icon_path?: string;
icon?: string; icon?: string;
} }
const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
// Hack to force empty label to always display empty value by default in the search field
export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
a11y_label: string;
}
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = ( const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item item
) => html` ) => html`
<ha-combo-box-item <ha-combo-box-item type="button" compact>
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
compact
>
${item.icon ${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>` ? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path : item.icon_path
@@ -87,8 +78,11 @@ export class HaPickerComboBox extends LitElement {
@state() private _listScrolled = false; @state() private _listScrolled = false;
@property({ attribute: false, type: Array }) @property({ attribute: false })
public getItems?: () => PickerComboBoxItem[]; public getItems?: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[]; public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -96,21 +90,45 @@ export class HaPickerComboBox extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>; public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String }) @property({ attribute: false })
public notFoundLabel?: string; public notFoundLabel?: string | ((search: string) => string);
@property({ attribute: "empty-label" })
public emptyLabel?: string;
@property({ attribute: false }) @property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>; public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover"; @property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
/** Section filter buttons for the list, section headers needs to be defined in getItems as strings */
@property({ attribute: false }) public sections?: (
| {
id: string;
label: string;
}
| "separator"
)[];
@property({ attribute: false }) public sectionTitleFunction?: (listInfo: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => string | undefined;
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer; @query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@query("ha-textfield") private _searchFieldElement?: HaTextField; @query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: PickerComboBoxItemWithLabel[] = []; @state() private _items: (PickerComboBoxItem | string)[] = [];
private _allItems: PickerComboBoxItemWithLabel[] = []; @state() private _sectionTitle?: string;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1; private _selectedItemIndex = -1;
@@ -121,6 +139,8 @@ export class HaPickerComboBox extends LitElement {
private _removeKeyboardShortcuts?: () => void; private _removeKeyboardShortcuts?: () => void;
private _search = "";
protected firstUpdated() { protected firstUpdated() {
this._registerKeyboardShortcuts(); this._registerKeyboardShortcuts();
} }
@@ -145,74 +165,142 @@ export class HaPickerComboBox extends LitElement {
"Search"} "Search"}
@input=${this._filterChanged} @input=${this._filterChanged}
></ha-textfield> ></ha-textfield>
${this._renderSectionButtons()}
${this.sections?.length
? html`
<div class="section-title-wrapper">
<div
class="section-title ${!this.selectedSection &&
this._sectionTitle
? "show"
: ""}"
>
${this._sectionTitle}
</div>
</div>
`
: nothing}
<lit-virtualizer <lit-virtualizer
@scroll=${this._onScrollList} .keyFunction=${this._keyFunction}
tabindex="0" tabindex="0"
scroller scroller
.items=${this._items} .items=${this._items}
.renderItem=${this._renderItem} .renderItem=${this._renderItem}
style="min-height: 36px;" style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""} class=${this._listScrolled ? "scrolled" : ""}
@scroll=${this._onScrollList}
@focus=${this._focusList} @focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
> >
</lit-virtualizer> `; </lit-virtualizer> `;
} }
private _defaultNotFoundItem = memoizeOne( private _renderSectionButtons() {
( if (!this.sections || this.sections.length === 0) {
label: this["notFoundLabel"], return nothing;
localize?: LocalizeFunc }
): PickerComboBoxItemWithLabel => ({
id: NO_MATCHING_ITEMS_FOUND_ID,
primary:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
icon_path: mdiMagnify,
a11y_label:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
})
);
private _getAdditionalItems = (searchString?: string) => { return html`
const items = this.getAdditionalItems?.(searchString) || []; <ha-chip-set class="sections">
${this.sections.map((section) =>
section === "separator"
? html`<div class="separator"></div>`
: html`<ha-filter-chip
@click=${this._toggleSection}
.section-id=${section.id}
.selected=${this.selectedSection === section.id}
.label=${section.label}
>
</ha-filter-chip>`
)}
</ha-chip-set>
`;
}
return items.map<PickerComboBoxItemWithLabel>((item) => ({ @eventOptions({ passive: true })
...item, private _visibilityChanged(ev) {
a11y_label: item.a11y_label || item.primary, if (
})); this._virtualizerElement &&
}; this.sectionTitleFunction &&
this.sections?.length
) {
const firstItem = this._virtualizerElement.items[ev.first];
const secondItem = this._virtualizerElement.items[ev.first + 1];
this._sectionTitle = this.sectionTitleFunction({
firstIndex: ev.first,
lastIndex: ev.last,
firstItem: firstItem as PickerComboBoxItem | string,
secondItem: secondItem as PickerComboBoxItem | string,
itemsCount: this._virtualizerElement.items.length,
});
}
}
private _getItems = (): PickerComboBoxItemWithLabel[] => { private _getAdditionalItems = (searchString?: string) =>
const items = this.getItems ? this.getItems() : []; this.getAdditionalItems?.(searchString) || [];
const sortedItems = items private _getItems = () => {
.map<PickerComboBoxItemWithLabel>((item) => ({ let items = [
...item, ...(this.getItems
a11y_label: item.a11y_label || item.primary, ? this.getItems(this._search, this.selectedSection)
})) : []),
.sort((entityA, entityB) => ];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) =>
caseInsensitiveStringCompare( caseInsensitiveStringCompare(
entityA.sorting_label!, (entityA as PickerComboBoxItem).sorting_label!,
entityB.sorting_label!, (entityB as PickerComboBoxItem).sorting_label!,
this.hass?.locale.language ?? navigator.language this.hass?.locale.language ?? navigator.language
) )
); );
}
if (!sortedItems.length) { if (!items.length) {
sortedItems.push( items.push(NO_ITEMS_AVAILABLE_ID);
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
);
} }
const additionalItems = this._getAdditionalItems(); const additionalItems = this._getAdditionalItems();
sortedItems.push(...additionalItems); items.push(...additionalItems);
return sortedItems;
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
}
return items;
}; };
private _renderItem = (item: PickerComboBoxItem, index: number) => { private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
if (item === "padding") {
return html`<div class="bottom-padding"></div>`;
}
if (item === NO_ITEMS_AVAILABLE_ID) {
return html`
<div class="combo-box-row">
<ha-combo-box-item type="text" compact>
<ha-svg-icon
slot="start"
.path=${this._search ? mdiMagnify : mdiMinusBoxOutline}
></ha-svg-icon>
<span slot="headline"
>${this._search
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(this._search)
: this.notFoundLabel ||
this.hass?.localize("ui.components.combo-box.no_match") ||
"No matching items found"
: this.emptyLabel ||
this.hass?.localize("ui.components.combo-box.no_items") ||
"No items available"}</span
>
</ha-combo-box-item>
</div>
`;
}
if (typeof item === "string") {
return html`<div class="title">${item}</div>`;
}
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER; const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
return html`<div return html`<div
id=${`list-item-${index}`} id=${`list-item-${index}`}
@@ -221,9 +309,7 @@ export class HaPickerComboBox extends LitElement {
.index=${index} .index=${index}
@click=${this._valueSelected} @click=${this._valueSelected}
> >
${item.id === NO_MATCHING_ITEMS_FOUND_ID ${renderer(item, index)}
? DEFAULT_ROW_RENDERER(item, index)
: renderer(item, index)}
</div>`; </div>`;
}; };
@@ -242,10 +328,6 @@ export class HaPickerComboBox extends LitElement {
const value = (ev.currentTarget as any).value as string; const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim(); const newValue = value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
fireEvent(this, "value-changed", { value: newValue }); fireEvent(this, "value-changed", { value: newValue });
}; };
@@ -256,51 +338,83 @@ export class HaPickerComboBox extends LitElement {
private _filterChanged = (ev: Event) => { private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField; const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim(); const searchString = textfield.value.trim();
this._search = searchString;
if (!searchString) { if (this.sections?.length) {
this._items = this._allItems; this._items = this._getItems();
return; } else {
} if (!searchString) {
this._items = this._allItems;
return;
}
const index = this._fuseIndex(this._allItems); const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
const fuse = new HaFuse( const fuse = new HaFuse(
this._allItems, this._allItems as PickerComboBoxItem[],
{ {
shouldSort: false, shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2), minMatchCharLength: Math.min(searchString.length, 2),
}, },
index index
); );
const results = fuse.multiTermsSearch(searchString); const results = fuse.multiTermsSearch(searchString);
let filteredItems = this._allItems as PickerComboBoxItem[]; let filteredItems = [...this._allItems];
if (results) {
const items = results.map((result) => result.item); if (results) {
if (items.length === 0) { const items: (PickerComboBoxItem | string)[] = results.map(
items.push( (result) => result.item
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) );
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
}
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems as PickerComboBoxItem[],
this._allItems as PickerComboBoxItem[]
); );
} }
const additionalItems = this._getAdditionalItems(searchString);
items.push(...additionalItems); this._items = filteredItems as PickerComboBoxItem[];
filteredItems = items;
} }
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems,
this._allItems
);
}
this._items = filteredItems as PickerComboBoxItemWithLabel[];
this._selectedItemIndex = -1; this._selectedItemIndex = -1;
if (this._virtualizerElement) { if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0); this._virtualizerElement.scrollTo(0, 0);
} }
}; };
private _toggleSection(ev: Event) {
ev.stopPropagation();
this._resetSelectedItem();
this._sectionTitle = undefined;
const section = (ev.target as HTMLElement)["section-id"] as string;
if (!section) {
return;
}
if (this.selectedSection === section) {
this.selectedSection = undefined;
} else {
this.selectedSection = section;
}
this._items = this._getItems();
// Reset scroll position when filter changes
if (this._virtualizerElement) {
this._virtualizerElement.scrollToIndex(0);
}
}
private _registerKeyboardShortcuts() { private _registerKeyboardShortcuts() {
this._removeKeyboardShortcuts = tinykeys(this, { this._removeKeyboardShortcuts = tinykeys(this, {
ArrowUp: this._selectPreviousItem, ArrowUp: this._selectPreviousItem,
@@ -344,7 +458,7 @@ export class HaPickerComboBox extends LitElement {
return; return;
} }
if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) { if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search // Skip titles, padding and empty search
if (nextIndex === maxItems) { if (nextIndex === maxItems) {
return; return;
@@ -373,7 +487,7 @@ export class HaPickerComboBox extends LitElement {
return; return;
} }
if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) { if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search // Skip titles, padding and empty search
if (nextIndex === 0) { if (nextIndex === 0) {
return; return;
@@ -395,13 +509,6 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = 0; const nextIndex = 0;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") { if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1; this._selectedItemIndex = nextIndex + 1;
} else { } else {
@@ -419,13 +526,6 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = this._virtualizerElement.items.length - 1; const nextIndex = this._virtualizerElement.items.length - 1;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") { if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1; this._selectedItemIndex = nextIndex - 1;
} else { } else {
@@ -453,10 +553,7 @@ export class HaPickerComboBox extends LitElement {
ev.stopPropagation(); ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem; const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if ( if (this._virtualizerElement?.items.length === 1) {
this._virtualizerElement?.items.length === 1 &&
firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
) {
fireEvent(this, "value-changed", { fireEvent(this, "value-changed", {
value: firstItem.id, value: firstItem.id,
}); });
@@ -472,7 +569,7 @@ export class HaPickerComboBox extends LitElement {
const item = this._virtualizerElement?.items[ const item = this._virtualizerElement?.items[
this._selectedItemIndex this._selectedItemIndex
] as PickerComboBoxItem; ] as PickerComboBoxItem;
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { if (item) {
fireEvent(this, "value-changed", { value: item.id }); fireEvent(this, "value-changed", { value: item.id });
} }
}; };
@@ -484,6 +581,9 @@ export class HaPickerComboBox extends LitElement {
this._selectedItemIndex = -1; this._selectedItemIndex = -1;
} }
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
static styles = [ static styles = [
haStyleScrollbar, haStyleScrollbar,
css` css`
@@ -558,6 +658,80 @@ export class HaPickerComboBox extends LitElement {
background-color: var(--ha-color-fill-neutral-normal-hover); background-color: var(--ha-color-fill-neutral-normal-hover);
} }
} }
.sections {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
:host([mode="dialog"]) .sections {
padding: var(--ha-space-3) var(--ha-space-4);
}
.sections ha-filter-chip {
flex-shrink: 0;
--md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
.sections .separator {
height: var(--ha-space-8);
width: 0;
border: 1px solid var(--ha-color-border-neutral-quiet);
}
.section-title,
.title {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
.title {
width: 100%;
}
:host([mode="dialog"]) .title {
padding: var(--ha-space-1) var(--ha-space-4);
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
.section-title-wrapper {
height: 0;
position: relative;
}
.section-title {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-8));
}
.section-title.show {
opacity: 1;
z-index: 1;
}
.empty-search {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
padding: var(--ha-space-3);
}
`, `,
]; ];
} }

View File

@@ -1,6 +1,8 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { html, LitElement } from "lit"; import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import type { StateSelector } from "../../data/selector"; import type { StateSelector } from "../../data/selector";
import { extractFromTarget } from "../../data/target";
import { SubscribeMixin } from "../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-state-picker"; import "../entity/ha-entity-state-picker";
@@ -25,15 +27,29 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public context?: { @property({ attribute: false }) public context?: {
filter_attribute?: string; filter_attribute?: string;
filter_entity?: string | string[]; filter_entity?: string | string[];
filter_target?: HassServiceTarget;
}; };
@state() private _entityIds?: string | string[];
willUpdate(changedProps) {
if (changedProps.has("selector") || changedProps.has("context")) {
this._resolveEntityIds(
this.selector.state?.entity_id,
this.context?.filter_entity,
this.context?.filter_target
).then((entityIds) => {
this._entityIds = entityIds;
});
}
}
protected render() { protected render() {
if (this.selector.state?.multiple) { if (this.selector.state?.multiple) {
return html` return html`
<ha-entity-states-picker <ha-entity-states-picker
.hass=${this.hass} .hass=${this.hass}
.entityId=${this.selector.state?.entity_id || .entityId=${this._entityIds}
this.context?.filter_entity}
.attribute=${this.selector.state?.attribute || .attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute} this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options} .extraOptions=${this.selector.state?.extra_options}
@@ -50,8 +66,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
return html` return html`
<ha-entity-state-picker <ha-entity-state-picker
.hass=${this.hass} .hass=${this.hass}
.entityId=${this.selector.state?.entity_id || .entityId=${this._entityIds}
this.context?.filter_entity}
.attribute=${this.selector.state?.attribute || .attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute} this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options} .extraOptions=${this.selector.state?.extra_options}
@@ -65,6 +80,24 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
></ha-entity-state-picker> ></ha-entity-state-picker>
`; `;
} }
private async _resolveEntityIds(
selectorEntityId: string | string[] | undefined,
contextFilterEntity: string | string[] | undefined,
contextFilterTarget: HassServiceTarget | undefined
): Promise<string | string[] | undefined> {
if (selectorEntityId !== undefined) {
return selectorEntityId;
}
if (contextFilterEntity !== undefined) {
return contextFilterEntity;
}
if (contextFilterTarget !== undefined) {
const result = await extractFromTarget(this.hass, contextFilterTarget);
return result.referenced_entities;
}
return undefined;
}
} }
declare global { declare global {

View File

@@ -33,6 +33,7 @@ import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url"; import { documentationUrl } from "../util/documentation-url";
import "./ha-checkbox"; import "./ha-checkbox";
import "./ha-icon-button"; import "./ha-icon-button";
import "./ha-markdown";
import "./ha-selector/ha-selector"; import "./ha-selector/ha-selector";
import "./ha-service-picker"; import "./ha-service-picker";
import "./ha-service-section-icon"; import "./ha-service-section-icon";
@@ -684,10 +685,14 @@ export class HaServiceControl extends LitElement {
dataField.key}</span dataField.key}</span
> >
<span slot="description" <span slot="description"
>${this.hass.localize( ><ha-markdown
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description` breaks
) || dataField?.description}</span allow-svg
> .content=${this.hass.localize(
`component.${domain}.services.${serviceName}.fields.${dataField.key}.description`
) || dataField?.description}
></ha-markdown>
</span>
<ha-selector <ha-selector
.context=${this._selectorContext(targetEntities)} .context=${this._selectorContext(targetEntities)}
.disabled=${this.disabled || .disabled=${this.disabled ||

View File

@@ -1,22 +1,13 @@
import { import {
mdiBell, mdiBell,
mdiCalendar,
mdiCellphoneCog, mdiCellphoneCog,
mdiChartBox,
mdiClipboardList,
mdiCog, mdiCog,
mdiFormatListBulletedType,
mdiHammer,
mdiLightningBolt,
mdiMenu, mdiMenu,
mdiMenuOpen, mdiMenuOpen,
mdiPlayBoxMultiple,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js"; } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { import {
customElement, customElement,
eventOptions, eventOptions,
@@ -33,6 +24,14 @@ import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle"; import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend"; import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import {
FIXED_PANELS,
getDefaultPanelUrlPath,
getPanelIcon,
getPanelIconPath,
getPanelTitle,
SHOW_AFTER_SPACER_PANELS,
} from "../data/panel";
import type { PersistentNotification } from "../data/persistent_notification"; import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification"; import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs"; import { subscribeRepairsIssueRegistry } from "../data/repairs";
@@ -53,8 +52,6 @@ import "./ha-spinner";
import "./ha-svg-icon"; import "./ha-svg-icon";
import "./user/ha-user-badge"; import "./user/ha-user-badge";
const SHOW_AFTER_SPACER = ["config", "developer-tools"];
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body; const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = { const SORT_VALUE_URL_PATHS = {
@@ -66,18 +63,6 @@ const SORT_VALUE_URL_PATHS = {
config: 11, config: 11,
}; };
export const PANEL_ICONS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
history: mdiChartBox,
logbook: mdiFormatListBulletedType,
lovelace: mdiViewDashboard,
map: mdiTooltipAccount,
"media-browser": mdiPlayBoxMultiple,
todo: mdiClipboardList,
};
const panelSorter = ( const panelSorter = (
reverseSort: string[], reverseSort: string[],
defaultPanel: string, defaultPanel: string,
@@ -142,7 +127,7 @@ const defaultPanelSorter = (
export const computePanels = memoizeOne( export const computePanels = memoizeOne(
( (
panels: HomeAssistant["panels"], panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["defaultPanel"], defaultPanel: string,
panelsOrder: string[], panelsOrder: string[],
hiddenPanels: string[], hiddenPanels: string[],
locale: HomeAssistant["locale"] locale: HomeAssistant["locale"]
@@ -154,14 +139,23 @@ export const computePanels = memoizeOne(
const beforeSpacer: PanelInfo[] = []; const beforeSpacer: PanelInfo[] = [];
const afterSpacer: PanelInfo[] = []; const afterSpacer: PanelInfo[] = [];
Object.values(panels).forEach((panel) => { const allPanels = Object.values(panels).filter(
(panel) => !FIXED_PANELS.includes(panel.url_path)
);
allPanels.forEach((panel) => {
const isDefaultPanel = panel.url_path === defaultPanel;
if ( if (
hiddenPanels.includes(panel.url_path) || !isDefaultPanel &&
(!panel.title && panel.url_path !== defaultPanel) (!panel.title ||
hiddenPanels.includes(panel.url_path) ||
(panel.default_visible === false &&
!panelsOrder.includes(panel.url_path)))
) { ) {
return; return;
} }
(SHOW_AFTER_SPACER.includes(panel.url_path) (SHOW_AFTER_SPACER_PANELS.includes(panel.url_path)
? afterSpacer ? afterSpacer
: beforeSpacer : beforeSpacer
).push(panel); ).push(panel);
@@ -248,10 +242,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return nothing; return nothing;
} }
// Show the supervisor as being part of configuration const selectedPanel = this.hass.panelUrl;
const selectedPanel = this.route.path?.startsWith("/hassio/")
? "config"
: this.hass.panelUrl;
// prettier-ignore // prettier-ignore
return html` return html`
@@ -296,7 +287,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
hass.localize !== oldHass.localize || hass.localize !== oldHass.localize ||
hass.locale !== oldHass.locale || hass.locale !== oldHass.locale ||
hass.states !== oldHass.states || hass.states !== oldHass.states ||
hass.defaultPanel !== oldHass.defaultPanel || hass.userData !== oldHass.userData ||
hass.systemData !== oldHass.systemData ||
hass.connected !== oldHass.connected hass.connected !== oldHass.connected
); );
} }
@@ -393,21 +385,22 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _renderAllPanels(selectedPanel: string) { private _renderAllPanels(selectedPanel: string) {
if (!this._panelOrder || !this._hiddenPanels) { if (!this._panelOrder || !this._hiddenPanels) {
return html` return html`
<ha-fade-in .delay=${500} <ha-fade-in .delay=${500}>
><ha-spinner size="small"></ha-spinner <ha-spinner size="small"></ha-spinner>
></ha-fade-in> </ha-fade-in>
`; `;
} }
const defaultPanel = getDefaultPanelUrlPath(this.hass);
const [beforeSpacer, afterSpacer] = computePanels( const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels, this.hass.panels,
this.hass.defaultPanel, defaultPanel,
this._panelOrder, this._panelOrder,
this._hiddenPanels, this._hiddenPanels,
this.hass.locale this.hass.locale
); );
// prettier-ignore
return html` return html`
<ha-md-list <ha-md-list
class="ha-scrollbar" class="ha-scrollbar"
@@ -419,54 +412,39 @@ class HaSidebar extends SubscribeMixin(LitElement) {
${this._renderPanels(beforeSpacer, selectedPanel)} ${this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()} ${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)} ${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()} ${this.hass.user?.is_admin
? this._renderConfiguration(selectedPanel)
: this._renderExternalConfiguration()}
</ha-md-list> </ha-md-list>
`; `;
} }
private _renderPanels(panels: PanelInfo[], selectedPanel: string) { private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
return panels.map((panel) => return panels.map((panel) =>
this._renderPanel( this._renderPanel(panel, panel.url_path === selectedPanel)
panel.url_path,
panel.url_path === this.hass.defaultPanel
? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon,
panel.url_path === this.hass.defaultPanel && !panel.icon
? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
selectedPanel
)
); );
} }
private _renderPanel( private _renderPanel(panel: PanelInfo, isSelected: boolean) {
urlPath: string, const title = getPanelTitle(this.hass, panel);
title: string | null, const urlPath = panel.url_path;
icon: string | null | undefined, const icon = getPanelIcon(panel);
iconPath: string | null | undefined, const iconPath = getPanelIconPath(panel);
selectedPanel: string
) { return html`
return urlPath === "config" <ha-md-list-item
? this._renderConfiguration(title, selectedPanel) .href=${`/${urlPath}`}
: html` type="link"
<ha-md-list-item class=${classMap({ selected: isSelected })}
.href=${`/${urlPath}`} @mouseenter=${this._itemMouseEnter}
type="link" @mouseleave=${this._itemMouseLeave}
class=${classMap({ >
selected: selectedPanel === urlPath, ${iconPath
})} ? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
@mouseenter=${this._itemMouseEnter} : html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
@mouseleave=${this._itemMouseLeave} <span class="item-text" slot="headline">${title}</span>
> </ha-md-list-item>
${iconPath `;
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
</ha-md-list-item>
`;
} }
private _renderDivider() { private _renderDivider() {
@@ -477,10 +455,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return html`<div class="spacer" disabled></div>`; return html`<div class="spacer" disabled></div>`;
} }
private _renderConfiguration(title: string | null, selectedPanel: string) { private _renderConfiguration(selectedPanel: string) {
if (!this.hass.user?.is_admin) {
return nothing;
}
const isSelected =
selectedPanel === "config" || this.route.path?.startsWith("/hassio/");
return html` return html`
<ha-md-list-item <ha-md-list-item
class="configuration${selectedPanel === "config" ? " selected" : ""}" class="configuration ${classMap({ selected: isSelected })}"
type="button" type="button"
href="/config" href="/config"
@mouseenter=${this._itemMouseEnter} @mouseenter=${this._itemMouseEnter}
@@ -494,15 +477,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
${this._updatesCount + this._issuesCount} ${this._updatesCount + this._issuesCount}
</span> </span>
` `
: ""} : nothing}
<span class="item-text" slot="headline">${title}</span> <span class="item-text" slot="headline"
>${this.hass.localize("panel.config")}</span
>
${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0) ${this.alwaysExpand && (this._updatesCount > 0 || this._issuesCount > 0)
? html` ? html`
<span class="badge" slot="end" <span class="badge" slot="end"
>${this._updatesCount + this._issuesCount}</span >${this._updatesCount + this._issuesCount}</span
> >
` `
: ""} : nothing}
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
@@ -525,19 +510,20 @@ class HaSidebar extends SubscribeMixin(LitElement) {
? html` ? html`
<span class="badge" slot="start"> ${notificationCount} </span> <span class="badge" slot="start"> ${notificationCount} </span>
` `
: ""} : nothing}
<span class="item-text" slot="headline" <span class="item-text" slot="headline"
>${this.hass.localize("ui.notification_drawer.title")}</span >${this.hass.localize("ui.notification_drawer.title")}</span
> >
${this.alwaysExpand && notificationCount > 0 ${this.alwaysExpand && notificationCount > 0
? html`<span class="badge" slot="end">${notificationCount}</span>` ? html`<span class="badge" slot="end">${notificationCount}</span>`
: ""} : nothing}
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
private _renderUserItem(selectedPanel: string) { private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(this.hass); const isRTL = computeRTL(this.hass);
const isSelected = selectedPanel === "profile";
return html` return html`
<ha-md-list-item <ha-md-list-item
@@ -545,7 +531,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
type="link" type="link"
class=${classMap({ class=${classMap({
user: true, user: true,
selected: selectedPanel === "profile", selected: isSelected,
rtl: isRTL, rtl: isRTL,
})} })}
@mouseenter=${this._itemMouseEnter} @mouseenter=${this._itemMouseEnter}
@@ -556,31 +542,30 @@ class HaSidebar extends SubscribeMixin(LitElement) {
.user=${this.hass.user} .user=${this.hass.user}
.hass=${this.hass} .hass=${this.hass}
></ha-user-badge> ></ha-user-badge>
<span class="item-text" slot="headline">
<span class="item-text" slot="headline" ${this.hass.user ? this.hass.user.name : ""}
>${this.hass.user ? this.hass.user.name : ""}</span </span>
>
</ha-md-list-item> </ha-md-list-item>
`; `;
} }
private _renderExternalConfiguration() { private _renderExternalConfiguration() {
return html`${!this.hass.user?.is_admin && if (!this.hass.auth.external?.config.hasSettingsScreen) {
this.hass.auth.external?.config.hasSettingsScreen return nothing;
? html` }
<ha-md-list-item return html`
@click=${this._handleExternalAppConfiguration} <ha-md-list-item
type="button" @click=${this._handleExternalAppConfiguration}
@mouseenter=${this._itemMouseEnter} type="button"
@mouseleave=${this._itemMouseLeave} @mouseenter=${this._itemMouseEnter}
> @mouseleave=${this._itemMouseLeave}
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon> >
<span class="item-text" slot="headline"> <ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
${this.hass.localize("ui.sidebar.external_app_configuration")} <span class="item-text" slot="headline">
</span> ${this.hass.localize("ui.sidebar.external_app_configuration")}
</ha-md-list-item> </span>
` </ha-md-list-item>
: ""}`; `;
} }
private _handleExternalAppConfiguration(ev: Event) { private _handleExternalAppConfiguration(ev: Event) {

View File

@@ -0,0 +1,178 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { subscribeLabFeatures } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
interface Snowflake {
id: number;
left: number;
size: number;
duration: number;
delay: number;
blur: number;
}
@customElement("ha-snowflakes")
export class HaSnowflakes extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state() private _enabled = false;
@state() private _snowflakes: Snowflake[] = [];
private _maxSnowflakes = 50;
public hassSubscribe() {
return [
subscribeLabFeatures(this.hass!.connection, (features) => {
this._enabled =
features.find(
(f) =>
f.domain === "frontend" && f.preview_feature === "winter_mode"
)?.enabled ?? false;
}),
];
}
private _generateSnowflakes() {
if (!this._enabled) {
this._snowflakes = [];
return;
}
const snowflakes: Snowflake[] = [];
for (let i = 0; i < this._maxSnowflakes; i++) {
snowflakes.push({
id: i,
left: Math.random() * 100, // Random position from 0-100%
size: Math.random() * 12 + 8, // Random size between 8-20px
duration: Math.random() * 8 + 8, // Random duration between 8-16s
delay: Math.random() * 8, // Random delay between 0-8s
blur: Math.random() * 1, // Random blur between 0-1px
});
}
this._snowflakes = snowflakes;
}
protected willUpdate(changedProps: Map<string, unknown>) {
super.willUpdate(changedProps);
if (changedProps.has("_enabled")) {
this._generateSnowflakes();
}
}
protected render() {
if (!this._enabled) {
return nothing;
}
const isDark = this.hass?.themes.darkMode ?? false;
return html`
<div class="snowflakes ${isDark ? "dark" : "light"}" aria-hidden="true">
${this._snowflakes.map(
(flake) => html`
<div
class="snowflake ${this.narrow && flake.id >= 30
? "hide-narrow"
: ""}"
style="
left: ${flake.left}%;
font-size: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
filter: blur(${flake.blur}px);
"
>
</div>
`
)}
</div>
`;
}
static readonly styles = css`
:host {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 9999;
overflow: hidden;
}
.snowflakes {
position: absolute;
top: -10%;
left: 0;
width: 100%;
height: 110%;
pointer-events: none;
}
.snowflake {
position: absolute;
top: -10%;
opacity: 0.7;
user-select: none;
pointer-events: none;
animation: fall linear infinite;
}
.light .snowflake {
color: #00bcd4;
text-shadow:
0 0 5px #00bcd4,
0 0 10px #00e5ff;
}
.dark .snowflake {
color: #fff;
text-shadow:
0 0 5px rgba(255, 255, 255, 0.8),
0 0 10px rgba(255, 255, 255, 0.5);
}
.snowflake.hide-narrow {
display: none;
}
@keyframes fall {
0% {
transform: translateY(-10vh) translateX(0);
}
25% {
transform: translateY(30vh) translateX(10px);
}
50% {
transform: translateY(60vh) translateX(-10px);
}
75% {
transform: translateY(85vh) translateX(10px);
}
100% {
transform: translateY(120vh) translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.snowflake {
animation: none;
display: none;
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-snowflakes": HaSnowflakes;
}
}

View File

@@ -1,15 +1,31 @@
import "@home-assistant/webawesome/dist/components/popover/popover"; import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
// @ts-ignore // @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css"; import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import { mdiPlaylistPlus } from "@mdi/js"; import { mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket"; import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit"; import { LitElement, css, html, nothing, unsafeCSS } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array"; import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { isValidEntityId } from "../common/entity/valid_entity_id"; import { isValidEntityId } from "../common/entity/valid_entity_id";
import { computeRTL } from "../common/util/compute_rtl";
import {
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { labelsContext } from "../data/context";
import { getDevices, type DevicePickerItem } from "../data/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity"; import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
import { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
import { domainToName } from "../data/integration";
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
import { import {
areaMeetsFilter, areaMeetsFilter,
deviceMeetsFilter, deviceMeetsFilter,
@@ -18,18 +34,23 @@ import {
type TargetTypeFloorless, type TargetTypeFloorless,
} from "../data/target"; } from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail"; import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../resources/fuse";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-bottom-sheet"; import "./ha-generic-picker";
import "./ha-button"; import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-input-helper-text";
import "./ha-svg-icon"; import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group"; import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-selector";
import type { HaTargetPickerSelector } from "./target-picker/ha-target-picker-selector";
import "./target-picker/ha-target-picker-value-chip"; import "./target-picker/ha-target-picker-value-chip";
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
@customElement("ha-target-picker") @customElement("ha-target-picker")
export class HaTargetPicker extends SubscribeMixin(LitElement) { export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -68,23 +89,54 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false; @property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
@state() private _open = false; @state() private _selectedSection?: TargetTypeFloorless;
@state() private _addTargetWidth = 0; @state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _narrow = false; @state()
@consume({ context: labelsContext, subscribe: true })
@state() private _pickerFilter?: TargetTypeFloorless; private _labelRegistry!: LabelRegistryEntry[];
@state() private _pickerWrapperOpen = false;
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement;
@query("ha-target-picker-selector")
private _targetPickerSelectorElement?: HaTargetPickerSelector;
private _newTarget?: { type: TargetType; id: string }; private _newTarget?: { type: TargetType; id: string };
private _getDevicesMemoized = memoizeOne(getDevices);
private _getLabelsMemoized = memoizeOne(getLabels);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private get _showEntityId() {
return this.hass.userData?.showEntityIdPicker;
}
private _fuseIndexes = {
area: memoizeOne((states: FloorComboBoxItem[]) =>
this._createFuseIndex(states)
),
entity: memoizeOne((states: EntityComboBoxItem[]) =>
this._createFuseIndex(states)
),
device: memoizeOne((states: DevicePickerItem[]) =>
this._createFuseIndex(states)
),
label: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
),
};
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (!this.hasUpdated) {
this._loadConfigEntries();
}
}
private _createFuseIndex = (states) =>
Fuse.createIndex(["search_labels"], states);
protected render() { protected render() {
if (this.addOnTop) { if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `; return html` ${this._renderPicker()} ${this._renderItems()} `;
@@ -289,137 +341,63 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
} }
private _renderPicker() { private _renderPicker() {
const sections = [
{
id: "entity",
label: this.hass.localize("ui.components.target-picker.type.entities"),
},
{
id: "device",
label: this.hass.localize("ui.components.target-picker.type.devices"),
},
{
id: "area",
label: this.hass.localize("ui.components.target-picker.type.areas"),
},
"separator" as const,
{
id: "label",
label: this.hass.localize("ui.components.target-picker.type.labels"),
},
];
return html` return html`
<div class="add-target-wrapper"> <div class="add-target-wrapper">
<ha-button <ha-generic-picker
id="add-target-button" .hass=${this.hass}
size="small" .disabled=${this.disabled}
appearance="filled" .autofocus=${this.autofocus}
@click=${this._showPicker} .helper=${this.helper}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.target-picker.no_targets"
)}
.sectionTitleFunction=${this._sectionTitleFunction}
.selectedSection=${this._selectedSection}
.rowRenderer=${this._renderRow}
.getItems=${this._getItems}
@value-changed=${this._targetPicked}
.addButtonLabel=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
.getAdditionalItems=${this._getAdditionalItems}
> >
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon> </ha-generic-picker>
${this.hass.localize("ui.components.target-picker.add_target")}
</ha-button>
${!this._narrow && (this._pickerWrapperOpen || this._open)
? html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._addTargetWidth}px;"
without-arrow
distance="-4"
placement="bottom-start"
for="add-target-button"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._showSelector}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
${this._renderTargetSelector()}
</wa-popover>
`
: this._pickerWrapperOpen || this._open
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._showSelector}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
>
${this._renderTargetSelector(true)}
</ha-bottom-sheet>`
: nothing}
</div> </div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing}
`; `;
} }
connectedCallback() { private _targetPicked(ev: CustomEvent<{ value: string }>) {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
};
private _showPicker() {
this._addTargetWidth = this._addTargetWrapper?.offsetWidth || 0;
this._pickerWrapperOpen = true;
}
// wait for drawer animation to finish
private _showSelector = () => {
this._open = true;
requestAnimationFrame(() => {
this._targetPickerSelectorElement?.focus();
});
};
private _handleUpdatePickerFilter(
ev: CustomEvent<TargetTypeFloorless | undefined>
) {
this._updatePickerFilter(
typeof ev.detail === "string" ? ev.detail : undefined
);
}
private _updatePickerFilter = (filter?: TargetTypeFloorless) => {
this._pickerFilter = filter;
};
private _hidePicker(ev) {
ev.stopPropagation(); ev.stopPropagation();
this._open = false; const value = ev.detail.value;
this._pickerWrapperOpen = false; if (value.startsWith(CREATE_ID)) {
this._createNewDomainElement(value.substring(CREATE_ID.length));
if (this._newTarget) { return;
this._addTarget(this._newTarget.id, this._newTarget.type);
this._newTarget = undefined;
} }
}
private _renderTargetSelector(dialogMode = false) { const [type, id] = ev.detail.value.split(SEPARATOR);
if (!this._open) { this._addTarget(id, type as TargetType);
return nothing;
}
return html`
<ha-target-picker-selector
.hass=${this.hass}
@filter-type-changed=${this._handleUpdatePickerFilter}
.filterType=${this._pickerFilter}
@target-picked=${this._handleTargetPicked}
@create-domain-picked=${this._handleCreateDomain}
.targetValue=${this.value}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.createDomains=${this.createDomains}
.mode=${dialogMode ? "dialog" : "popover"}
></ha-target-picker-selector>
`;
} }
private _addTarget(id: string, type: TargetType) { private _addTarget(id: string, type: TargetType) {
@@ -454,26 +432,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
?.removeAttribute("collapsed"); ?.removeAttribute("collapsed");
} }
private _handleTargetPicked = async ( private _createNewDomainElement = (domain: string) => {
ev: CustomEvent<{ type: TargetType; id: string }>
) => {
ev.stopPropagation();
this._pickerWrapperOpen = false;
if (!ev.detail.type || !ev.detail.id) {
return;
}
// save new target temporarily to add it after dialog closes
this._newTarget = ev.detail;
};
private _handleCreateDomain = (ev: CustomEvent<string>) => {
this._pickerWrapperOpen = false;
const domain = ev.detail;
showHelperDetailDialog(this, { showHelperDetailDialog(this, {
domain, domain,
dialogClosedCallback: (item) => { dialogClosedCallback: (item) => {
@@ -675,6 +634,459 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return undefined; return undefined;
} }
private _getRowType = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem
) => {
if (
(item as FloorComboBoxItem).type === "area" ||
(item as FloorComboBoxItem).type === "floor"
) {
return (item as FloorComboBoxItem).type;
}
if ("domain" in item) {
return "device";
}
if ("stateObj" in item) {
return "entity";
}
if (item.id === EMPTY_SEARCH) {
return "empty";
}
return "label";
};
private _sectionTitleFunction = ({
firstIndex,
lastIndex,
firstItem,
secondItem,
itemsCount,
}: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => {
if (
firstItem === undefined ||
secondItem === undefined ||
typeof firstItem === "string" ||
(typeof secondItem === "string" && secondItem !== "padding") ||
(firstIndex === 0 && lastIndex === itemsCount - 1)
) {
return undefined;
}
const type = this._getRowType(firstItem as PickerComboBoxItem);
const translationType:
| "areas"
| "entities"
| "devices"
| "labels"
| undefined =
type === "area" || type === "floor"
? "areas"
: type === "entity"
? "entities"
: type && type !== "empty"
? `${type}s`
: undefined;
return translationType
? this.hass.localize(
`ui.components.target-picker.type.${translationType}`
)
: undefined;
};
private _getItems = (searchString: string, section: string) => {
this._selectedSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
this.hass.localize,
this.entityFilter,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.value,
searchString,
this._configEntryLookup,
this._selectedSection
);
};
private _getItemsMemoized = memoizeOne(
(
localize: HomeAssistant["localize"],
entityFilter: this["entityFilter"],
deviceFilter: this["deviceFilter"],
includeDomains: this["includeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
targetValue: this["value"],
searchTerm: string,
configEntryLookup: Record<string, ConfigEntry>,
filterType?: TargetTypeFloorless
) => {
const items: (
| string
| FloorComboBoxItem
| EntityComboBoxItem
| PickerComboBoxItem
)[] = [];
if (!filterType || filterType === "entity") {
let entityItems = this._getEntitiesMemoized(
this.hass,
includeDomains,
undefined,
entityFilter,
includeDeviceClasses,
undefined,
undefined,
targetValue?.entity_id
? ensureArray(targetValue.entity_id)
: undefined,
undefined,
`entity${SEPARATOR}`
);
if (searchTerm) {
entityItems = this._filterGroup(
"entity",
entityItems,
searchTerm,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
) as EntityComboBoxItem[];
}
if (!filterType && entityItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.entities"));
}
items.push(...entityItems);
}
if (!filterType || filterType === "device") {
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.device_id
? ensureArray(targetValue.device_id)
: undefined,
undefined,
`device${SEPARATOR}`
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
}
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
}
items.push(...deviceItems);
}
if (!filterType || filterType === "area") {
let areasAndFloors = this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
),
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined,
targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined
);
if (searchTerm) {
areasAndFloors = this._filterGroup(
"area",
areasAndFloors,
searchTerm
) as FloorComboBoxItem[];
}
if (!filterType && areasAndFloors.length) {
// show group title
items.push(localize("ui.components.target-picker.type.areas"));
}
items.push(
...areasAndFloors.map((item, index) => {
const nextItem = areasAndFloors[index + 1];
if (
!nextItem ||
(item.type === "area" && nextItem.type === "floor")
) {
return {
...item,
last: true,
};
}
return item;
})
);
}
if (!filterType || filterType === "label") {
let labels = this._getLabelsMemoized(
this.hass,
this._labelRegistry,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined,
`label${SEPARATOR}`
);
if (searchTerm) {
labels = this._filterGroup("label", labels, searchTerm);
}
if (!filterType && labels.length) {
// show group title
items.push(localize("ui.components.target-picker.type.labels"));
}
items.push(...labels);
}
return items;
}
);
private _filterGroup(
type: TargetType,
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
searchTerm: string,
checkExact?: (
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
) => boolean
) {
const fuseIndex = this._fuseIndexes[type](items);
const fuse = new HaFuse(
items,
{
shouldSort: false,
minMatchCharLength: Math.min(searchTerm.length, 2),
},
fuseIndex
);
const results = fuse.multiTermsSearch(searchTerm);
let filteredItems = items;
if (results) {
filteredItems = results.map((result) => result.item);
}
if (!checkExact) {
return filteredItems;
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex((item) => checkExact(item));
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
}
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
private _getCreateItems = memoizeOne(
(createDomains: this["createDomains"]) => {
if (!createDomains?.length) {
return [];
}
return createDomains.map((domain) => {
const primary = this.hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(this.hass.localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
}
);
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _renderRow = (
item:
| PickerComboBoxItem
| (FloorComboBoxItem & { last?: boolean | undefined })
| EntityComboBoxItem
| DevicePickerItem,
index: number
) => {
if (!item) {
return nothing;
}
const type = this._getRowType(item);
let hasFloor = false;
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
item.id = item[type]?.[`${type}_id`];
rtl = computeRTL(this.hass);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
}
if (type === "entity") {
showEntityId = !!this._showEntityId;
}
return html`
<ha-combo-box-item
id=${`list-item-${index}`}
tabindex="-1"
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${(item as FloorComboBoxItem).type === "area" && hasFloor
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""}
>
${(item as FloorComboBoxItem).type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "var(--ha-space-0)",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${(
item as FloorComboBoxItem & { last?: boolean | undefined }
).last}
slot="start"
></ha-tree-indicator>
`
: nothing}
${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>`
: type === "entity" && (item as EntityComboBoxItem).stateObj
? html`
<state-badge
slot="start"
.stateObj=${(item as EntityComboBoxItem).stateObj}
.hass=${this.hass}
></state-badge>
`
: type === "device" && (item as DevicePickerItem).domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: (item as DevicePickerItem).domain!,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
})}
/>
`
: type === "floor"
? html`<ha-floor-icon
slot="start"
.floor=${(item as FloorComboBoxItem).floor!}
></ha-floor-icon>`
: type === "area"
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${(item as EntityComboBoxItem).stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${(item as EntityComboBoxItem).stateObj?.entity_id}
</span>
`
: nothing}
${(item as EntityComboBoxItem).domain_name &&
(type !== "entity" || !showEntityId)
? html`
<div slot="trailing-supporting-text" class="domain">
${(item as EntityComboBoxItem).domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _noTargetFoundLabel = (search: string) =>
this.hass.localize("ui.components.target-picker.no_target_found", {
term: html`<b>${search}</b>`,
});
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return css` return css`
.add-target-wrapper { .add-target-wrapper {
@@ -683,31 +1095,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
margin-top: var(--ha-space-3); margin-top: var(--ha-space-3);
} }
wa-popover { ha-generic-picker {
--wa-space-l: var(--ha-space-0); width: 100%;
}
wa-popover::part(body) {
width: min(max(var(--body-width), 336px), 600px);
max-width: min(max(var(--body-width), 336px), 600px);
max-height: 500px;
height: 70vh;
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: var(--ha-space-0);
--ha-bottom-sheet-surface-background: var(--card-background-color);
} }
${unsafeCSS(chipStyles)} ${unsafeCSS(chipStyles)}

View File

@@ -0,0 +1,97 @@
import {
mdiAvTimer,
mdiCalendar,
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiHomeAssistant,
mdiMapMarker,
mdiMapMarkerRadius,
mdiMessageAlert,
mdiMicrophoneMessage,
mdiNfcVariant,
mdiNumeric,
mdiStateMachine,
mdiSwapHorizontal,
mdiWeatherSunny,
mdiWebhook,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
export const TRIGGER_ICONS = {
calendar: mdiCalendar,
device: mdiDevices,
event: mdiGestureDoubleTap,
state: mdiStateMachine,
geo_location: mdiMapMarker,
homeassistant: mdiHomeAssistant,
mqtt: mdiSwapHorizontal,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
conversation: mdiMicrophoneMessage,
tag: mdiNfcVariant,
template: mdiCodeBraces,
time: mdiClockOutline,
time_pattern: mdiAvTimer,
webhook: mdiWebhook,
persistent_notification: mdiMessageAlert,
zone: mdiMapMarkerRadius,
list: mdiFormatListBulleted,
};
@customElement("ha-trigger-icon")
export class HaTriggerIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public trigger?: string;
@property() public icon?: string;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
}
if (!this.trigger) {
return nothing;
}
if (!this.hass) {
return this._renderFallback();
}
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
});
return html`${until(icon)}`;
}
private _renderFallback() {
const domain = computeDomain(this.trigger!);
return html`
<ha-svg-icon
.path=${TRIGGER_ICONS[this.trigger!] || FALLBACK_DOMAIN_ICONS[domain]}
></ha-svg-icon>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-trigger-icon": HaTriggerIcon;
}
}

View File

@@ -1,6 +1,4 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog"; import { css, html, LitElement } from "lit";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { import {
customElement, customElement,
eventOptions, eventOptions,
@@ -8,6 +6,9 @@ import {
query, query,
state, state,
} from "lit/decorators"; } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { mdiClose } from "@mdi/js";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
@@ -31,6 +32,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* *
* @slot header - Replace the entire header area. * @slot header - Replace the entire header area.
* @slot headerNavigationIcon - Leading header action (e.g. close/back button). * @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus). * @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog content body. * @slot - Dialog content body.
* @slot footer - Dialog footer content. * @slot footer - Dialog footer content.
@@ -52,8 +55,8 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @attr {boolean} open - Controls the dialog open state. * @attr {boolean} open - Controls the dialog open state.
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium". * @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset. Defaults to "medium".
* @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false. * @attr {boolean} prevent-scrim-close - Prevents closing the dialog by clicking the scrim/overlay. Defaults to false.
* @attr {string} header-title - Header title text when no custom title slot is provided. * @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text when no custom subtitle slot is provided. * @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below". * @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts. * @attr {boolean} flexcontent - Makes the dialog body a flex container for flexible layouts.
* *
@@ -72,6 +75,12 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
export class HaWaDialog extends LitElement { export class HaWaDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean, reflect: true }) @property({ type: Boolean, reflect: true })
public open = false; public open = false;
@@ -81,11 +90,11 @@ export class HaWaDialog extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" }) @property({ type: Boolean, reflect: true, attribute: "prevent-scrim-close" })
public preventScrimClose = false; public preventScrimClose = false;
@property({ type: String, attribute: "header-title" }) @property({ attribute: "header-title" })
public headerTitle = ""; public headerTitle?: string;
@property({ type: String, attribute: "header-subtitle" }) @property({ attribute: "header-subtitle" })
public headerSubtitle = ""; public headerSubtitle?: string;
@property({ type: String, attribute: "header-subtitle-position" }) @property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below"; public headerSubtitlePosition: "above" | "below" = "below";
@@ -117,6 +126,11 @@ export class HaWaDialog extends LitElement {
.open=${this._open} .open=${this._open}
.lightDismiss=${!this.preventScrimClose} .lightDismiss=${!this.preventScrimClose}
without-header without-header
aria-labelledby=${ifDefined(
this.ariaLabelledBy ||
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@wa-show=${this._handleShow} @wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow} @wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide} @wa-after-hide=${this._handleAfterHide}
@@ -133,14 +147,14 @@ export class HaWaDialog extends LitElement {
.path=${mdiClose} .path=${mdiClose}
></ha-icon-button> ></ha-icon-button>
</slot> </slot>
${this.headerTitle ${this.headerTitle !== undefined
? html`<span slot="title" class="title"> ? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle} ${this.headerTitle}
</span>` </span>`
: nothing} : html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle ${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>` ? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: nothing} : html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot> <slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header> </ha-dialog-header>
</slot> </slot>

View File

@@ -62,6 +62,10 @@ class HaWebRtcPlayer extends LitElement {
private _candidatesList: RTCIceCandidate[] = []; private _candidatesList: RTCIceCandidate[] = [];
private _handleVisibilityChange = () => { private _handleVisibilityChange = () => {
if (document.pictureInPictureElement) {
// video is playing in picture-in-picture mode, don't do anything
return;
}
if (document.hidden) { if (document.hidden) {
this._cleanUp(); this._cleanUp();
} else { } else {

View File

@@ -18,7 +18,7 @@ import {
removeLocalMedia, removeLocalMedia,
} from "../../data/media_source"; } from "../../data/media_source";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-button"; import "../ha-button";
import "../ha-check-list-item"; import "../ha-check-list-item";
@@ -305,6 +305,7 @@ class DialogMediaManage extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
--dialog-z-index: 9; --dialog-z-index: 9;
@@ -314,9 +315,9 @@ class DialogMediaManage extends LitElement {
@media (min-width: 800px) { @media (min-width: 800px) {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--dialog-surface-position: fixed; --mdc-dialog-max-height: calc(
--dialog-surface-top: 40px; 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
--mdc-dialog-max-height: calc(100vh - 72px); );
} }
} }

View File

@@ -19,7 +19,7 @@ import type {
MediaPlayerItem, MediaPlayerItem,
MediaPlayerLayoutType, MediaPlayerLayoutType,
} from "../../data/media-player"; } from "../../data/media-player";
import { haStyleDialog } from "../../resources/styles"; import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-dialog"; import "../ha-dialog";
import "../ha-dialog-header"; import "../ha-dialog-header";
@@ -223,6 +223,7 @@ class DialogMediaPlayerBrowse extends LitElement {
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyleDialog, haStyleDialog,
haStyleDialogFixedTop,
css` css`
ha-dialog { ha-dialog {
--dialog-z-index: 9; --dialog-z-index: 9;
@@ -230,23 +231,27 @@ class DialogMediaPlayerBrowse extends LitElement {
} }
ha-media-player-browse { ha-media-player-browse {
--media-browser-max-height: calc(100vh - 65px); --media-browser-max-height: calc(
100vh - 65px - var(--safe-area-inset-y)
);
} }
:host(.opened) ha-media-player-browse { :host(.opened) ha-media-player-browse {
height: calc(100vh - 65px); height: calc(100vh - 65px - var(--safe-area-inset-y));
} }
@media (min-width: 800px) { @media (min-width: 800px) {
ha-dialog { ha-dialog {
--mdc-dialog-max-width: 800px; --mdc-dialog-max-width: 800px;
--dialog-surface-position: fixed; --mdc-dialog-max-height: calc(
--dialog-surface-top: 40px; 100vh - var(--ha-space-18) - var(--safe-area-inset-y)
--mdc-dialog-max-height: calc(100vh - 72px); );
} }
ha-media-player-browse { ha-media-player-browse {
position: initial; position: initial;
--media-browser-max-height: calc(100vh - 145px); --media-browser-max-height: calc(
100vh - 145px - var(--safe-area-inset-y)
);
width: 700px; width: 700px;
} }
} }

View File

@@ -34,6 +34,7 @@ class SearchInput extends LitElement {
return html` return html`
<ha-textfield <ha-textfield
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
autocomplete="off"
.label=${this.label || this.hass.localize("ui.common.search")} .label=${this.label || this.hass.localize("ui.common.search")}
.value=${this.filter || ""} .value=${this.filter || ""}
icon icon

View File

@@ -545,7 +545,7 @@ export class HaTargetPickerItemRow extends LitElement {
name: entityName || deviceName || item, name: entityName || deviceName || item,
context, context,
stateObject, stateObject,
notFound: !stateObject && item !== "all", notFound: !stateObject && item !== "all" && item !== "none",
}; };
} }

File diff suppressed because it is too large Load Diff

View File

@@ -128,9 +128,7 @@ class HaUserPicker extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this.hass.localize( .notFoundLabel=${this._notFoundLabel}
"ui.components.user-picker.no_match"
)}
.placeholder=${placeholder} .placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} .getItems=${this._getItems}
@@ -149,6 +147,11 @@ class HaUserPicker extends LitElement {
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
} }
private _notFoundLabel = (search: string) =>
this.hass.localize("ui.components.user-picker.no_match", {
term: html`<b>${search}</b>`,
});
} }
declare global { declare global {

View File

@@ -50,7 +50,7 @@ export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{ {
groups: { groups: {
device_id: {}, device_id: {},
serviceGroups: {}, dynamicGroups: {},
}, },
}, },
{ {
@@ -117,14 +117,6 @@ export const VIRTUAL_ACTIONS: Partial<
}, },
} as const; } as const;
export const SERVICE_PREFIX = "__SERVICE__";
export const isService = (key: string | undefined): boolean | undefined =>
key?.startsWith(SERVICE_PREFIX);
export const getService = (key: string): string =>
key.substring(SERVICE_PREFIX.length);
export const COLLAPSIBLE_ACTION_ELEMENTS = [ export const COLLAPSIBLE_ACTION_ELEMENTS = [
"ha-automation-action-choose", "ha-automation-action-choose",
"ha-automation-action-condition", "ha-automation-action-condition",

View File

@@ -5,6 +5,7 @@ export interface AnalyticsPreferences {
diagnostics?: boolean; diagnostics?: boolean;
usage?: boolean; usage?: boolean;
statistics?: boolean; statistics?: boolean;
snapshots?: boolean;
} }
export interface Analytics { export interface Analytics {

View File

@@ -1,3 +1,4 @@
import { getAreasFloorHierarchy } from "../common/areas/areas-floor-hierarchy";
import { computeAreaName } from "../common/entity/compute_area_name"; import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name"; import { computeFloorName } from "../common/entity/compute_floor_name";
@@ -12,11 +13,7 @@ import {
} from "./device_registry"; } from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity"; import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry"; import type { EntityRegistryDisplayEntry } from "./entity_registry";
import { import type { FloorRegistryEntry } from "./floor_registry";
floorCompare,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem { export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area"; type: "floor" | "area";
@@ -182,68 +179,59 @@ export const getAreasAndFloors = (
); );
} }
const floorAreaLookup = getFloorAreaLookup(outputAreas); const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
const unassignedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
const compare = floorCompare(haFloors);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
const items: FloorComboBoxItem[] = []; const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => { hierarchy.floors.forEach((f) => {
if (floor) { const floor = haFloors[f.id];
const floorName = computeFloorName(floor); const floorAreas = f.areas.map((areaId) => haAreas[areaId]);
const areaSearchLabels = floorAreas const floorName = computeFloorName(floor);
.map((area) => {
const areaName = computeAreaName(area) || area.area_id; const areaSearchLabels = floorAreas
return [area.area_id, areaName, ...area.aliases]; .map((area) => {
}) const areaName = computeAreaName(area);
.flat(); return [area.area_id, ...(areaName ? [areaName] : []), ...area.aliases];
})
.flat();
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
items.push({
id: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
items.push( items.push(
...floorAreas.map((area) => { ...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id; const areaName = computeAreaName(area);
return { return {
id: formatId({ id: area.area_id, type: "area" }), id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const, type: "area" as const,
primary: areaName, primary: areaName || area.area_id,
area: area, area: area,
icon: area.icon || undefined, icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases], search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
}; };
}) })
); );
}); });
items.push( items.push(
...unassignedAreas.map((area) => { ...hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id; const areaName = computeAreaName(area) || area.area_id;
return { return {
id: formatId({ id: area.area_id, type: "area" }), id: formatId({ id: area.area_id, type: "area" }),

View File

@@ -59,6 +59,15 @@ export const deleteAreaRegistryEntry = (hass: HomeAssistant, areaId: string) =>
area_id: areaId, area_id: areaId,
}); });
export const reorderAreaRegistryEntries = (
hass: HomeAssistant,
areaIds: string[]
) =>
hass.callWS({
type: "config/area_registry/reorder",
area_ids: areaIds,
});
export const getAreaEntityLookup = ( export const getAreaEntityLookup = (
entities: EntityRegistryEntry[] entities: EntityRegistryEntry[]
): AreaEntityLookup => { ): AreaEntityLookup => {

View File

@@ -214,6 +214,8 @@ export interface PipelineRun {
stage: "ready" | "wake_word" | "stt" | "intent" | "tts" | "done" | "error"; stage: "ready" | "wake_word" | "stt" | "intent" | "tts" | "done" | "error";
run: PipelineRunStartEvent["data"]; run: PipelineRunStartEvent["data"];
error?: PipelineErrorEvent["data"]; error?: PipelineErrorEvent["data"];
started: Date;
finished?: Date;
wake_word?: PipelineWakeWordStartEvent["data"] & wake_word?: PipelineWakeWordStartEvent["data"] &
Partial<PipelineWakeWordEndEvent["data"]> & { done: boolean }; Partial<PipelineWakeWordEndEvent["data"]> & { done: boolean };
stt?: PipelineSTTStartEvent["data"] & stt?: PipelineSTTStartEvent["data"] &
@@ -235,6 +237,7 @@ export const processEvent = (
stage: "ready", stage: "ready",
run: event.data, run: event.data,
events: [event], events: [event],
started: new Date(event.timestamp),
}; };
return run; return run;
} }
@@ -290,9 +293,14 @@ export const processEvent = (
tts: { ...run.tts!, ...event.data, done: true }, tts: { ...run.tts!, ...event.data, done: true },
}; };
} else if (event.type === "run-end") { } else if (event.type === "run-end") {
run = { ...run, stage: "done" }; run = { ...run, finished: new Date(event.timestamp), stage: "done" };
} else if (event.type === "error") { } else if (event.type === "error") {
run = { ...run, stage: "error", error: event.data }; run = {
...run,
finished: new Date(event.timestamp),
stage: "error",
error: event.data,
};
} else { } else {
run = { ...run }; run = { ...run };
} }

View File

@@ -1,21 +1,33 @@
import type { import type {
HassEntityAttributeBase, HassEntityAttributeBase,
HassEntityBase, HassEntityBase,
HassServiceTarget,
} from "home-assistant-js-websocket"; } from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array"; import { ensureArray } from "../common/array/ensure-array";
import type { WeekdayShort } from "../common/datetime/weekday";
import { navigate } from "../common/navigate"; import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize"; import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params"; import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types"; import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint"; import type { BlueprintInput } from "./blueprint";
import type { ConditionDescription } from "./condition";
import { CONDITION_BUILDING_BLOCKS } from "./condition"; import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script"; import type { Action, Field, MODES } from "./script";
import { migrateAutomationAction } from "./script"; import { migrateAutomationAction } from "./script";
import type { TriggerDescription } from "./trigger";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single"; export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10; export const AUTOMATION_DEFAULT_MAX = 10;
export const DYNAMIC_PREFIX = "__DYNAMIC__";
export const isDynamic = (key: string | undefined): boolean | undefined =>
key?.startsWith(DYNAMIC_PREFIX);
export const getValueFromDynamic = (key: string): string =>
key.substring(DYNAMIC_PREFIX.length);
export interface AutomationEntity extends HassEntityBase { export interface AutomationEntity extends HassEntityBase {
attributes: HassEntityAttributeBase & { attributes: HassEntityAttributeBase & {
id?: string; id?: string;
@@ -85,6 +97,12 @@ export interface BaseTrigger {
id?: string; id?: string;
variables?: Record<string, unknown>; variables?: Record<string, unknown>;
enabled?: boolean; enabled?: boolean;
options?: Record<string, unknown>;
}
export interface PlatformTrigger extends BaseTrigger {
trigger: Exclude<string, LegacyTrigger["trigger"]>;
target?: HassServiceTarget;
} }
export interface StateTrigger extends BaseTrigger { export interface StateTrigger extends BaseTrigger {
@@ -194,7 +212,7 @@ export interface CalendarTrigger extends BaseTrigger {
offset: string; offset: string;
} }
export type Trigger = export type LegacyTrigger =
| StateTrigger | StateTrigger
| MqttTrigger | MqttTrigger
| GeoLocationTrigger | GeoLocationTrigger
@@ -211,13 +229,20 @@ export type Trigger =
| TemplateTrigger | TemplateTrigger
| EventTrigger | EventTrigger
| DeviceTrigger | DeviceTrigger
| CalendarTrigger | CalendarTrigger;
| TriggerList;
export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
interface BaseCondition { interface BaseCondition {
condition: string; condition: string;
alias?: string; alias?: string;
enabled?: boolean; enabled?: boolean;
options?: Record<string, unknown>;
}
export interface PlatformCondition extends BaseCondition {
condition: Exclude<string, LegacyCondition["condition"]>;
target?: HassServiceTarget;
} }
export interface LogicalCondition extends BaseCondition { export interface LogicalCondition extends BaseCondition {
@@ -257,13 +282,11 @@ export interface ZoneCondition extends BaseCondition {
zone: string; zone: string;
} }
type Weekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat";
export interface TimeCondition extends BaseCondition { export interface TimeCondition extends BaseCondition {
condition: "time"; condition: "time";
after?: string; after?: string;
before?: string; before?: string;
weekday?: Weekday | Weekday[]; weekday?: WeekdayShort | WeekdayShort[];
} }
export interface TemplateCondition extends BaseCondition { export interface TemplateCondition extends BaseCondition {
@@ -304,7 +327,7 @@ export type AutomationElementGroup = Record<
{ icon?: string; members?: AutomationElementGroup } { icon?: string; members?: AutomationElementGroup }
>; >;
export type Condition = export type LegacyCondition =
| StateCondition | StateCondition
| NumericStateCondition | NumericStateCondition
| SunCondition | SunCondition
@@ -315,6 +338,8 @@ export type Condition =
| LogicalCondition | LogicalCondition
| TriggerCondition; | TriggerCondition;
export type Condition = LegacyCondition | PlatformCondition;
export type ConditionWithShorthand = export type ConditionWithShorthand =
| Condition | Condition
| ShorthandAndConditionList | ShorthandAndConditionList
@@ -576,6 +601,7 @@ export interface TriggerSidebarConfig extends BaseSidebarConfig {
insertAfter: (value: Trigger | Trigger[]) => boolean; insertAfter: (value: Trigger | Trigger[]) => boolean;
toggleYamlMode: () => void; toggleYamlMode: () => void;
config: Trigger; config: Trigger;
description?: TriggerDescription;
yamlMode: boolean; yamlMode: boolean;
uiSupported: boolean; uiSupported: boolean;
} }
@@ -591,6 +617,7 @@ export interface ConditionSidebarConfig extends BaseSidebarConfig {
insertAfter: (value: Condition | Condition[]) => boolean; insertAfter: (value: Condition | Condition[]) => boolean;
toggleYamlMode: () => void; toggleYamlMode: () => void;
config: Condition; config: Condition;
description?: ConditionDescription;
yamlMode: boolean; yamlMode: boolean;
uiSupported: boolean; uiSupported: boolean;
} }

View File

@@ -16,8 +16,16 @@ import {
formatListWithAnds, formatListWithAnds,
formatListWithOrs, formatListWithOrs,
} from "../common/string/format-list"; } from "../common/string/format-list";
import { hasTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { Condition, ForDict, Trigger } from "./automation"; import type {
Condition,
ForDict,
LegacyCondition,
LegacyTrigger,
Trigger,
} from "./automation";
import { getConditionDomain, getConditionObjectId } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation"; import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import { import {
localizeDeviceAutomationCondition, localizeDeviceAutomationCondition,
@@ -25,8 +33,7 @@ import {
} from "./device_automation"; } from "./device_automation";
import type { EntityRegistryEntry } from "./entity_registry"; import type { EntityRegistryEntry } from "./entity_registry";
import type { FrontendLocaleData } from "./translation"; import type { FrontendLocaleData } from "./translation";
import { isTriggerList } from "./trigger"; import { getTriggerDomain, getTriggerObjectId, isTriggerList } from "./trigger";
import { hasTemplate } from "../common/string/has-template";
const triggerTranslationBaseKey = const triggerTranslationBaseKey =
"ui.panel.config.automation.editor.triggers.type"; "ui.panel.config.automation.editor.triggers.type";
@@ -121,6 +128,37 @@ const tryDescribeTrigger = (
return trigger.alias; return trigger.alias;
} }
const description = describeLegacyTrigger(
trigger as LegacyTrigger,
hass,
entityRegistry
);
if (description) {
return description;
}
const triggerType = trigger.trigger;
const domain = getTriggerDomain(trigger.trigger);
const type = getTriggerObjectId(trigger.trigger);
return (
hass.localize(
`component.${domain}.triggers.${type}.description_configured`
) ||
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${triggerType as LegacyTrigger["trigger"]}.label`
) ||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
);
};
const describeLegacyTrigger = (
trigger: LegacyTrigger,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
) => {
// Event Trigger // Event Trigger
if (trigger.trigger === "event" && trigger.event_type) { if (trigger.trigger === "event" && trigger.event_type) {
const eventTypes: string[] = []; const eventTypes: string[] = [];
@@ -802,13 +840,7 @@ const tryDescribeTrigger = (
} }
); );
} }
return undefined;
return (
hass.localize(
`ui.panel.config.automation.editor.triggers.type.${trigger.trigger}.label`
) ||
hass.localize(`ui.panel.config.automation.editor.triggers.unknown_trigger`)
);
}; };
export const describeCondition = ( export const describeCondition = (
@@ -871,6 +903,39 @@ const tryDescribeCondition = (
} }
} }
const description = describeLegacyCondition(
condition as LegacyCondition,
hass,
entityRegistry
);
if (description) {
return description;
}
const conditionType = condition.condition;
const domain = getConditionDomain(condition.condition);
const type = getConditionObjectId(condition.condition);
return (
hass.localize(
`component.${domain}.conditions.${type}.description_configured`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.type.${conditionType as LegacyCondition["condition"]}.label`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.unknown_condition`
)
);
};
const describeLegacyCondition = (
condition: LegacyCondition,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
) => {
if (condition.condition === "or") { if (condition.condition === "or") {
const conditions = ensureArray(condition.conditions); const conditions = ensureArray(condition.conditions);
@@ -1262,12 +1327,5 @@ const tryDescribeCondition = (
); );
} }
return ( return undefined;
hass.localize(
`ui.panel.config.automation.editor.conditions.type.${condition.condition}.label`
) ||
hass.localize(
`ui.panel.config.automation.editor.conditions.unknown_condition`
)
);
}; };

View File

@@ -10,6 +10,7 @@ import {
import { formatTime } from "../common/datetime/format_time"; import { formatTime } from "../common/datetime/format_time";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { fileDownload } from "../util/file_download"; import { fileDownload } from "../util/file_download";
import { handleFetchPromise } from "../util/hass-call-api"; import { handleFetchPromise } from "../util/hass-call-api";
import type { BackupManagerState, ManagerStateEvent } from "./backup_manager"; import type { BackupManagerState, ManagerStateEvent } from "./backup_manager";
@@ -414,7 +415,7 @@ ${hass.auth.data.hassUrl}
${hass.localize("ui.panel.config.backup.emergency_kit_file.encryption_key")} ${hass.localize("ui.panel.config.backup.emergency_kit_file.encryption_key")}
${encryptionKey} ${encryptionKey}
${hass.localize("ui.panel.config.backup.emergency_kit_file.more_info", { link: "https://www.home-assistant.io/more-info/backup-emergency-kit" })}`); ${hass.localize("ui.panel.config.backup.emergency_kit_file.more_info", { link: documentationUrl(hass, "/more-info/backup-emergency-kit") })}`);
export const geneateEmergencyKitFileName = ( export const geneateEmergencyKitFileName = (
hass: HomeAssistant, hass: HomeAssistant,

View File

@@ -31,6 +31,7 @@ export interface CalendarEventData {
dtend: string; dtend: string;
rrule?: string; rrule?: string;
description?: string; description?: string;
location?: string;
} }
export interface CalendarEventMutableParams { export interface CalendarEventMutableParams {
@@ -39,6 +40,7 @@ export interface CalendarEventMutableParams {
dtend: string; dtend: string;
rrule?: string; rrule?: string;
description?: string; description?: string;
location?: string;
} }
// The scope of a delete/update for a recurring event // The scope of a delete/update for a recurring event
@@ -96,6 +98,7 @@ export const fetchCalendarEvents = async (
uid: ev.uid, uid: ev.uid,
summary: ev.summary, summary: ev.summary,
description: ev.description, description: ev.description,
location: ev.location,
dtstart: eventStart, dtstart: eventStart,
dtend: eventEnd, dtend: eventEnd,
recurrence_id: ev.recurrence_id, recurrence_id: ev.recurrence_id,

228
src/data/chat_log.ts Normal file
View File

@@ -0,0 +1,228 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
export const enum ChatLogEventType {
INITIAL_STATE = "initial_state",
CREATED = "created",
UPDATED = "updated",
DELETED = "deleted",
CONTENT_ADDED = "content_added",
}
export interface ChatLogAttachment {
media_content_id: string;
mime_type: string;
path: string;
}
export interface ChatLogSystemContent {
role: "system";
content: string;
created: Date;
}
export interface ChatLogUserContent {
role: "user";
content: string;
created: Date;
attachments?: ChatLogAttachment[];
}
export interface ChatLogAssistantContent {
role: "assistant";
agent_id: string;
created: Date;
content?: string;
thinking_content?: string;
tool_calls?: any[];
}
export interface ChatLogToolResultContent {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: any;
created: Date;
}
export type ChatLogContent =
| ChatLogSystemContent
| ChatLogUserContent
| ChatLogAssistantContent
| ChatLogToolResultContent;
export interface ChatLog {
conversation_id: string;
continue_conversation: boolean;
content: ChatLogContent[];
created: Date;
}
// Internal wire format types (not exported)
interface ChatLogSystemContentWire {
role: "system";
content: string;
created: string;
}
interface ChatLogUserContentWire {
role: "user";
content: string;
created: string;
attachments?: ChatLogAttachment[];
}
interface ChatLogAssistantContentWire {
role: "assistant";
agent_id: string;
created: string;
content?: string;
thinking_content?: string;
tool_calls?: {
tool_name: string;
tool_args: Record<string, any>;
id: string;
external: boolean;
}[];
}
interface ChatLogToolResultContentWire {
role: "tool_result";
agent_id: string;
tool_call_id: string;
tool_name: string;
tool_result: any;
created: string;
}
type ChatLogContentWire =
| ChatLogSystemContentWire
| ChatLogUserContentWire
| ChatLogAssistantContentWire
| ChatLogToolResultContentWire;
interface ChatLogWire {
conversation_id: string;
continue_conversation: boolean;
content: ChatLogContentWire[];
created: string;
}
const processContent = (content: ChatLogContentWire): ChatLogContent => ({
...content,
created: new Date(content.created),
});
const processChatLog = (chatLog: ChatLogWire): ChatLog => ({
...chatLog,
created: new Date(chatLog.created),
content: chatLog.content.map(processContent),
});
interface ChatLogInitialStateEvent {
event_type: ChatLogEventType.INITIAL_STATE;
data: ChatLogWire;
}
interface ChatLogIndexInitialStateEvent {
event_type: ChatLogEventType.INITIAL_STATE;
data: ChatLogWire[];
}
interface ChatLogCreatedEvent {
conversation_id: string;
event_type: ChatLogEventType.CREATED;
data: ChatLogWire;
}
interface ChatLogUpdatedEvent {
conversation_id: string;
event_type: ChatLogEventType.UPDATED;
data: { chat_log: ChatLogWire };
}
interface ChatLogDeletedEvent {
conversation_id: string;
event_type: ChatLogEventType.DELETED;
data: ChatLogWire;
}
interface ChatLogContentAddedEvent {
conversation_id: string;
event_type: ChatLogEventType.CONTENT_ADDED;
data: { content: ChatLogContentWire };
}
type ChatLogSubscriptionEvent =
| ChatLogInitialStateEvent
| ChatLogUpdatedEvent
| ChatLogDeletedEvent
| ChatLogContentAddedEvent;
type ChatLogIndexSubscriptionEvent =
| ChatLogIndexInitialStateEvent
| ChatLogCreatedEvent
| ChatLogDeletedEvent;
export const subscribeChatLog = (
hass: HomeAssistant,
conversationId: string,
callback: (chatLog: ChatLog | null) => void
): Promise<UnsubscribeFunc> => {
let chatLog: ChatLog | null = null;
return hass.connection.subscribeMessage<ChatLogSubscriptionEvent>(
(event) => {
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
chatLog = processChatLog(event.data);
callback(chatLog);
} else if (event.event_type === ChatLogEventType.CONTENT_ADDED) {
if (chatLog) {
chatLog = {
...chatLog,
content: [...chatLog.content, processContent(event.data.content)],
};
callback(chatLog);
}
} else if (event.event_type === ChatLogEventType.UPDATED) {
chatLog = processChatLog(event.data.chat_log);
callback(chatLog);
} else if (event.event_type === ChatLogEventType.DELETED) {
chatLog = null;
callback(null);
}
},
{
type: "conversation/chat_log/subscribe",
conversation_id: conversationId,
}
);
};
export const subscribeChatLogIndex = (
hass: HomeAssistant,
callback: (chatLogs: ChatLog[]) => void
): Promise<UnsubscribeFunc> => {
let chatLogs: ChatLog[] = [];
return hass.connection.subscribeMessage<ChatLogIndexSubscriptionEvent>(
(event) => {
if (event.event_type === ChatLogEventType.INITIAL_STATE) {
chatLogs = event.data.map(processChatLog);
callback(chatLogs);
} else if (event.event_type === ChatLogEventType.CREATED) {
chatLogs = [...chatLogs, processChatLog(event.data)];
callback(chatLogs);
} else if (event.event_type === ChatLogEventType.DELETED) {
chatLogs = chatLogs.filter(
(chatLog) => chatLog.conversation_id !== event.conversation_id
);
callback(chatLogs);
}
},
{
type: "conversation/chat_log/subscribe_index",
}
);
};

View File

@@ -1,38 +1,15 @@
import { import { mdiMapClock, mdiShape } from "@mdi/js";
mdiAmpersand, import { computeDomain } from "../common/entity/compute_domain";
mdiClockOutline, import { computeObjectId } from "../common/entity/compute_object_id";
mdiCodeBraces, import type { HomeAssistant } from "../types";
mdiDevices,
mdiGateOr,
mdiIdentifier,
mdiMapClock,
mdiMapMarkerRadius,
mdiNotEqualVariant,
mdiNumeric,
mdiShape,
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import type { AutomationElementGroupCollection } from "./automation"; import type { AutomationElementGroupCollection } from "./automation";
import type { Selector, TargetSelector } from "./selector";
export const CONDITION_ICONS = {
device: mdiDevices,
and: mdiAmpersand,
or: mdiGateOr,
not: mdiNotEqualVariant,
state: mdiStateMachine,
numeric_state: mdiNumeric,
sun: mdiWeatherSunny,
template: mdiCodeBraces,
time: mdiClockOutline,
trigger: mdiIdentifier,
zone: mdiMapMarkerRadius,
};
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [ export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{ {
groups: { groups: {
device: {}, device: {},
dynamicGroups: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } }, entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: { time_location: {
icon: mdiMapClock, icon: mdiMapClock,
@@ -62,3 +39,33 @@ export const COLLAPSIBLE_CONDITION_ELEMENTS = [
"ha-automation-condition-not", "ha-automation-condition-not",
"ha-automation-condition-or", "ha-automation-condition-or",
]; ];
export interface ConditionDescription {
target?: TargetSelector["target"];
fields: Record<
string,
{
example?: string | boolean | number;
default?: unknown;
required?: boolean;
selector?: Selector;
context?: Record<string, string>;
}
>;
}
export type ConditionDescriptions = Record<string, ConditionDescription>;
export const subscribeConditions = (
hass: HomeAssistant,
callback: (conditions: ConditionDescriptions) => void
) =>
hass.connection.subscribeMessage<ConditionDescriptions>(callback, {
type: "condition_platforms/subscribe",
});
export const getConditionDomain = (condition: string) =>
condition.includes(".") ? computeDomain(condition) : condition;
export const getConditionObjectId = (condition: string) =>
condition.includes(".") ? computeObjectId(condition) : "_";

View File

@@ -186,7 +186,8 @@ export const getDevices = (
deviceFilter?: HaDevicePickerDeviceFilterFunc, deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc, entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[], excludeDevices?: string[],
value?: string value?: string,
idPrefix = ""
): DevicePickerItem[] => { ): DevicePickerItem[] => {
const devices = Object.values(hass.devices); const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities); const entities = Object.values(hass.entities);
@@ -298,7 +299,7 @@ export const getDevices = (
const domainName = domain ? domainToName(hass.localize, domain) : undefined; const domainName = domain ? domainToName(hass.localize, domain) : undefined;
return { return {
id: device.id, id: `${idPrefix}${device.id}`,
label: "", label: "",
primary: primary:
deviceName || deviceName ||

View File

@@ -102,6 +102,7 @@ export type EnergySolarForecasts = Record<string, EnergySolarForecast>;
export interface DeviceConsumptionEnergyPreference { export interface DeviceConsumptionEnergyPreference {
// This is an ever increasing value // This is an ever increasing value
stat_consumption: string; stat_consumption: string;
stat_rate?: string;
name?: string; name?: string;
included_in_stat?: string; included_in_stat?: string;
} }
@@ -130,11 +131,17 @@ export interface FlowToGridSourceEnergyPreference {
number_energy_price: number | null; number_energy_price: number | null;
} }
export interface GridPowerSourceEnergyPreference {
// W meter
stat_rate: string;
}
export interface GridSourceTypeEnergyPreference { export interface GridSourceTypeEnergyPreference {
type: "grid"; type: "grid";
flow_from: FlowFromGridSourceEnergyPreference[]; flow_from: FlowFromGridSourceEnergyPreference[];
flow_to: FlowToGridSourceEnergyPreference[]; flow_to: FlowToGridSourceEnergyPreference[];
power?: GridPowerSourceEnergyPreference[];
cost_adjustment_day: number; cost_adjustment_day: number;
} }
@@ -143,6 +150,7 @@ export interface SolarSourceTypeEnergyPreference {
type: "solar"; type: "solar";
stat_energy_from: string; stat_energy_from: string;
stat_rate?: string;
config_entry_solar_forecast: string[] | null; config_entry_solar_forecast: string[] | null;
} }
@@ -150,6 +158,7 @@ export interface BatterySourceTypeEnergyPreference {
type: "battery"; type: "battery";
stat_energy_from: string; stat_energy_from: string;
stat_energy_to: string; stat_energy_to: string;
stat_rate?: string;
} }
export interface GasSourceTypeEnergyPreference { export interface GasSourceTypeEnergyPreference {
type: "gas"; type: "gas";
@@ -351,6 +360,35 @@ export const getReferencedStatisticIds = (
return statIDs; return statIDs;
}; };
export const getReferencedStatisticIdsPower = (
prefs: EnergyPreferences
): string[] => {
const statIDs: (string | undefined)[] = [];
for (const source of prefs.energy_sources) {
if (source.type === "gas" || source.type === "water") {
continue;
}
if (source.type === "solar") {
statIDs.push(source.stat_rate);
continue;
}
if (source.type === "battery") {
statIDs.push(source.stat_rate);
continue;
}
if (source.power) {
statIDs.push(...source.power.map((p) => p.stat_rate));
}
}
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
return statIDs.filter(Boolean) as string[];
};
export const enum CompareMode { export const enum CompareMode {
NONE = "", NONE = "",
PREVIOUS = "previous", PREVIOUS = "previous",
@@ -398,9 +436,10 @@ const getEnergyData = async (
"gas", "gas",
"device", "device",
]); ]);
const powerStatIds = getReferencedStatisticIdsPower(prefs);
const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]); const waterStatIds = getReferencedStatisticIds(prefs, info, ["water"]);
const allStatIDs = [...energyStatIds, ...waterStatIds]; const allStatIDs = [...energyStatIds, ...waterStatIds, ...powerStatIds];
const dayDifference = differenceInDays(end || new Date(), start); const dayDifference = differenceInDays(end || new Date(), start);
const period = const period =
@@ -411,6 +450,8 @@ const getEnergyData = async (
: dayDifference > 2 : dayDifference > 2
? "day" ? "day"
: "hour"; : "hour";
const finePeriod =
dayDifference > 64 ? "day" : dayDifference > 8 ? "hour" : "5minute";
const statsMetadata: Record<string, StatisticsMetaData> = {}; const statsMetadata: Record<string, StatisticsMetaData> = {};
const statsMetadataArray = allStatIDs.length const statsMetadataArray = allStatIDs.length
@@ -432,6 +473,9 @@ const getEnergyData = async (
? (gasUnit as (typeof VOLUME_UNITS)[number]) ? (gasUnit as (typeof VOLUME_UNITS)[number])
: undefined, : undefined,
}; };
const powerUnits: StatisticsUnitConfiguration = {
power: "kW",
};
const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata); const waterUnit = getEnergyWaterUnit(hass, prefs, statsMetadata);
const waterUnits: StatisticsUnitConfiguration = { const waterUnits: StatisticsUnitConfiguration = {
volume: waterUnit, volume: waterUnit,
@@ -442,6 +486,12 @@ const getEnergyData = async (
"change", "change",
]) ])
: {}; : {};
const _powerStats: Statistics | Promise<Statistics> = powerStatIds.length
? fetchStatistics(hass!, start, end, powerStatIds, finePeriod, powerUnits, [
"mean",
])
: {};
const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length const _waterStats: Statistics | Promise<Statistics> = waterStatIds.length
? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [ ? fetchStatistics(hass!, start, end, waterStatIds, period, waterUnits, [
"change", "change",
@@ -548,6 +598,7 @@ const getEnergyData = async (
const [ const [
energyStats, energyStats,
powerStats,
waterStats, waterStats,
energyStatsCompare, energyStatsCompare,
waterStatsCompare, waterStatsCompare,
@@ -555,13 +606,14 @@ const getEnergyData = async (
fossilEnergyConsumptionCompare, fossilEnergyConsumptionCompare,
] = await Promise.all([ ] = await Promise.all([
_energyStats, _energyStats,
_powerStats,
_waterStats, _waterStats,
_energyStatsCompare, _energyStatsCompare,
_waterStatsCompare, _waterStatsCompare,
_fossilEnergyConsumption, _fossilEnergyConsumption,
_fossilEnergyConsumptionCompare, _fossilEnergyConsumptionCompare,
]); ]);
const stats = { ...energyStats, ...waterStats }; const stats = { ...energyStats, ...waterStats, ...powerStats };
if (compare) { if (compare) {
statsCompare = { ...energyStatsCompare, ...waterStatsCompare }; statsCompare = { ...energyStatsCompare, ...waterStatsCompare };
} }
@@ -723,6 +775,7 @@ export const getEnergyDataCollection = (
hass.locale, hass.locale,
hass.config hass.config
); );
collection.refresh();
scheduleUpdatePeriod(); scheduleUpdatePeriod();
}, },
addHours( addHours(

View File

@@ -344,7 +344,8 @@ export const getEntities = (
includeUnitOfMeasurement?: string[], includeUnitOfMeasurement?: string[],
includeEntities?: string[], includeEntities?: string[],
excludeEntities?: string[], excludeEntities?: string[],
value?: string value?: string,
idPrefix = ""
): EntityComboBoxItem[] => { ): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = []; let items: EntityComboBoxItem[] = [];
@@ -395,10 +396,9 @@ export const getEntities = (
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ "); .join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return { return {
id: entityId, id: `${idPrefix}${entityId}`,
primary: primary, primary: primary,
secondary: secondary, secondary: secondary,
domain_name: domainName, domain_name: domainName,
@@ -411,7 +411,6 @@ export const getEntities = (
friendlyName, friendlyName,
entityId, entityId,
].filter(Boolean) as string[], ].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj, stateObj: stateObj,
}; };
}); });

View File

@@ -51,6 +51,15 @@ export const deleteFloorRegistryEntry = (
floor_id: floorId, floor_id: floorId,
}); });
export const reorderFloorRegistryEntries = (
hass: HomeAssistant,
floorIds: string[]
) =>
hass.callWS({
type: "config/floor_registry/reorder",
floor_ids: floorIds,
});
export const getFloorAreaLookup = ( export const getFloorAreaLookup = (
areas: AreaRegistryEntry[] areas: AreaRegistryEntry[]
): FloorAreaLookup => { ): FloorAreaLookup => {

View File

@@ -3,11 +3,20 @@ import type { Connection } from "home-assistant-js-websocket";
export interface CoreFrontendUserData { export interface CoreFrontendUserData {
showAdvanced?: boolean; showAdvanced?: boolean;
showEntityIdPicker?: boolean; showEntityIdPicker?: boolean;
default_panel?: string;
} }
export interface SidebarFrontendUserData { export interface SidebarFrontendUserData {
panelOrder: string[]; panelOrder?: string[];
hiddenPanels: string[]; hiddenPanels?: string[];
}
export interface CoreFrontendSystemData {
default_panel?: string;
}
export interface HomeFrontendSystemData {
favorite_entities?: string[];
} }
declare global { declare global {
@@ -15,10 +24,16 @@ declare global {
core: CoreFrontendUserData; core: CoreFrontendUserData;
sidebar: SidebarFrontendUserData; sidebar: SidebarFrontendUserData;
} }
interface FrontendSystemData {
core: CoreFrontendSystemData;
home: HomeFrontendSystemData;
}
} }
export type ValidUserDataKey = keyof FrontendUserData; export type ValidUserDataKey = keyof FrontendUserData;
export type ValidSystemDataKey = keyof FrontendSystemData;
export const fetchFrontendUserData = async < export const fetchFrontendUserData = async <
UserDataKey extends ValidUserDataKey, UserDataKey extends ValidUserDataKey,
>( >(
@@ -59,3 +74,46 @@ export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
key: userDataKey, key: userDataKey,
} }
); );
export const fetchFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey
): Promise<FrontendSystemData[SystemDataKey] | null> => {
const result = await conn.sendMessagePromise<{
value: FrontendSystemData[SystemDataKey] | null;
}>({
type: "frontend/get_system_data",
key,
});
return result.value;
};
export const saveFrontendSystemData = async <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
key: SystemDataKey,
value: FrontendSystemData[SystemDataKey]
): Promise<void> =>
conn.sendMessagePromise<undefined>({
type: "frontend/set_system_data",
key,
value,
});
export const subscribeFrontendSystemData = <
SystemDataKey extends ValidSystemDataKey,
>(
conn: Connection,
systemDataKey: SystemDataKey,
onChange: (data: { value: FrontendSystemData[SystemDataKey] | null }) => void
) =>
conn.subscribeMessage<{ value: FrontendSystemData[SystemDataKey] | null }>(
onChange,
{
type: "frontend/subscribe_system_data",
key: systemDataKey,
}
);

View File

@@ -59,6 +59,8 @@ import type {
} from "./entity_registry"; } from "./entity_registry";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg"; import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import { getTriggerDomain, getTriggerObjectId } from "./trigger";
import { getConditionDomain, getConditionObjectId } from "./condition";
/** Icon to use when no icon specified for service. */ /** Icon to use when no icon specified for service. */
export const DEFAULT_SERVICE_ICON = mdiRoomService; export const DEFAULT_SERVICE_ICON = mdiRoomService;
@@ -133,14 +135,29 @@ const resources: {
all?: Promise<Record<string, ServiceIcons>>; all?: Promise<Record<string, ServiceIcons>>;
domains: Record<string, ServiceIcons | Promise<ServiceIcons>>; domains: Record<string, ServiceIcons | Promise<ServiceIcons>>;
}; };
triggers: {
all?: Promise<Record<string, TriggerIcons>>;
domains: Record<string, TriggerIcons | Promise<TriggerIcons>>;
};
conditions: {
all?: Promise<Record<string, ConditionIcons>>;
domains: Record<string, ConditionIcons | Promise<ConditionIcons>>;
};
} = { } = {
entity: {}, entity: {},
entity_component: {}, entity_component: {},
services: { domains: {} }, services: { domains: {} },
triggers: { domains: {} },
conditions: { domains: {} },
}; };
interface IconResources< interface IconResources<
T extends ComponentIcons | PlatformIcons | ServiceIcons, T extends
| ComponentIcons
| PlatformIcons
| ServiceIcons
| TriggerIcons
| ConditionIcons,
> { > {
resources: Record<string, T>; resources: Record<string, T>;
} }
@@ -184,12 +201,29 @@ type ServiceIcons = Record<
{ service: string; sections?: Record<string, string> } { service: string; sections?: Record<string, string> }
>; >;
export type IconCategory = "entity" | "entity_component" | "services"; type TriggerIcons = Record<
string,
{ trigger: string; sections?: Record<string, string> }
>;
type ConditionIcons = Record<
string,
{ condition: string; sections?: Record<string, string> }
>;
export type IconCategory =
| "entity"
| "entity_component"
| "services"
| "triggers"
| "conditions";
interface CategoryType { interface CategoryType {
entity: PlatformIcons; entity: PlatformIcons;
entity_component: ComponentIcons; entity_component: ComponentIcons;
services: ServiceIcons; services: ServiceIcons;
triggers: TriggerIcons;
conditions: ConditionIcons;
} }
export const getHassIcons = async <T extends IconCategory>( export const getHassIcons = async <T extends IconCategory>(
@@ -258,42 +292,66 @@ export const getComponentIcons = async (
return resources.entity_component.resources.then((res) => res[domain]); return resources.entity_component.resources.then((res) => res[domain]);
}; };
export const getServiceIcons = async ( export const getCategoryIcons = async <
T extends Exclude<IconCategory, "entity" | "entity_component">,
>(
hass: HomeAssistant, hass: HomeAssistant,
category: T,
domain?: string, domain?: string,
force = false force = false
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> => { ): Promise<CategoryType[T] | Record<string, CategoryType[T]> | undefined> => {
if (!domain) { if (!domain) {
if (!force && resources.services.all) { if (!force && resources[category].all) {
return resources.services.all; return resources[category].all as Promise<
Record<string, CategoryType[T]>
>;
} }
resources.services.all = getHassIcons(hass, "services", domain).then( resources[category].all = getHassIcons(hass, category).then((res) => {
(res) => { resources[category].domains = res.resources as any;
resources.services.domains = res.resources; return res?.resources as Record<string, CategoryType[T]>;
return res?.resources; }) as any;
} return resources[category].all as Promise<Record<string, CategoryType[T]>>;
);
return resources.services.all;
} }
if (!force && domain in resources.services.domains) { if (!force && domain in resources[category].domains) {
return resources.services.domains[domain]; return resources[category].domains[domain] as Promise<CategoryType[T]>;
} }
if (resources.services.all && !force) { if (resources[category].all && !force) {
await resources.services.all; await resources[category].all;
if (domain in resources.services.domains) { if (domain in resources[category].domains) {
return resources.services.domains[domain]; return resources[category].domains[domain] as Promise<CategoryType[T]>;
} }
} }
if (!isComponentLoaded(hass, domain)) { if (!isComponentLoaded(hass, domain)) {
return undefined; return undefined;
} }
const result = getHassIcons(hass, "services", domain); const result = getHassIcons(hass, category, domain);
resources.services.domains[domain] = result.then( resources[category].domains[domain] = result.then(
(res) => res?.resources[domain] (res) => res?.resources[domain]
); ) as any;
return resources.services.domains[domain]; return resources[category].domains[domain] as Promise<CategoryType[T]>;
}; };
export const getServiceIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> =>
getCategoryIcons(hass, "services", domain, force);
export const getTriggerIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
getCategoryIcons(hass, "triggers", domain, force);
export const getConditionIcons = async (
hass: HomeAssistant,
domain?: string,
force = false
): Promise<ConditionIcons | Record<string, ConditionIcons> | undefined> =>
getCategoryIcons(hass, "conditions", domain, force);
// Cache for sorted range keys // Cache for sorted range keys
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>(); const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
@@ -473,6 +531,45 @@ export const attributeIcon = async (
return icon; return icon;
}; };
export const triggerIcon = async (
hass: HomeAssistant,
trigger: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = getTriggerDomain(trigger);
const triggerName = getTriggerObjectId(trigger);
const triggerIcons = await getTriggerIcons(hass, domain);
if (triggerIcons) {
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
icon = trgrIcon?.trigger;
}
if (!icon) {
icon = await domainIcon(hass, domain);
}
return icon;
};
export const conditionIcon = async (
hass: HomeAssistant,
condition: string
): Promise<string | undefined> => {
let icon: string | undefined;
const domain = getConditionDomain(condition);
const conditionIcons = await getConditionIcons(hass, domain);
if (conditionIcons) {
const conditionName = getConditionObjectId(condition);
const condIcon = conditionIcons[conditionName] as ConditionIcons[string];
icon = condIcon?.condition;
}
if (!icon) {
icon = await domainIcon(hass, domain);
}
return icon;
};
export const serviceIcon = async ( export const serviceIcon = async (
hass: HomeAssistant, hass: HomeAssistant,
service: string service: string

View File

@@ -108,7 +108,8 @@ export const getLabels = (
includeDeviceClasses?: string[], includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc, deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc, entityFilter?: HaEntityPickerEntityFilterFunc,
excludeLabels?: string[] excludeLabels?: string[],
idPrefix = ""
): PickerComboBoxItem[] => { ): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) { if (!labels || labels.length === 0) {
return []; return [];
@@ -262,7 +263,7 @@ export const getLabels = (
} }
const items = outputLabels.map<PickerComboBoxItem>((label) => ({ const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id, id: `${idPrefix}${label.label_id}`,
primary: label.name, primary: label.name,
secondary: label.description ?? "", secondary: label.description ?? "",
icon: label.icon || undefined, icon: label.icon || undefined,

78
src/data/labs.ts Normal file
View File

@@ -0,0 +1,78 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
export interface LabPreviewFeature {
preview_feature: string;
domain: string;
enabled: boolean;
is_built_in: boolean;
feedback_url?: string;
learn_more_url?: string;
report_issue_url?: string;
}
export interface LabPreviewFeaturesResponse {
features: LabPreviewFeature[];
}
export const fetchLabFeatures = async (
hass: HomeAssistant
): Promise<LabPreviewFeature[]> => {
const response = await hass.callWS<LabPreviewFeaturesResponse>({
type: "labs/list",
});
return response.features;
};
export const labsUpdatePreviewFeature = (
hass: HomeAssistant,
domain: string,
preview_feature: string,
enabled: boolean,
create_backup?: boolean
): Promise<void> =>
hass.callWS({
type: "labs/update",
domain,
preview_feature,
enabled,
...(create_backup !== undefined && { create_backup }),
});
const fetchLabFeaturesCollection = (conn: Connection) =>
conn
.sendMessagePromise<LabPreviewFeaturesResponse>({
type: "labs/list",
})
.then((response) => response.features);
const subscribeLabUpdates = (
conn: Connection,
store: Store<LabPreviewFeature[]>
) =>
conn.subscribeEvents(
debounce(
() =>
fetchLabFeaturesCollection(conn).then((features: LabPreviewFeature[]) =>
store.setState(features, true)
),
500,
true
),
"labs_updated"
);
export const subscribeLabFeatures = (
conn: Connection,
onChange: (features: LabPreviewFeature[]) => void
) =>
createCollection<LabPreviewFeature[]>(
"_labFeatures",
fetchLabFeaturesCollection,
subscribeLabUpdates,
conn,
onChange
);

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