Compare commits

..
Author SHA1 Message Date
Paul Bottein 31c56eb8b5 Refactor icon loading 2025-06-12 17:33:31 +02:00
Paul BotteinandGitHub 01d2ef13c6 Ensure grid options always return an object (#25760) 2025-06-12 10:44:20 +03:00
Petar PetrovandGitHub af6911e848 Fix alerts refresh on device page (#25748)
* Fix alerts refresh on device page

* don't reset actions periodically

* reset stuff only on deviceId change
2025-06-12 09:40:36 +02:00
Paul BotteinandGitHub 21af10fd28 Change backup type order (#25759) 2025-06-12 07:29:58 +00:00
Paul BotteinandGitHub 6d30d15638 Fix edit card not working in chrome after editing (#25751) 2025-06-11 14:15:50 +00:00
Paul BotteinandGitHub d542b52ebd Display full error for card preview mode (#25747) 2025-06-11 13:04:54 +03:00
c0ffeeca7andGitHub 43cc49bb32 Z-Wave: apply sentence-style capitalization (#25739) 2025-06-11 09:13:32 +03:00
b3f0a6328e More support for no-grid energy dashboard (#25644)
* More support for no-grid energy dashboard

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

Co-authored-by: Petar Petrov <[email protected]>

* lint

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-06-11 09:11:46 +03:00
Paulus SchoutsenandGitHub 9d6a7e7e6f Assist Chat: handle intent progress delta not always being there (#25730)
handle intent progress data type change
2025-06-10 10:59:10 -04:00
Paul BotteinandGitHub 78d7da21aa Fix custom value selected when clicking item in combo box (#25734) 2025-06-10 16:46:09 +02:00
Paul BotteinandGitHub 0474a24df6 Allow to open more info using query params (#25733) 2025-06-10 13:20:10 +03:00
Paul BotteinandGitHub 6e7ac6fdf7 Reduce keypad gap and margin in alarm panel card (#25735) 2025-06-10 13:16:06 +03:00
Petar PetrovandGitHub 7b9683df89 Fix period boundaries in Energy dashboard (#25728) 2025-06-10 10:28:51 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
8523ddfd29 Update vaadinWebComponents monorepo to v24.7.8 (#25729)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 15:50:36 +03:00
Petar PetrovandGitHub 2589e1a49f Handle tiny values in a log chart (#25727) 2025-06-09 14:45:23 +03:00
Paul SchreiberandGitHub 5ce5f9a189 fix spelling of JavaScript in bug report template (#25726)
Correctly capitalize JavaScript in the bug report template.
2025-06-09 06:08:58 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6dd7217a20 Update dependency @babel/runtime to v7.27.6 (#25722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 09:01:08 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0d02d0d334 Update vitest monorepo to v3.2.2 (#25723)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 09:00:45 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
fed0dfa091 Update vitest monorepo to v3.2.1 (#25715)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 09:09:17 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
39de40dec9 Update Yarn to v4.9.2 (#25714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 09:09:11 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e1c42d9985 Update dependency typescript-eslint to v8.33.1 (#25712)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 20:33:20 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ad375c9b01 Update dependency hls.js to v1.6.5 (#25711)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 20:33:01 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
07230e5ef5 Update dependency @codemirror/language to v6.11.1 (#25708)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 19:59:35 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
52f5af6090 Update dependency @rsdoctor/rspack-plugin to v1.1.3 (#25709)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 19:59:08 +02:00
karwostsandGitHub 3c07289077 Handle shorthand template conditions in trace (#25705) 2025-06-06 18:10:10 +02:00
MathieuandGitHub 8eb7fe8b0a Adjust tooltip positioning in ha-sidebar for not first lis… (#25696)
fix(tooltip): fix tooltip positioning in ha-sidebar for not first listbox
2025-06-05 04:35:32 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c8c2966d34 Update dependency gulp to v5.0.1 (#25698)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-05 06:25:43 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a8768a5d9d Update dependency @bundle-stats/plugin-webpack-filter to v4.20.2 (#25692)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-05 06:25:20 +02:00
karwostsandGitHub 02bb7086e7 Hoist integration card tooltips (#25679) 2025-06-03 21:41:27 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Simon Lamon
42d8b2ae19 Update dependency lint-staged to v16 (#25463)
* Update dependency lint-staged to v16

* Remove shell parameter

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <[email protected]>
2025-06-03 19:18:39 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
e08f4a6bba Update dependency @types/chromecast-caf-receiver to v6.0.22 (#25681)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-03 17:32:25 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2e6c35d977 Update dependency hls.js to v1.6.4 (#25682)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-03 17:32:08 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
17305a818b Update dependency eslint to v9.28.0 (#25683)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-03 01:32:02 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
08389dad04 Update babel monorepo to v7.27.4 (#25680)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 22:36:58 +03:00
Bram KragtenandGitHub ab6ace46b5 Set answers to yes and no for cloud pipeline confirm (#25674) 2025-06-02 14:02:06 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
535dedbbc4 Update dependency hls.js to v1.6.3 (#25669)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 16:01:37 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
412eb0c647 Update dependency @codemirror/view to v6.37.1 (#25672)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 16:01:29 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
87c8ebd493 Update dependency @codemirror/view to v6.37.0 (#25664)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-01 09:31:21 +02:00
Simon LamonandGitHub 6e49f89126 Calendar add event button gap alignment (#25662)
Calendar gap alignment
2025-06-01 09:39:10 +03:00
ildar170975andGitHub a099e65a9d Fix "unavailable" state in Area card (#25063)
* fix "unavailable" state

* Show "-" for unavailable/undefined
2025-05-31 10:45:58 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
11e4a9f056 Update dependency typescript-eslint to v8.33.0 (#25657)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-30 17:52:29 +00:00
Bram KragtenandGitHub b617299eee Add css variables for start and end padding of tabs (#25654) 2025-05-30 16:48:15 +02:00
Bram KragtenandGitHub 768f27b1b9 Add label to collapse button in data table groups (#25653) 2025-05-30 16:47:54 +02:00
Bram KragtenandGitHub 5ed816df6d fix line height entity card (#25652) 2025-05-30 15:34:03 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
6692ac7517 Update dependency @lokalise/node-api to v14.8.0 (#25651)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-30 14:11:04 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
65499db0cb Update babel monorepo to v7.27.3 (#25650)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-30 14:27:05 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
11a1eabf61 Update rspack monorepo to v1.3.12 (#25649)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-30 14:26:40 +03:00
ildar170975andGitHub b30fa122ba Revert #25027 "more-info-camera: disable download_snapshot if idle" (#25643)
remove a check for "idle"
2025-05-30 10:59:03 +03:00
ildar170975andGitHub 6730d08b85 Map card: add prefix & suffix for "label_mode: attribute" (#25033)
* added prefix & suffix

* added prefix & suffix

* added prefix & suffix

* added prefix & suffix

* added prefix & suffix

* remove prefix, rename suffix -> unit

* remove prefix, rename suffix -> unit

* remove prefix, rename suffix -> unit

* remove prefix, rename suffix -> unit

* remove prefix, rename suffix -> unit
2025-05-30 10:56:35 +03:00
67003d6fd1 Add cyrilic letters to slugify() (#25647)
* add cyrilic

* Update src/common/string/slugify.ts

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-05-30 05:38:45 +00:00
Petar PetrovandGitHub 414d46be65 Add device button on the Z-Wave controller's device page (#25636) 2025-05-29 15:29:34 +03:00
Petar PetrovandGitHub 1485d1a1de Fix Z-WaveJS device count in dashboard (#25635) 2025-05-29 15:29:24 +03:00
Ville SkyttäandGitHub fd13e41524 Spelling fixes (#25638) 2025-05-29 10:48:50 +02:00
Petar PetrovandGitHub 77f7ca0368 Use theme variables for network graph labels (#25634) 2025-05-29 10:05:26 +02:00
Ville SkyttäandGitHub 7471250a07 Fix Zigbee capitalization in manage device button (#25637) 2025-05-29 07:59:58 +00:00
karwostsandGitHub 8b0a63d791 Cleanup some styling on disabled entity picker (#25632) 2025-05-29 08:53:30 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
57ffa814ed Update dependency @octokit/rest to v22 (#25633)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 08:51:54 +03:00
Paul BotteinandGitHub 16e20456e2 Put item at the top of picker result if there is an exact match with entity id (#25625) 2025-05-28 22:33:42 +02:00
Paul BotteinandGitHub 9c0ce41ebb Improve search in add automation element dialog (#25626)
* Improve search in add automation element dialog

* Remove unexpected import

* Take min character search into account
2025-05-28 19:38:33 +02:00
Paul BotteinandGitHub b458a1d7c6 Improve action picker UI and search (#25525) 2025-05-28 15:03:17 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0eaeeb1141 Update dependency globals to v16.2.0 (#25624)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:51:35 +03:00
Paul BotteinandGitHub b7e63e697f Fix picker field height (#25623) 2025-05-28 14:07:22 +03:00
Paul BotteinandGitHub 06db0f4b98 Fix missing helper for entity picker (#25622) 2025-05-28 14:06:45 +03:00
Paul BotteinandGitHub d33636c6fb Force narrow style for action, condition and trigger in flows (#25619) 2025-05-28 12:04:54 +02:00
WendelinandGitHub bbb546159c Set markdown code line-height (#25618) 2025-05-28 10:04:54 +02:00
karwostsandGitHub e8fc36026a More error handling for preview flow (#25611)
* More error handling for preview flow

* await unsub
2025-05-28 07:04:03 +03:00
Petar PetrovandGitHub 38f8c804af Fix for history graph with tiny values (#25612) 2025-05-27 20:46:03 +02:00
Petar PetrovandGitHub 7c5bf26240 Fix duplicate legend items when comparing energy data (#25610) 2025-05-27 20:43:32 +02:00
Norbert RittelandGitHub 189067d14b Fix typo in restore_entity_id_selected::confirm_text (#25615) 2025-05-27 19:56:55 +02:00
Petar PetrovandGitHub e79e0f77b8 Navigate to newly added device (#25608) 2025-05-27 16:19:53 +02:00
WendelinandGitHub b226e5c697 Fix font settings for button card (#25607) 2025-05-27 12:55:20 +02:00
WendelinandGitHub 52ad31601c Fix sidebar loading and demo (#25606) 2025-05-27 10:22:04 +02:00
Petar PetrovandGitHub cba3e4df7f Fix double history graphs for a disabled entity (#25604) 2025-05-27 09:08:58 +02:00
Paul BotteinandGitHub 3532cfa974 Fix duplicated items in strategy editor (#25600) 2025-05-26 19:21:44 +02:00
Bram Kragten 629eb29d42 Merge branch 'rc' into dev 2025-05-26 18:32:04 +02:00
Bram KragtenandGitHub 61019447cf Update alarm card styling (#25598) 2025-05-26 18:31:27 +02:00
Bram KragtenandGitHub cde2b436d1 Add restore entity id (#25592) 2025-05-26 16:23:23 +00:00
6c7d750734 Add support for grouping media players (#24736)
Co-authored-by: Simon Lamon <[email protected]>
Co-authored-by: Bram Kragten <[email protected]>
2025-05-26 14:35:09 +00:00
fcf5ed7731 Use context instead of stateObj for card features (#25577)
Co-authored-by: Bram Kragten <[email protected]>
2025-05-26 13:59:26 +00:00
3ce639946c Make weather-forecast card more responsive (#24900)
Co-authored-by: Bram Kragten <[email protected]>
2025-05-26 13:26:32 +00:00
bb5f01ac81 Use failed add-ons and folders of backup (#25548)
Co-authored-by: Bram Kragten <[email protected]>
2025-05-26 13:25:57 +00:00
208e863327 Save sidebar in user data (#25555)
Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Bram Kragten <[email protected]>
2025-05-26 13:25:11 +00:00
9f5f100e98 Tweak rules for entity-filter card (#25570)
Co-authored-by: Bram Kragten <[email protected]>
2025-05-26 13:24:10 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
114c1fb98b Update vaadinWebComponents monorepo to v24.7.7 (#25596)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 15:20:08 +02:00
Bram Kragten 3e5bd64b83 Update ha-selector-action.ts 2025-05-26 15:14:59 +02:00
karwostsandGitHub 02b4b8e334 Fix device actions in step-flow-form (#24539)
* Fix device actions in step-flow-form

* Move xlation fetch to device action

* also conditions & triggers

* move to firstUpdated
2025-05-26 14:19:09 +02:00
Petar PetrovandGitHub da8d43f5d1 Allow href="data:..." in config flow step description (#25559)
* Allow href="data:..." in config flow step description

* Update src/dialogs/config-flow/show-dialog-config-flow.ts
2025-05-26 13:56:11 +02:00
Paul BotteinandGitHub 18aaa44d2d Use constant for Home Assistant bluetooth node in graph (#25595) 2025-05-26 12:26:06 +02:00
Paul BotteinandGitHub 7fa697a768 Add actions section and add domains to other section in area dashboard (#25558)
* Add actions section and add domains to other section in area dashboard

* Add timer and input button

* Better grouping
2025-05-26 10:50:42 +02:00
Jan-Philipp BeneckeandGitHub 28fe60f02b Improve broken cards on dashboards (#25557) 2025-05-26 08:45:22 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
549451eccb Bump relative-ci/agent-action from 2.2.0 to 3.0.0 (#25593)
Bumps [relative-ci/agent-action](https://github.com/relative-ci/agent-action) from 2.2.0 to 3.0.0.
- [Release notes](https://github.com/relative-ci/agent-action/releases)
- [Commits](https://github.com/relative-ci/agent-action/compare/v2.2.0...v3.0.0)

---
updated-dependencies:
- dependency-name: relative-ci/agent-action
  dependency-version: 3.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-26 08:26:06 +02:00
113cc118cf Support for disabled selectors in config flow (#22592)
* Support for disabled selectors in config flow

* rename flag to readonly

* rename to read_only

* Merge branch 'dev' of https://github.com/home-assistant/frontend into disabled-fields-config-flow

* rework for new backend

* Fix disabled entity picker

* no longer used type

* Update src/dialogs/config-flow/step-flow-form.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-05-23 20:52:42 +03:00
Bram KragtenandGitHub 7ffb0f1e3b rename android-safe-area-inset to app-safe-area-inset (#25575) 2025-05-23 18:34:53 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
60ef43044b Update octokit monorepo to v8.0.1 (#25576)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 17:37:26 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4e4a82e023 Update rspack monorepo to v1.3.11 (#25574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 17:36:56 +02:00
Paulus SchoutsenandGitHub f563146165 Sort discoveries by title on integration page (#25578) 2025-05-23 17:33:47 +02:00
Petar PetrovandGitHub 81ba2db93a Custom variable down sampling for line charts (#25561) 2025-05-23 15:20:41 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4d8176ad6e Update octokit monorepo to v8 (major) (#25573)
Update octokit monorepo to v8

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 14:13:11 +03:00
Petar PetrovandGitHub 9b8be9f1af Fix chart labels for multi year periods (#25572) 2025-05-23 12:08:22 +02:00
WendelinandGitHub c11d2c10df Enable keyboard sorting for items-display-editor (#25546)
* Enable keyboard sorting for items-display-editor

* Add alt + arrow and fix bugs

* Fix alt bug

* Improve selected drag highlight
2025-05-23 10:42:27 +02:00
Paul BotteinandGitHub 412a0e9f6a Improve area floor picker UI and search (#25540)
* Improve area floor picker UI and search

* Improve area floor picker UI and search

* Remove noResultSorting
2025-05-23 10:35:47 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
67dc830bbf Update dependency marked to v15.0.12 (#25571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 08:52:22 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5581c10139 Update vitest monorepo to v3.1.4 (#25569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-22 16:36:02 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ec26818c53 Update dependency @types/leaflet to v1.9.18 (#25568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-22 18:26:19 +02:00
Paul BotteinandGitHub 399458f811 Improve category picker UI and search (#25560) 2025-05-22 18:14:26 +02:00
Paul BotteinandGitHub 3355986585 Restore screen readers support on pickers (#25553) 2025-05-22 09:51:12 +00:00
9b7db191a6 Add range support to icon translations (#25541)
Co-authored-by: Bram Kragten <[email protected]>
2025-05-22 11:32:44 +02:00
Paulus SchoutsenandGitHub 2d549ba22f Voice wizard: Wait 10s for update entity to change state (#25556) 2025-05-22 09:49:29 +02:00
Grzegorz LibiszewskiandGitHub c3e155a95c Unified safe area (insets) for Android and iOS (#24689)
* feat: Introduce new css variables for safe area

* feat: Replace all safe area env with variable
2025-05-22 08:11:04 +03:00
Petar PetrovandGitHub 754829a836 Localize ZHA visualization text (#25543) 2025-05-21 12:54:38 +00:00
c0ffeeca7andGitHub 87bd039239 Add comma (#25547) 2025-05-21 15:44:16 +03:00
WendelinandGitHub 32b3c83337 Edit sidebar in a dialog (#25532) 2025-05-21 13:42:43 +02:00
karwostsandGitHub f0beef22d2 Add hint helpers to fields that support markdown (#25528)
* Add hint helpers to fields that support markdown

* reduce duplication
2025-05-21 08:13:28 +03:00
Petar PetrovandGitHub 07e5f53469 Bluetooth network visualization (#25512)
* Bluetooth network visualization

* fix category symbol

* Add translations

* memoize bluetooth data

* throttle data updates to 10s

* handle proxies that appear as end devices too

* fix tab highlighting

* memoize fix
2025-05-21 07:59:44 +03:00
c0ffeeca7andGitHub 97e0217906 Remote access strings: tiny typo fix (#25539) 2025-05-20 12:47:51 +00:00
Paul BotteinandGitHub 006c7e1ea8 Improve floor picker UI and search (#25535)
* Improve floor picker UI and search

* sort by level
2025-05-20 14:45:40 +02:00
Andrej ShumkovskiandGitHub 9736d0cb55 Added Albanian (Shqip) Language (#25538)
Update translationMetadata.json

Added Shqip language following: 

https://developers.home-assistant.io/docs/translations/#maintainer-steps-to-add-a-new-language
2025-05-20 14:35:49 +02:00
WendelinandGitHub 9ec689382b Add has-fab to hass-tabs-subpage (#25536) 2025-05-20 14:00:27 +03:00
Paul BotteinandGitHub 4de95f6710 Improve label picker UI and search (#25522) 2025-05-20 12:47:33 +02:00
Paul BotteinandGitHub a55ef8ad47 Fix entity picker in footer graph and media card (#25531) 2025-05-20 11:32:37 +02:00
Paul BotteinandGitHub 01b398c2a3 Fix clear area action in area-picker (#25511)
* Fix clear area action in area-picker

* Fix area not displayed after creation
2025-05-20 10:01:01 +02:00
Paulus SchoutsenandGitHub 83df10ef29 Tweaks to Assist chat dialog (#25494) 2025-05-20 08:39:56 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a026c72230 Update dependency eslint to v9.27.0 (#25529)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-20 07:44:11 +03:00
c4e391c264 Echarts network graph for ZHA (#25457)
* Echarts network graph for ZHA

* improve layout

* better diff

* remove vis-network

* not bad layout

* fix LQI and clean up a bit

* Use ha-chart-base and remove header

* legend

* use color vars

* use colorVariables

* fix

* add physics toggle

* tweak lines

* remove vis-network

* minor tweaks

* dynamically load graph chart

* type fix

* fix height

* navigate to device page on label click

* PR comments

* aria tweak

* make extraComponents non reactive

* PR comments

* quick fix

* just make hass non reactive

* button tweak

* Update src/components/chart/ha-network-graph.ts

Co-authored-by: Wendelin <[email protected]>

---------

Co-authored-by: Wendelin <[email protected]>
2025-05-19 16:37:05 +03:00
4b72a6029c Fix stack editor bugs (#25501)
* Fix stack editor bugs

* Update src/panels/lovelace/editor/config-elements/hui-stack-card-editor.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-05-19 16:36:46 +03:00
TimothyandGitHub 8b87188075 Add navigate command to the external bus (#25516) 2025-05-19 13:22:19 +00:00
Paul BotteinandGitHub 2a4c6c9af5 Improve user picker UI and search (#25514)
Improve user picker
2025-05-19 13:53:19 +03:00
Paul BotteinandGitHub 5cbadaa5f9 Fix labels in duration input (#25510) 2025-05-19 11:26:25 +02:00
15fd4134d0 Improve device picker UI and search (#25401)
* Improve device picker

* Improve device search quick bar

* Fix types

* Fix selected device in the dropdown

* Move filter to picker

* Rename filters

* Use generic picker

* Update src/dialogs/quick-bar/ha-quick-bar.ts

---------

Co-authored-by: Bram Kragten <[email protected]>
2025-05-19 09:24:54 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
eda9abc3c5 Lock file maintenance (#25508)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-19 07:16:33 +03:00
karwostsandGitHub 7e56d5f351 Fix loading behavior when manually typing card type (#25502) 2025-05-18 08:57:59 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
5d4805cde6 Update dependency @codemirror/search to v6.5.11 (#25499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-18 08:54:22 +03:00
karwostsandGitHub c94326bc08 Fix duplicate_id in energy-usage-graph (#25503) 2025-05-18 08:53:37 +03:00
karwostsandGitHub b61180baa6 Don't access hidden tabs via left/right arrow (#25495) 2025-05-17 12:43:12 +02:00
karwostsandGitHub bc15d1474e Force wrap too long expansion panel headers (#25496) 2025-05-17 12:42:08 +02:00
Bram Kragten 95c3013497 Merge branch 'rc' 2025-05-16 18:57:16 +02:00
Bram Kragten 69a156f352 Bumped version to 20250516.0 2025-05-16 18:57:02 +02:00
Paul BotteinandBram Kragten 67f3d31a4b Fix domain not translated in entity picker and quick bar (#25444)
* Fix domain not translated in entity picker

* Remove unused param
2025-05-16 18:56:16 +02:00
Bram Kragten 98a2966432 Render choose and if action correctly on mobile (#25411) 2025-05-16 18:56:14 +02:00
karwostsandBram Kragten 75a2c061c2 Fix card editor help links (#25408) 2025-05-16 18:56:13 +02:00
karwostsandBram Kragten 724df18175 Fix devices graph max_devices (#25406) 2025-05-16 18:56:12 +02:00
Paul BotteinandBram Kragten c00b4120ab Fix selected entity in the entity picker dropdown (#25405) 2025-05-16 18:56:11 +02:00
Paul BotteinandBram Kragten d392bb4c83 Fallback to preferred pipeline if the pipeline doesn't exist (#25404) 2025-05-16 18:56:11 +02:00
Paul BotteinandBram Kragten 30d46f2f8a Fix domain max-width in entity picker (#25400) 2025-05-16 18:56:10 +02:00
7c879cc291 Add installation method to the about page (#25378)
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Franck Nijhof <[email protected]>
2025-05-16 18:56:08 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
9a731880f3 Update rspack monorepo to v1.3.10 (#25493)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-16 18:06:17 +02:00
Yosi LevyandGitHub 77efaaf7de RTL base font fix (#25478)
* RTL base font fix

* Fix comments
2025-05-16 08:20:19 +03:00
Paulus SchoutsenandGitHub da96266454 Update media content type for picked TTS (#25489) 2025-05-16 08:18:31 +03:00
renovate[bot]andGitHub 036d739d6e Update dependency @codemirror/view to v6.36.8 (#25487) 2025-05-15 19:56:20 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
c274a94ee5 Update dependency typescript-eslint to v8.32.1 (#25488)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-15 19:53:57 +02:00
Paul BotteinandGitHub da7d359696 Use generic picker for statistic picker (#25484) 2025-05-15 19:38:36 +02:00
Paul BotteinandGitHub 20a7b3870c Improve area picker UI and search (#25472)
* Improve area picker UI and search

* Feedbacks
2025-05-15 15:53:22 +02:00
WendelinandGitHub 9749a64ae1 Preload Roboto for Chrome OS (#25480) 2025-05-15 14:48:09 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
68741f6ba4 Update vaadinWebComponents monorepo to v24.7.6 (#25475)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-15 14:33:08 +03:00
AlCalzoneandGitHub e052ee04b4 Fix label for Thermostat Setback in Z-Wave JS installer panel (#25476) 2025-05-15 13:22:52 +02:00
WendelinandGitHub d2cc4a624e Use ha-line-height css tokens in codebase (#25468) 2025-05-15 11:57:17 +02:00
Paul BotteinandGitHub 5bcbe98f8e Create generic picker component (#25464)
* Create generic combo-box

* Fix focus

* Delete entity combo-box

* Rename components

* Create generic picker

* Fix undefined and placeholder

* Fix labels

* Fix icon and search

* Add missing hideClearIcon property to entity picker

* Make search string optional
2025-05-15 09:55:32 +00:00
Petar PetrovandGitHub beee76580d Keep screen on while adding new ZHA device (#25465) 2025-05-15 11:55:05 +02:00
karwostsandGitHub 28e5a30772 Prioritize battery consumption before solar consumption (#25467) 2025-05-15 10:32:07 +03:00
785929b370 Add ZHA status details to configuration dashboard (#25425)
Co-authored-by: Wendelin <[email protected]>
2025-05-14 11:57:30 +00:00
55cf7e635d Keep screen on while adding new ZwaveJS device (#25375)
* Keep screen on while adding new ZwaveJS device

* Add compatibility check

* Update src/mixins/wakelock-mixin.ts

Co-authored-by: Bram Kragten <[email protected]>

* format

---------

Co-authored-by: Bram Kragten <[email protected]>
2025-05-14 13:45:48 +03:00
4b270eb444 Periodically refresh alerts on the device page (#25385)
* Periodically refresh alerts on the device page

* Update src/panels/config/devices/ha-config-device-page.ts

Co-authored-by: Bram Kragten <[email protected]>

* revert type change

* Apply suggestions from code review

Co-authored-by: Bram Kragten <[email protected]>

---------

Co-authored-by: Bram Kragten <[email protected]>
2025-05-14 11:28:56 +02:00
Jan-Philipp BeneckeandGitHub d0e55719d1 Make map card clustering configurable (#25429)
* Persistent map clustering

* Make config option instead
2025-05-14 11:27:42 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
87f9397643 Update dependency @bundle-stats/plugin-webpack-filter to v4.20.1 (#25461)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-14 08:47:57 +02:00
WendelinandGitHub 910e7e10a7 Use ha-dialog-header for data-entry-flow (#25403)
* Use ha-dialog-header for data-entry-flow header

* Fix header wrap
2025-05-13 14:17:54 +03:00
WendelinandGitHub e1b9b47ac7 Hide map cluster toggle when there is just one entry (#25454) 2025-05-13 12:23:24 +03:00
89e04fcc45 Allow for shift click in data table checkboxes (#25024)
Co-authored-by: Wendelin <[email protected]>
2025-05-13 11:08:45 +02:00
David RapanandGitHub 93f2e75fc9 Update network_adapter_info translation as all core discovery integrations will now use network (#25452) 2025-05-13 09:27:54 +02:00
WendelinandGitHub 6c671d398f Fix zwave creating nvm backup translation (#25453) 2025-05-13 05:59:50 +00:00
Paul BotteinandGitHub 2e5c6a4d3f Fix domain not translated in entity picker and quick bar (#25444)
* Fix domain not translated in entity picker

* Remove unused param
2025-05-13 08:54:31 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
04e736a51e Update dependency @lokalise/node-api to v14.7.0 (#25450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-13 08:53:25 +03:00
WendelinandGitHub ec3fdc0ea7 Subscribe to frontend user data (#25445) 2025-05-12 16:01:56 +02:00
Robert ReschandGitHub a7a8c25d24 Remove getCandidatesUpfront (#25399) 2025-05-12 14:55:39 +02:00
c0ffeeca7andGitHub 60d457c3d9 Register account: apply sentence-style capitalization (#25440) 2025-05-12 10:09:47 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
a58b1e636d Update dependency @rsdoctor/rspack-plugin to v1.1.2 (#25420)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 09:12:13 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
f67e7ae081 Update rspack monorepo to v1.3.9 (#25421)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 08:52:00 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0032c5508e Update dependency eslint-config-prettier to v10.1.5 (#25439)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-12 08:51:39 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
036df78de8 Update dependency idb-keyval to v6.2.2 (#25432)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-11 21:02:47 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
617a6ba938 Update dependency @lokalise/node-api to v14.6.0 (#25433)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-11 21:00:45 +02:00
Alex GustafssonandGitHub 76b9063aec Fix typo in lint config (#25419)
Fix a typo causing yaml and "aml" files to be formatted with prettier as
opposed to yaml and yml.
2025-05-10 13:22:25 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
60c1d0a556 Update dependency globals to v16.1.0 (#25416)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 13:22:10 +02:00
Bram KragtenandGitHub 193caec2df Render choose and if action correctly on mobile (#25411) 2025-05-10 09:03:53 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
42c8d132bf Update dependency @babel/preset-env to v7.27.2 (#25409)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 09:03:37 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0311a7c976 Update dependency @rsdoctor/rspack-plugin to v1.1.1 (#25414)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 09:03:26 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
40ffd50b8a Update dependency barcode-detector to v3.0.4 (#25412)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 09:03:22 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
334991902a Update dependency eslint-config-prettier to v10.1.3 (#25415)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-10 09:03:14 +02:00
karwostsandGitHub c968266065 Fix card editor help links (#25408) 2025-05-09 18:38:03 +02:00
Paul BotteinandGitHub d4fc0318f7 Fix domain max-width in entity picker (#25400) 2025-05-09 18:37:14 +02:00
karwostsandGitHub 8a0d3baf67 Fix devices graph max_devices (#25406) 2025-05-09 18:35:54 +02:00
Paul BotteinandGitHub 8fc55cb6e2 Fix selected entity in the entity picker dropdown (#25405) 2025-05-09 18:35:38 +02:00
Paul BotteinandGitHub d6ebd9bfc4 Fallback to preferred pipeline if the pipeline doesn't exist (#25404) 2025-05-09 16:01:03 +02:00
Bram KragtenandGitHub 15ae37d077 Move vaadin typography vars to typography globals (#25398) 2025-05-09 12:08:12 +00:00
461d5eb687 Add device rename to voice wizard (#25187)
Co-authored-by: Wendelin <[email protected]>
2025-05-09 13:56:50 +02:00
WendelinandGitHub 3058fcad46 Use ha-font-size typography css tokens (#25361) 2025-05-09 13:47:25 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>Wendelin
06bd1ae4cd Update dependency @rsdoctor/rspack-plugin to v1.1.0 (#25389)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Wendelin <[email protected]>
2025-05-09 13:43:39 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
00733357a1 Update dependency lint-staged to v15.5.2 (#25394)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-09 13:19:20 +02:00
Bram Kragten 7f6ce97199 Merge branch 'rc' 2025-05-09 12:11:58 +02:00
Bram Kragten 0742aed8e7 Bumped version to 20250509.0 2025-05-09 12:11:36 +02:00
Bram Kragten f675dd3388 Fix width of device in create entry step (#25392) 2025-05-09 12:11:08 +02:00
Bram Kragten 9751cb4e50 Fix bottom padding in service control when in narrow mode (#25390) 2025-05-09 12:11:07 +02:00
Bram Kragten 7919028780 Fix sidebar keyboard navigation in edit mode (#25376) 2025-05-09 12:11:07 +02:00
WendelinandBram Kragten 1508c7c905 Fix backup custom retention save issue (#25368) 2025-05-09 12:11:06 +02:00
WendelinandBram Kragten 3fe907f388 Fix backup settings undefined cloudStatus (#25366) 2025-05-09 12:11:05 +02:00
5cc87661b9 Add a dialog to SSDP to show raw data (#25362)
* Add a dialog to SSDP to show raw data

* tweaks

* Add a dialog to SSDP to show raw data

* reduce

* tweaks

* Update src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-raw-data.ts

Co-authored-by: Petar Petrov <[email protected]>

* Update src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts

Co-authored-by: Petar Petrov <[email protected]>

* Update src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts

Co-authored-by: Petar Petrov <[email protected]>

* add dump

* preen

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-05-09 12:11:04 +02:00
fc372172a6 Fix ha-dialog actions padding to use safe-area-inset (#25359)
* Fix ha-dialog actions padding to use safe-area-inset

* Update src/components/ha-dialog.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-05-09 12:11:03 +02:00
Simon LamonandBram Kragten 7bd9a39bf5 Fix bluetooth device info is encoding data twice (#25353)
Data is already encoded
2025-05-09 12:11:02 +02:00
Bram KragtenandGitHub 665c971822 Fix width of device in create entry step (#25392) 2025-05-09 12:00:06 +02:00
Bram KragtenandGitHub eff5471dd1 Fix bottom padding in service control when in narrow mode (#25390) 2025-05-09 11:01:58 +02:00
4fba9c3c0a Add installation method to the about page (#25378)
Co-authored-by: Bram Kragten <[email protected]>
Co-authored-by: Franck Nijhof <[email protected]>
2025-05-09 10:23:52 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
0b32b51e2f Update dependency @types/leaflet-draw to v1.0.12 (#25386)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-09 09:00:39 +02:00
Bram KragtenandGitHub 6370b0b8e5 Fix sidebar keyboard navigation in edit mode (#25376) 2025-05-09 08:51:04 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
681518f443 Update dependency typescript-eslint to v8.32.0 (#25383)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-08 23:51:36 +02:00
Simon LamonandGitHub 9f5b89978d Bump the hls patch (#25264) 2025-05-08 15:30:49 +00:00
Bram KragtenandGitHub 130839ee7b Revert "Add left text alignment to call service button" (#25377) 2025-05-08 17:24:10 +02:00
ba4ec960c8 Use ha-font-weight typography css tokens (#25374)
Co-authored-by: Petar Petrov <[email protected]>
2025-05-08 15:11:53 +00:00
Petar PetrovandGitHub 6692d9c6aa Use the new included_in_stat hierarchy in the Energy Sankey card (#25306) 2025-05-08 17:01:18 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
4d2d94c54f Update dependency @lokalise/node-api to v14.5.2 (#25380)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-08 17:51:45 +03:00
WendelinandGitHub d59c6612c6 Fix backup custom retention save issue (#25368) 2025-05-08 16:50:31 +02:00
WendelinandGitHub 498f158253 Use ha-font-smoothing typography css tokens (#25364) 2025-05-08 17:32:48 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b8026ccf46 Update vitest monorepo to v3.1.3 (#25379)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-08 17:29:57 +03:00
WendelinandGitHub 84def48222 Add tests for common name utils (#25373) 2025-05-08 16:59:28 +03:00
WendelinandGitHub cea0ac02fe Improve ha-spinner gallery docs (#25369) 2025-05-08 15:50:03 +03:00
WendelinandGitHub e1b099e88b Fix backup settings undefined cloudStatus (#25366) 2025-05-08 13:47:17 +02:00
Yosi LevyandGitHub d571ef3f18 Font style in side bar (#25367) 2025-05-08 13:21:39 +02:00
LucasandGitHub c8cffef647 Add left text alignment to call service button (#24846) 2025-05-08 09:58:46 +02:00
Simon LamonandGitHub 6b568307a4 Fix bluetooth device info is encoding data twice (#25353)
Data is already encoded
2025-05-08 08:21:01 +03:00
1b501907f1 Add a dialog to SSDP to show raw data (#25362)
* Add a dialog to SSDP to show raw data

* tweaks

* Add a dialog to SSDP to show raw data

* reduce

* tweaks

* Update src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-raw-data.ts

Co-authored-by: Petar Petrov <[email protected]>

* Update src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts

Co-authored-by: Petar Petrov <[email protected]>

* Update src/panels/config/integrations/integration-panels/ssdp/dialog-ssdp-discovery-info.ts

Co-authored-by: Petar Petrov <[email protected]>

* add dump

* preen

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-05-08 08:18:26 +03:00
c7e79998a4 Fix ha-dialog actions padding to use safe-area-inset (#25359)
* Fix ha-dialog actions padding to use safe-area-inset

* Update src/components/ha-dialog.ts

Co-authored-by: Petar Petrov <[email protected]>

---------

Co-authored-by: Petar Petrov <[email protected]>
2025-05-07 18:04:43 +02:00
WendelinandGitHub 03ccf014d9 Use ha-font-family typography css token (#25355)
Use ha-font-family
2025-05-07 17:38:07 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
ab41bdb87d Update dependency @lokalise/node-api to v14.5.1 (#25360)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-07 17:35:54 +03:00
karwostsandGitHub 821a0bc418 New solar-consumed-gauge algorithm (#25326)
* New solar-consumed-gauge algorithm

* update tests

* Apply suggestion

* reduce duplicate code
2025-05-07 17:35:24 +03:00
karwostsandGitHub 6d931b9e37 Add UoM to location selector (#25358) 2025-05-07 16:13:29 +02:00
Bram Kragten f0341f28ab Merge branch 'rc' 2025-05-07 13:14:55 +02:00
Paul BotteinandBram Kragten 603663e0cc Fix badge overlay in scrolling mode (#25352)
Co-authored-by: Bram Kragten <[email protected]>
2025-05-07 13:14:21 +02:00
Bram Kragten 80dddc9eef Only enable drag scroll controller when needed (#25351) 2025-05-07 13:14:20 +02:00
Bram Kragten 8494d0542e Bumped version to 20250507.0 2025-05-07 10:45:48 +02:00
WendelinandBram Kragten 467a5bef0b Fix data-table search clear button alignment (#25348) 2025-05-07 10:45:19 +02:00
Petar PetrovandBram Kragten 24736e36ff Set <=0 chart values to null so they render as gaps on a log graph (#25347) 2025-05-07 10:45:18 +02:00
Paulus SchoutsenandBram Kragten 604c00d772 Update network browser descriptions (#25345) 2025-05-07 10:45:17 +02:00
Bram Kragten 12bbba1bff fix height of stats graph card in section (#25344) 2025-05-07 10:45:16 +02:00
Bram Kragten e3221ad4ee Merge branch 'rc' 2025-05-06 20:46:09 +02:00
Bram Kragten 7d3a7fa1db Bumped version to 20250506.0 2025-05-06 20:45:47 +02:00
Paul BotteinandBram Kragten 5e67bf1fa7 Fix outlined icon button style (#25340) 2025-05-06 20:45:16 +02:00
Paul BotteinandBram Kragten 7147c7578d Use right theme variable for dashboard tab color in edit mode (#25339) 2025-05-06 20:45:16 +02:00
Paul BotteinandBram Kragten 90710fedf2 Simplify entity combo-box code (#25338) 2025-05-06 20:45:15 +02:00
Paul BotteinandBram Kragten 9629159ef1 Use middle dot 00B7 as separator (#25336) 2025-05-06 20:45:14 +02:00
Paul BotteinandBram Kragten bfac6e1516 Add covers to overview view for area strategy (#25334) 2025-05-06 20:45:13 +02:00
WendelinandBram Kragten 7c288d1769 Fix sidebar item text width (#25332)
* Fix sidebar item text width to utilize full available space

* Update src/components/ha-sidebar.ts

Co-authored-by: Bram Kragten <[email protected]>

---------

Co-authored-by: Bram Kragten <[email protected]>
2025-05-06 20:45:12 +02:00
Paul BotteinandBram Kragten 39119eeb2a Align side bar title with items (#25330) 2025-05-06 20:45:11 +02:00
WendelinandBram Kragten 9c16ce3342 Fix flow form padding end (#25328)
* Fix flow form padding end

* Use more end padding if docs are present
2025-05-06 20:45:10 +02:00
3595fab5cb Show voice ID in TTS media browser (#25324)
* Show voice ID in TTS media browser

* Apply suggestions from code review

Co-authored-by: Paul Bottein <[email protected]>

* Add copy button

* Now copy correct clipboard icon

* Improve styling

* GAP

---------

Co-authored-by: Paul Bottein <[email protected]>
2025-05-06 20:45:09 +02:00
WendelinandBram Kragten 78b2d17f10 Add custom retention info to backup locations (#25318)
* Add retention messages to backup locations

* Fix mobile

* Use join
2025-05-06 20:45:09 +02:00
Paul BotteinandGitHub 7b8b8f9d0b 20250502.1 (#25323) 2025-05-05 20:14:29 +02:00
Paul Bottein b7f866faff Bumped version to 20250502.1 2025-05-05 20:12:59 +02:00
Paulus SchoutsenandPaul Bottein 37671fd613 Populate integration domain My link (#25322)
* Populate integration domain My link

* break out of loop

* Actually just return from function

* Consolidate code
2025-05-05 20:12:06 +02:00
Paulus SchoutsenandPaul Bottein 92905c1433 Clean up network browser nav (#25321)
* Clean up network browser nav

* Add ha-md-list
2025-05-05 20:12:05 +02:00
Paulus SchoutsenandPaul Bottein cb7251cb5e Reorder my links (#25319) 2025-05-05 20:12:04 +02:00
Paul Bottein 44485c0de4 Do not display no areas in entity pickers (#25317) 2025-05-05 20:12:03 +02:00
Paul Bottein 1191a09576 Use new entity naming in card entity picker (#25316) 2025-05-05 20:12:03 +02:00
karwostsandPaul Bottein c178488aac Add energy hourly calculations to CSV report (#25315) 2025-05-05 20:12:02 +02:00
WendelinandPaul Bottein 9f6463eec0 Fix zwave add device LR/mesh icons (#25313)
Fix zwave LR/mesh icons
2025-05-05 20:12:01 +02:00
WendelinandPaul Bottein 70fef59401 Fix options and repair flow success (#25312) 2025-05-05 20:12:00 +02:00
WendelinandPaul Bottein 3e053e07c6 Fix selected entity overflow (#25311) 2025-05-05 20:11:59 +02:00
WendelinandPaul Bottein 376cac6002 Fix select entity change (#25310) 2025-05-05 20:11:58 +02:00
Paul Bottein 5cd68301ed Fix pasting yaml in automation code editor (#25309)
Fix pasting yaml in code editor
2025-05-05 20:11:57 +02:00
WendelinandPaul Bottein 3121721ac7 Revert "Use md-select for entity-row and state-card" (#25308)
Revert "Use md-select for entity-row and state-card (#25307)"

This reverts commit 3c9dce20e2.
2025-05-05 20:11:56 +02:00
WendelinandPaul Bottein 868daf692d Use md-select for entity-row and state-card (#25307) 2025-05-05 20:11:56 +02:00
WendelinandPaul Bottein 75e9ac9e73 Fix flow-form header (#25305) 2025-05-05 20:11:55 +02:00
Paulus SchoutsenandPaul Bottein f6e36f2038 Add profile security link to My Home Assistant (#25303) 2025-05-05 20:11:54 +02:00
Bram KragtenandPaul Bottein 55770f3e02 Fix display of disabled items in traces (#25293) 2025-05-05 20:11:53 +02:00
Bram KragtenandPaul Bottein cfc7f91f03 Fix select entity row opening more info on select (#25292) 2025-05-05 20:11:52 +02:00
Jan-Philipp BeneckeandPaul Bottein 74488c0b96 Use new entity picker style in quick bar (#25265)
* Use new entity picker style in quick bar

* Cleanup

* Add missing no area

* Process code review
2025-05-05 20:11:51 +02:00
Bram Kragten 1154d1769d Bumped version to 20250502.0 2025-05-02 21:34:23 +03:00
Bram KragtenandSimon Lamon a820cd4576 Fix decorators with properties (#25282)
* Fix decorators with properties

* Green build

---------

Co-authored-by: Simon Lamon <[email protected]>
2025-05-02 21:33:56 +03:00
Bram Kragten 678a8a85cb Fix alignment of ha-labeled-slider (#25279) 2025-05-02 21:33:55 +03:00
karwostsandBram Kragten 358e450e60 Fix disabled language picker (#25278) 2025-05-02 21:33:54 +03:00
Jan-Philipp BeneckeandBram Kragten 32acef8fad Add save shortcut to shortcuts dialog (#25271) 2025-05-02 21:33:53 +03:00
J. Nick KostonandBram Kragten fae619085c Add my links for the Bluetooth monitors (#25270)
The plan is to link these in the Bluetooth docs for help debugging
2025-05-02 21:33:52 +03:00
Paulus SchoutsenandBram Kragten 5d89563aa5 Import missing components on init page (#25269) 2025-05-02 21:33:52 +03:00
Bram Kragten d7dd11ba7f Improve error handling in automation i18n (#25266) 2025-05-02 21:33:51 +03:00
Jan-Philipp BeneckeandBram Kragten 4624cc609f Always show backup location retention settings (#25261)
Always show backup location retention settings
2025-05-02 21:33:50 +03:00
karwostsandBram Kragten f24b6a4cb1 Fix typo in energy calculation (#25259)
* New energy calculation

* more tests and stricter tests. change priority order

* more test and fix error
2025-05-02 21:33:49 +03:00
Jan-Philipp BeneckeandBram Kragten 2b06742bb9 Hide the tab when view is a subview (#25256) 2025-05-02 21:33:48 +03:00
Jan-Philipp BeneckeandBram Kragten 9e5b7462af Add ? as shortcut for shortcuts dialog (#25253)
Bind shortcuts dialog to `?` key
2025-05-02 21:33:47 +03:00
J. Nick KostonandBram Kragten a544ff4c8a Improve message when no discovery data is found (#25252)
* Improve message when no discovery data is found

It was pointed out its a bit confusing when a device has not been
discovered yet for the discovery/network browser panels as we
only said there was no data. Give the user a better hint
as to why there is no data.

* Improve message when no discovery data is found

It was pointed out its a bit confusing when a device has not been
discovered yet for the discovery/network browser panels as we
only said there was no data. Give the user a better hint
as to why there is no data.

* Improve message when no discovery data is found

It was pointed out its a bit confusing when a device has not been
discovered yet for the discovery/network browser panels as we
only said there was no data. Give the user a better hint
as to why there is no data.

* Improve message when no discovery data is found

It was pointed out its a bit confusing when a device has not been
discovered yet for the discovery/network browser panels as we
only said there was no data. Give the user a better hint
as to why there is no data.
2025-05-02 21:33:46 +03:00
J. Nick KostonandBram Kragten b81d2013dc Fix formatting of mac address fields in device info (#25251)
Previous change lost the `:` seperator, and unexpectedly MAC became
title case instead of MAC when dhcp is loaded.
2025-05-02 21:33:45 +03:00
J. Nick KostonandBram Kragten 9b7e2886b6 Better explain when DHCP discovery data will be available (#25250)
It was pointed out that users likely may not know what DHCP is
and wonder why the data is not available yet.
2025-05-02 21:33:44 +03:00
karwostsandBram Kragten 69eaf178ca New energy calculation formula (#25242)
* New energy calculation

* more tests and stricter tests. change priority order
2025-05-02 21:33:44 +03:00
Yosi LevyandBram Kragten 1a14511fa6 Various RTL fixes (#25231) 2025-05-02 21:33:43 +03:00
Paul BotteinandGitHub e517175f68 20250430.2 (#25246) 2025-04-30 17:52:11 +02:00
Paul Bottein ee9cbf7370 Bumped version to 20250430.2 2025-04-30 17:50:44 +02:00
Bram KragtenandPaul Bottein efd7b380a9 Handle errrors/wrong values on paste (#25245) 2025-04-30 17:49:56 +02:00
Paul Bottein 46cc254f77 Use code font family variable in combo-box (#25243) 2025-04-30 17:49:55 +02:00
Paul BotteinandGitHub d996ba818f 20250430.1 (#25244) 2025-04-30 16:10:12 +02:00
Paul Bottein e5f41ceb3e Bumped version to 20250430.1 2025-04-30 16:08:42 +02:00
Bram KragtenandPaul Bottein 2e4ce71d06 Improve trigger condition check on paste (#25241) 2025-04-30 16:08:03 +02:00
Bram KragtenandPaul Bottein 50e39de974 Allow pasting more script config (#25240)
* Allow pasting more script config

* Update manual-script-editor.ts
2025-04-30 16:08:02 +02:00
Bram KragtenandPaul Bottein 1ba941282c Allow pasting more automation config formats (#25239) 2025-04-30 16:08:01 +02:00
J. Nick KostonandPaul Bottein b656ddc1f0 Add DHCP Browser entry point to network (#25235)
* Add DHCP Browser entry point to network

* lint
2025-04-30 16:08:00 +02:00
Paul BotteinandGitHub a699149388 20250430.0 (#25237) 2025-04-30 13:10:20 +02:00
559 changed files with 13165 additions and 8869 deletions
+2 -2
View File
@@ -108,9 +108,9 @@ body:
render: yaml
- type: textarea
attributes:
label: Javascript errors shown in your browser console/inspector
label: JavaScript errors shown in your browser console/inspector
description: >
If you come across any Javascript or other error logs, e.g., in your
If you come across any JavaScript or other error logs, e.g., in your
browser console/inspector please provide them.
render: txt
- type: textarea
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@v2.2.0
uses: relative-ci/agent-action@v3.0.0
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}
+1 -1
View File
@@ -1 +1 @@
yarn run lint-staged --relative --shell "/bin/bash"
yarn run lint-staged --relative
@@ -1,18 +0,0 @@
diff --git a/dist/hls.light.mjs b/dist/hls.light.mjs
index eed9d788fafdb159975e1a2eb08ac88ba9c9ac33..ace881935e6665946f1c8110ebd2f739cde4427e 100644
--- a/dist/hls.light.mjs
+++ b/dist/hls.light.mjs
@@ -20523,9 +20523,9 @@ class Hls {
}
Hls.defaultConfig = void 0;
-var KeySystemFormats = empty.KeySystemFormats;
-var KeySystems = empty.KeySystems;
-var SubtitleStreamController = empty.SubtitleStreamController;
-var TimelineController = empty.TimelineController;
+var KeySystemFormats = empty;
+var KeySystems = empty;
+var SubtitleStreamController = empty;
+var TimelineController = empty;
export { AbrController, AttrList, Cues as AudioStreamController, Cues as AudioTrackController, BasePlaylistController, BaseSegment, BaseStreamController, BufferController, Cues as CMCDController, CapLevelController, ChunkMetadata, ContentSteeringController, DateRange, Cues as EMEController, ErrorActionFlags, ErrorController, ErrorDetails, ErrorTypes, Events, FPSController, Fragment, Hls, HlsSkip, HlsUrlParameters, KeySystemFormats, KeySystems, Level, LevelDetails, LevelKey, LoadStats, MetadataSchema, NetworkErrorAction, Part, PlaylistLevelType, SubtitleStreamController, Cues as SubtitleTrackController, TimelineController, Hls as default, getMediaSource, isMSESupported, isSupported };
//# sourceMappingURL=hls.light.mjs.map
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
yarnPath: .yarn/releases/yarn-4.9.2.cjs
+1 -1
View File
@@ -302,7 +302,7 @@ export class HcConnect extends LitElement {
}
.error {
color: red;
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.error a {
+5 -5
View File
@@ -86,9 +86,9 @@ class HcLayout extends LitElement {
.card-header {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, 24px);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: 32px;
line-height: var(--ha-line-height-condensed);
padding: 24px 16px 16px;
display: block;
margin: 0;
@@ -98,7 +98,7 @@ class HcLayout extends LitElement {
border-radius: 4px 4px 0 0;
}
.subtitle {
font-size: 14px;
font-size: var(--ha-font-size-m);
color: var(--secondary-text-color);
line-height: initial;
}
@@ -113,7 +113,7 @@ class HcLayout extends LitElement {
}
:host ::slotted(.section-header) {
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
padding: 4px 16px;
text-transform: uppercase;
}
@@ -135,7 +135,7 @@ class HcLayout extends LitElement {
.footer {
text-align: center;
font-size: 12px;
font-size: var(--ha-font-size-s);
padding: 8px 0 24px;
color: var(--secondary-text-color);
}
+1 -1
View File
@@ -29,7 +29,7 @@ class HcLaunchScreen extends LitElement {
display: block;
height: 100vh;
background-color: #f2f4f9;
font-size: 24px;
font-size: var(--ha-font-size-2xl);
}
.container {
display: flex;
+2 -2
View File
@@ -68,7 +68,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(env(safe-area-inset-bottom), 48px) + 46px );
margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px );
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
@@ -76,7 +76,7 @@
padding-top: 48px;
}
.ohf-logo {
margin: max(env(safe-area-inset-bottom), 48px) 0;
margin: max(var(--safe-area-inset-bottom), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;
+23
View File
@@ -1,7 +1,30 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let changeFunction;
export const mockFrontend = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_user_data", () => ({
value: null,
}));
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
if (key === "sidebar") {
changeFunction?.({
value: {
panelOrder: value.panelOrder || [],
hiddenPanels: value.hiddenPanels || [],
},
});
}
});
hass.mockWS("frontend/subscribe_user_data", (_msg, _hass, onChange) => {
changeFunction = onChange;
onChange?.({
value: {
panelOrder: [],
hiddenPanels: [],
},
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
};
+3 -3
View File
@@ -38,12 +38,12 @@ class PageDescription extends HaMarkdown {
}
.title {
font-size: 42px;
line-height: 56px;
line-height: var(--ha-line-height-condensed);
padding-bottom: 8px;
}
.subtitle {
font-size: 18px;
line-height: 24px;
font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-normal);
}
.root {
max-width: 800px;
+1 -1
View File
@@ -34,7 +34,7 @@ class HaDemoOptions extends LitElement {
height: 64px;
padding: 0 16px;
pointer-events: none;
font-size: 20px;
font-size: var(--ha-font-size-xl);
}
`,
];
+4 -4
View File
@@ -250,14 +250,14 @@ class HaGallery extends LitElement {
}
.page-footer .header {
font-size: 16px;
font-weight: 500;
line-height: 28px;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
text-align: center;
}
.page-footer .secondary {
line-height: 23px;
line-height: var(--ha-line-height-normal);
text-align: center;
}
@@ -150,7 +150,7 @@ export class DemoHaBarButton extends LitElement {
margin: 0;
}
label {
font-weight: 600;
font-weight: var(--ha-font-weight-bold);
}
.custom {
--control-button-icon-color: var(--primary-color);
@@ -86,7 +86,7 @@ export class DemoHarControlNumberButtons extends LitElement {
margin: 0;
}
label {
font-weight: 600;
font-weight: var(--ha-font-weight-bold);
}
.custom {
color: #2196f3;
@@ -125,7 +125,7 @@ export class DemoHaControlSelectMenu extends LitElement {
margin: 0;
}
label {
font-weight: 600;
font-weight: var(--ha-font-weight-bold);
}
.custom {
--control-button-icon-color: var(--primary-color);
@@ -181,7 +181,7 @@ export class DemoHaControlSelect extends LitElement {
margin: 0;
}
label {
font-weight: 600;
font-weight: var(--ha-font-weight-bold);
}
.custom {
--mdc-icon-size: 24px;
@@ -144,7 +144,7 @@ export class DemoHaBarSlider extends LitElement {
margin: 0;
}
label {
font-weight: 600;
font-weight: var(--ha-font-weight-bold);
}
.custom {
--control-slider-color: #ffcf4c;
@@ -112,7 +112,7 @@ export class DemoHaControlSwitch extends LitElement {
margin: 0;
}
label {
font-weight: 600;
font-weight: var(--ha-font-weight-bold);
}
.custom {
--control-switch-on-color: var(--green-color);
@@ -105,8 +105,8 @@ export class DemoHaHsColorPicker extends LitElement {
width: 400px;
}
.value {
font-size: 22px;
font-weight: bold;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold);
margin: 0 0 12px 0;
}
`;
@@ -123,7 +123,7 @@ export class DemoHaSelectBox extends LitElement {
margin: 0;
}
label {
font-weight: 600;
font-weight: var(--ha-font-weight-bold);
margin-bottom: 8px;
display: block;
}
+56 -18
View File
@@ -1,6 +1,7 @@
import type { TemplateResult } from "lit";
import { html, css, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-bar";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-spinner";
@@ -11,29 +12,66 @@ export class DemoHaSpinner extends LitElement {
@property({ attribute: false }) hass!: HomeAssistant;
protected render(): TemplateResult {
return html`<ha-card header="Basic spinner">
<div class="card-content">
<ha-spinner></ha-spinner></div
></ha-card>
<ha-card header="Different spinner sizes">
<div class="card-content">
<ha-spinner size="tiny"></ha-spinner>
<ha-spinner size="small"></ha-spinner>
<ha-spinner size="medium"></ha-spinner>
<ha-spinner size="large"></ha-spinner></div
></ha-card>
<ha-card header="Spinner with an aria-label">
<div class="card-content">
<ha-spinner aria-label="Doing something..."></ha-spinner>
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner></div
></ha-card>`;
return html`
${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-badge ${mode} demo">
<div class="card-content">
<ha-spinner></ha-spinner>
<ha-spinner size="tiny"></ha-spinner>
<ha-spinner size="small"></ha-spinner>
<ha-spinner size="medium"></ha-spinner>
<ha-spinner size="large"></ha-spinner>
<ha-spinner aria-label="Doing something..."></ha-spinner>
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner>
</div>
</ha-card>
</div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
}
static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
margin: 16px;
border-radius: 8px;
}
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
`;
}
@@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
@@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
@@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
@@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
+1 -1
View File
@@ -106,7 +106,7 @@ export class DemoDateTimeDateTime extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
+1 -1
View File
@@ -92,7 +92,7 @@ export class DemoDateTimeDate extends LitElement {
static styles = css`
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
+1 -1
View File
@@ -106,7 +106,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
+1 -1
View File
@@ -106,7 +106,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
+1 -1
View File
@@ -106,7 +106,7 @@ export class DemoDateTimeTime extends LitElement {
margin: 12px auto;
}
.header {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.center {
text-align: center;
@@ -428,13 +428,13 @@ class HassioAddonConfig extends LitElement {
.header h2 {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, 24px);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: 48px;
line-height: var(--ha-line-height-expanded);
padding: 12px 16px 16px;
display: block;
margin-block: 0px;
font-weight: normal;
font-weight: var(--ha-font-weight-normal);
}
.card-actions.right {
justify-content: flex-end;
@@ -1280,12 +1280,12 @@ class HassioAddonInfo extends LitElement {
padding-left: 8px;
padding-inline-start: 8px;
padding-inline-end: initial;
font-size: 24px;
font-size: var(--ha-font-size-2xl);
color: var(--ha-card-header-color, var(--primary-text-color));
}
.addon-version {
float: var(--float-end);
font-size: 15px;
font-size: var(--ha-font-size-l);
vertical-align: middle;
}
.errors {
+2 -2
View File
@@ -391,7 +391,7 @@ export class HassioBackups extends LitElement {
top: -4px;
}
.selected-txt {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
padding-left: 16px;
padding-inline-start: 16px;
padding-inline-end: initial;
@@ -401,7 +401,7 @@ export class HassioBackups extends LitElement {
margin-top: 20px;
}
.header-toolbar .selected-txt {
font-size: 16px;
font-size: var(--ha-font-size-l);
}
.header-toolbar .header-btns {
margin-right: -12px;
+1 -1
View File
@@ -101,7 +101,7 @@ class HassioCardContent extends LitElement {
overflow: hidden;
position: relative;
height: 2.4em;
line-height: 1.2em;
line-height: var(--ha-line-height-condensed);
}
.icon_image img {
max-height: 40px;
+3 -3
View File
@@ -132,9 +132,9 @@ class HassioDashboard extends LitElement {
}
ha-fab.non-tabs {
position: fixed;
right: calc(16px + env(safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom));
inset-inline-end: calc(16px + env(safe-area-inset-right));
right: calc(16px + var(--safe-area-inset-right));
bottom: calc(16px + var(--safe-area-inset-bottom));
inset-inline-end: calc(16px + var(--safe-area-inset-right));
inset-inline-start: initial;
z-index: 1;
}
+1 -1
View File
@@ -131,7 +131,7 @@ export class HassioUpdate extends LitElement {
}
.update-heading {
font-size: var(--ha-font-size-l);
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
margin-bottom: 0.5em;
color: var(--primary-text-color);
}
@@ -173,7 +173,7 @@ class HassioHardwareDialog extends LitElement {
font-family: var(--ha-font-family-code);
}
code {
font-size: 85%;
font-size: var(--ha-font-size-s);
padding: 0.2em 0.4em;
}
search-input {
@@ -610,7 +610,7 @@ export class DialogHassioNetwork
display: flex;
justify-content: space-between;
padding: 8px;
padding-bottom: max(env(safe-area-inset-bottom), 8px);
padding-bottom: max(var(--safe-area-inset-bottom), 8px);
background-color: var(--mdc-theme-surface, #fff);
}
.warning {
+9 -4
View File
@@ -1,3 +1,8 @@
import {
haFontFamilyBody,
haFontSmoothing,
haMozOsxFontSmoothing,
} from "../../src/resources/theme/typography.globals";
import "./hassio-main";
import("../../src/resources/append-ha-style");
@@ -5,10 +10,10 @@ import("../../src/resources/append-ha-style");
const styleEl = document.createElement("style");
styleEl.textContent = `
body {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-weight: 400;
font-family: ${haFontFamilyBody};
-moz-osx-font-smoothing: ${haMozOsxFontSmoothing};
-webkit-font-smoothing: ${haFontSmoothing};
font-weight: var(--ha-font-weight-normal);
margin: 0;
padding: 0;
height: 100vh;
@@ -340,12 +340,12 @@ class HassioIngressView extends LitElement {
.header {
display: flex;
align-items: center;
font-size: 16px;
font-size: var(--ha-font-size-l);
height: 40px;
padding: 0 16px;
pointer-events: none;
background-color: var(--app-header-background-color);
font-weight: 400;
font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box;
@@ -354,7 +354,7 @@ class HassioIngressView extends LitElement {
.main-title {
margin: var(--margin-title);
line-height: 20px;
line-height: var(--ha-line-height-condensed);
flex-grow: 1;
}
+1
View File
@@ -14,6 +14,7 @@ export const hassioStyle = css`
margin-bottom: 8px;
font-family: var(--ha-font-family-body);
-webkit-font-smoothing: var(--ha-font-smoothing);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
+1 -1
View File
@@ -4,7 +4,7 @@ export default {
"prettier --cache --write",
"lit-analyzer --quiet",
],
"*.{json,css,md,markdown,html,y?aml}": "prettier --cache --write",
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") +
+33 -34
View File
@@ -26,15 +26,15 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.27.1",
"@babel/runtime": "7.27.6",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.0",
"@codemirror/language": "6.11.1",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.10",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.7",
"@codemirror/view": "6.37.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
@@ -89,14 +89,14 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.8.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.7.5",
"@vaadin/vaadin-themable-mixin": "24.7.5",
"@vaadin/combo-box": "24.7.8",
"@vaadin/vaadin-themable-mixin": "24.7.8",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "3.0.1",
"barcode-detector": "3.0.4",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.42.0",
@@ -111,9 +111,9 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
"hls.js": "1.6.5",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.1",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
@@ -122,7 +122,7 @@
"lit": "3.3.0",
"lit-html": "3.3.0",
"luxon": "3.6.1",
"marked": "15.0.11",
"marked": "15.0.12",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -137,7 +137,6 @@
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.3",
"vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -150,27 +149,27 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.27.1",
"@babel/core": "7.27.4",
"@babel/helper-define-polyfill-provider": "0.6.4",
"@babel/plugin-transform-runtime": "7.27.1",
"@babel/preset-env": "7.27.1",
"@bundle-stats/plugin-webpack-filter": "4.20.0",
"@lokalise/node-api": "14.5.0",
"@octokit/auth-oauth-device": "7.1.5",
"@octokit/plugin-retry": "7.2.1",
"@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "1.0.2",
"@rspack/cli": "1.3.8",
"@rspack/core": "1.3.8",
"@babel/plugin-transform-runtime": "7.27.4",
"@babel/preset-env": "7.27.2",
"@bundle-stats/plugin-webpack-filter": "4.20.2",
"@lokalise/node-api": "14.8.0",
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.1.3",
"@rspack/cli": "1.3.12",
"@rspack/core": "1.3.12",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-receiver": "6.0.22",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.17",
"@types/leaflet-draw": "1.0.11",
"@types/leaflet": "1.9.18",
"@types/leaflet-draw": "1.0.12",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.6.2",
@@ -180,14 +179,14 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.1.2",
"@vitest/coverage-v8": "3.2.2",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.26.0",
"eslint": "9.28.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.2",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-lit": "2.1.1",
@@ -197,7 +196,7 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.0",
"glob": "11.0.2",
"gulp": "5.0.0",
"gulp": "5.0.1",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.0.0",
@@ -205,7 +204,7 @@
"husky": "9.1.7",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"lint-staged": "15.5.1",
"lint-staged": "16.1.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -219,9 +218,9 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.8.3",
"typescript-eslint": "8.31.1",
"typescript-eslint": "8.33.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.1.2",
"vitest": "3.2.2",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -233,9 +232,9 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.0",
"@fullcalendar/daygrid": "6.1.17",
"globals": "16.0.0",
"globals": "16.2.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},
"packageManager": "[email protected].1"
"packageManager": "[email protected].2"
}
+4 -4
View File
@@ -93,8 +93,8 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
background-color: var(--primary-background-color, #fafafa);
}
p {
font-size: 14px;
line-height: 20px;
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
}
.card-content {
background: var(
@@ -151,8 +151,8 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
margin-inline-start: initial;
}
h1 {
font-size: 28px;
font-weight: 400;
font-size: var(--ha-font-size-3xl);
font-weight: var(--ha-font-weight-normal);
margin-top: 16px;
margin-bottom: 16px;
}
+3 -3
View File
@@ -57,9 +57,9 @@ export class HaPickAuthProvider extends LitElement {
position: relative;
z-index: 1;
text-align: center;
font-size: 14px;
font-weight: 400;
line-height: 20px;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal);
}
h3:before {
border-top: 1px solid var(--divider-color);
@@ -10,7 +10,6 @@ import type { LitElement } from "lit";
export interface DragScrollControllerConfig {
selector: string;
enabled?: boolean;
trackScroll?: boolean;
}
export class DragScrollController implements ReactiveController {
@@ -24,10 +23,6 @@ export class DragScrollController implements ReactiveController {
public scrollLeft = 0;
public scrolledStart = false;
public scrolledEnd = false;
private _host: ReactiveControllerHost & LitElement;
private _selector: string;
@@ -36,8 +31,6 @@ export class DragScrollController implements ReactiveController {
private _enabled = true;
private _trackScroll = false;
public get enabled(): boolean {
return this._enabled;
}
@@ -57,11 +50,10 @@ export class DragScrollController implements ReactiveController {
constructor(
host: ReactiveControllerHost & LitElement,
{ selector, enabled, trackScroll }: DragScrollControllerConfig
{ selector, enabled }: DragScrollControllerConfig
) {
this._selector = selector;
this._host = host;
this._trackScroll = trackScroll ?? false;
this.enabled = enabled ?? true;
host.addController(this);
}
@@ -83,14 +75,6 @@ export class DragScrollController implements ReactiveController {
);
if (this._scrollContainer) {
this._scrollContainer.addEventListener("mousedown", this._mouseDown);
if (this._trackScroll) {
this._scrollContainer.addEventListener("scroll", this._onScroll);
this.scrolledStart = this._scrollContainer.scrollLeft > 0;
this.scrolledEnd =
this._scrollContainer.scrollLeft + this._scrollContainer.offsetWidth <
this._scrollContainer.scrollWidth;
this._host.requestUpdate();
}
}
}
@@ -99,34 +83,15 @@ export class DragScrollController implements ReactiveController {
window.removeEventListener("mouseup", this._mouseUp);
if (this._scrollContainer) {
this._scrollContainer.removeEventListener("mousedown", this._mouseDown);
this._scrollContainer.removeEventListener("scroll", this._onScroll);
this._scrollContainer = undefined;
}
this.scrolled = false;
this.scrolling = false;
this.scrolledStart = false;
this.scrolledEnd = false;
this.mouseIsDown = false;
this.scrollStartX = 0;
this.scrollLeft = 0;
}
private _onScroll = (event: Event) => {
const oldScrolledStart = this.scrolledStart;
const oldScrolledEnd = this.scrolledEnd;
const container = event.currentTarget as HTMLElement;
this.scrolledStart = container.scrollLeft > 0;
this.scrolledEnd =
container.scrollLeft + container.offsetWidth < container.scrollWidth;
if (
this.scrolledStart !== oldScrolledStart ||
this.scrolledEnd !== oldScrolledEnd
) {
this._host.requestUpdate();
}
};
private _mouseDown = (event: MouseEvent) => {
const scrollContainer = this._scrollContainer;
-8
View File
@@ -2,7 +2,6 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
import { updateIcon } from "./update_icon";
import { deviceTrackerIcon } from "./device_tracker_icon";
import { batteryIcon } from "./battery_icon";
export const stateIcon = (
stateObj: HassEntity,
@@ -10,17 +9,10 @@ export const stateIcon = (
): string | undefined => {
const domain = computeStateDomain(stateObj);
const compareState = state ?? stateObj.state;
const dc = stateObj.attributes.device_class;
switch (domain) {
case "update":
return updateIcon(stateObj, compareState);
case "sensor":
if (dc === "battery") {
return batteryIcon(stateObj, compareState);
}
break;
case "device_tracker":
return deviceTrackerIcon(stateObj, compareState);
+4
View File
@@ -0,0 +1,4 @@
const validServiceId = /^(\w+)\.(\w+)$/;
export const isValidServiceId = (actionId: string) =>
validServiceId.test(actionId);
+13 -2
View File
@@ -1,9 +1,19 @@
// https://gist.github.com/hagemann/382adfc57adbd5af078dc93feef01fe1
export const slugify = (value: string, delimiter = "_") => {
const a =
"àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·";
const b = `aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz${delimiter}`;
"àáâäæãåāăąабçćčđďдèéêëēėęěеёэфğǵгḧхîïíīįìıİийкłлḿмñńǹňнôöòóœøōõőоṕпŕřрßśšşșсťțтûüùúūǘůűųувẃẍÿýыžźżз·";
const b = `aaaaaaaaaaabcccdddeeeeeeeeeeefggghhiiiiiiiiijkllmmnnnnnoooooooooopprrrsssssstttuuuuuuuuuuvwxyyyzzzz${delimiter}`;
const p = new RegExp(a.split("").join("|"), "g");
const complex_cyrillic = {
ж: "zh",
х: "kh",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "shch",
ю: "iu",
я: "ia",
};
let slugified;
@@ -14,6 +24,7 @@ export const slugify = (value: string, delimiter = "_") => {
.toString()
.toLowerCase()
.replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters
.replace(/[а-я]/g, (c) => complex_cyrillic[c] || "") // Replace some cyrillic characters
.replace(/(\d),(?=\d)/g, "$1") // Remove Commas between numbers
.replace(/[^a-z0-9]+/g, delimiter) // Replace all non-word characters
.replace(new RegExp(`(${delimiter})\\1+`, "g"), "$1") // Replace multiple delimiters with single delimiter
+1 -1
View File
@@ -2,7 +2,7 @@ import type { CSSResult } from "lit";
const _extractCssVars = (
cssString: string,
condition: (string) => boolean = () => true
condition: (string: string) => boolean = () => true
) => {
const variables: Record<string, string> = {};
@@ -0,0 +1,14 @@
import { html } from "lit";
import type { LocalizeFunc } from "./localize";
const MARKDOWN_SUPPORT_URL = "https://commonmark.org/help/";
export const supportsMarkdownHelper = (localize: LocalizeFunc) =>
localize("ui.common.supports_markdown", {
markdown_help_link: html`<a
href=${MARKDOWN_SUPPORT_URL}
target="_blank"
rel="noreferrer"
>${localize("ui.common.markdown")}</a
>`,
});
+72
View File
@@ -0,0 +1,72 @@
import type { LineSeriesOption } from "echarts";
export function downSampleLineData(
data: LineSeriesOption["data"],
chartWidth: number,
minX?: number,
maxX?: number
) {
if (!data || data.length < 10) {
return data;
}
const width = chartWidth * window.devicePixelRatio;
if (data.length <= width) {
return data;
}
const min = minX ?? getPointData(data[0]!)[0];
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.floor((max - min) / width);
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
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } });
} else {
if (frame.min.y > y) {
frame.min = { point, x, y };
}
if (frame.max.y < y) {
frame.max = { point, x, y };
}
}
}
// Convert frames back to points
const result: typeof data = [];
for (const [_i, frame] of frames) {
// Use min/max points to preserve visual accuracy
// The order of the data must be preserved so max may be before min
if (frame.min.x > frame.max.x) {
result.push(frame.max.point);
}
result.push(frame.min.point);
if (frame.min.x < frame.max.x) {
result.push(frame.max.point);
}
}
return result;
}
function getPointData(point: NonNullable<LineSeriesOption["data"]>[number]) {
const pointData =
point && typeof point === "object" && "value" in point
? point.value
: point;
return pointData as number[];
}
+146 -85
View File
@@ -27,6 +27,7 @@ import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array";
import "../chips/ha-assist-chip";
import { downSampleLineData } from "./down-sample";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
@@ -48,7 +49,8 @@ export class HaChartBase extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: false }) public extraComponents?: any[];
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@state()
@consume({ context: themesContext, subscribe: true })
@@ -106,48 +108,49 @@ export class HaChartBase extends LitElement {
})
);
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if (
!this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
if (!this.options?.dataZoom) {
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if (
!this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
}
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
}
};
};
const handleKeyUp = (ev: KeyboardEvent) => {
if (
this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
const handleKeyUp = (ev: KeyboardEvent) => {
if (
this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
}
}
protected firstUpdated() {
@@ -191,16 +194,19 @@ export class HaChartBase extends LitElement {
<div class="chart"></div>
</div>
${this._renderLegend()}
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<div class="chart-controls">
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div>
`;
}
@@ -210,15 +216,15 @@ export class HaChartBase extends LitElement {
return nothing;
}
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
if (!legend.show) {
if (!legend.show || legend.type !== "custom") {
return nothing;
}
const datasets = ensureArray(this.data);
const items = (legend.data ||
datasets
const items: LegendComponentOption["data"] =
legend.data ||
((datasets
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
.map((d) => d.name ?? d.id) ||
[]) as string[];
.map((d) => d.name ?? d.id) || []) as string[]);
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -233,20 +239,32 @@ export class HaChartBase extends LitElement {
})}
>
<ul>
${items.map((item: string, index: number) => {
${items.map((item, index) => {
if (!this.expandLegend && index >= overflowLimit) {
return nothing;
}
const dataset = datasets.find(
(d) => d.id === item || d.name === item
);
const color = dataset?.color as string;
const borderColor = dataset?.itemStyle?.borderColor as string;
let itemStyle: Record<string, any> = {};
let name = "";
if (typeof item === "string") {
name = item;
const dataset = datasets.find(
(d) => d.id === item || d.name === item
);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
};
} else {
name = item.name ?? "";
itemStyle = item.itemStyle ?? {};
}
const color = itemStyle?.color as string;
const borderColor = itemStyle?.borderColor as string;
return html`<li
.name=${item}
.name=${name}
@click=${this._legendClick}
class=${classMap({ hidden: this._hiddenDatasets.has(item) })}
.title=${item}
class=${classMap({ hidden: this._hiddenDatasets.has(name) })}
.title=${name}
>
<div
class="bullet"
@@ -255,7 +273,7 @@ export class HaChartBase extends LitElement {
borderColor: borderColor || color,
})}
></div>
<div class="label">${item}</div>
<div class="label">${name}</div>
</li>`;
})}
${items.length > overflowLimit
@@ -315,7 +333,9 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.getZr().on("dblclick", this._handleClickZoom);
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
}
if (this._isTouchDevice) {
this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) {
@@ -380,9 +400,9 @@ export class HaChartBase extends LitElement {
if (axis.type !== "time" || axis.show === false) {
return axis;
}
if (axis.max && axis.min) {
if (axis.min) {
this._minutesDifference = differenceInMinutes(
axis.max as Date,
(axis.max as Date) || new Date(),
axis.min as Date
);
}
@@ -410,6 +430,12 @@ export class HaChartBase extends LitElement {
} as XAXisOption;
});
}
let legend = this.options?.legend;
if (legend) {
legend = ensureArray(legend).map((l) =>
l.type === "custom" ? { show: false } : l
);
}
const options = {
animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false,
@@ -424,7 +450,7 @@ export class HaChartBase extends LitElement {
iconStyle: { opacity: 0 },
},
...this.options,
legend: { show: false },
legend,
xAxis,
};
@@ -468,6 +494,13 @@ export class HaChartBase extends LitElement {
smooth: false,
},
bar: { itemStyle: { barBorderWidth: 1.5 } },
graph: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
},
},
categoryAxis: {
axisLine: { show: false },
axisTick: { show: false },
@@ -600,19 +633,21 @@ export class HaChartBase extends LitElement {
}
private _getSeries() {
const series = ensureArray(this.data).filter(
(d) => !this._hiddenDatasets.has(String(d.name ?? d.id))
);
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return series.map((d) =>
d.type === "line"
? {
...d,
data: d.data?.map((v) =>
const series = ensureArray(this.data)
.filter((d) => !this._hiddenDatasets.has(String(d.name ?? d.id)))
.map((s) => {
if (s.type === "line") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: s.data?.map((v) =>
Array.isArray(v)
? [
v[0],
@@ -621,10 +656,26 @@ export class HaChartBase extends LitElement {
]
: v
),
}
: d
);
}
};
}
if (s.sampling === "minmax") {
const minX =
xAxis?.min && typeof xAxis.min === "number"
? xAxis.min
: undefined;
const maxX =
xAxis?.max && typeof xAxis.max === "number"
? xAxis.max
: undefined;
return {
...s,
sampling: undefined,
data: downSampleLineData(s.data, this.clientWidth, minX, maxX),
};
}
}
return s;
});
return series;
}
@@ -725,21 +776,31 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.zoom-reset {
.chart-controls {
position: absolute;
top: 16px;
right: 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-controls ha-icon-button,
.chart-controls ::slotted(ha-icon-button) {
background: var(--card-background-color);
border-radius: 4px;
--mdc-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
.chart-controls ha-icon-button.inactive,
.chart-controls ::slotted(ha-icon-button.inactive) {
color: var(--state-inactive-color);
}
.chart-legend {
max-height: 60%;
overflow-y: auto;
padding: 12px 0 0;
font-size: 12px;
font-size: var(--ha-font-size-s);
color: var(--primary-text-color);
}
.chart-legend ul {
+299
View File
@@ -0,0 +1,299 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
export interface NetworkNode {
id: string;
name?: string;
category?: number;
label?: string;
value?: number;
symbolSize?: number;
symbol?: string;
itemStyle?: {
color?: string;
borderColor?: string;
borderWidth?: number;
};
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
*/
polarDistance?: number;
}
export interface NetworkLink {
source: string;
target: string;
value?: number;
reverseValue?: number;
lineStyle?: {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
};
symbolSize?: number | number[];
symbol?: string;
label?: {
show?: boolean;
formatter?: string;
};
ignoreForceLayout?: boolean;
}
export interface NetworkData {
nodes: NetworkNode[];
links: NetworkLink[];
categories?: { name: string; symbol: string }[];
}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@customElement("ha-network-graph")
export class HaNetworkGraph extends LitElement {
public chart?: EChartsType;
@property({ attribute: false }) public data!: NetworkData;
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
public hass!: HomeAssistant;
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
@state() private _showLabels = true;
private _listeners: (() => void)[] = [];
private _nodePositions: Record<string, { x: number; y: number }> = {};
@query("ha-chart-base") private _baseChart?: HaChartBase;
constructor() {
super();
if (!GraphChart) {
import("echarts/lib/chart/graph/install").then((module) => {
GraphChart = module;
this.requestUpdate();
});
}
}
public async connectedCallback() {
super.connectedCallback();
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
}
})
);
}
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
}
protected render() {
if (!GraphChart) {
return nothing;
}
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled,
this._reducedMotion,
this._showLabels
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
>
<slot name="button" slot="button"></slot>
<ha-icon-button
slot="button"
class=${this._physicsEnabled ? "active" : "inactive"}
.path=${mdiGoogleCirclesGroup}
@click=${this._togglePhysics}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_physics"
)}
></ha-icon-button>
<ha-icon-button
slot="button"
class=${this._showLabels ? "active" : "inactive"}
.path=${mdiFormatTextVariant}
@click=${this._toggleLabels}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_labels"
)}
></ha-icon-button>
</ha-chart-base>`;
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
formatter: this.tooltipFormatter,
},
legend: {
show: !!categories?.length,
data: categories?.map((category) => ({
...category,
icon: category.symbol,
})),
top: 8,
},
dataZoom: {
type: "inside",
filterMode: "none",
},
})
);
private _getSeries = memoizeOne(
(
data: NetworkData,
physicsEnabled: boolean,
reducedMotion: boolean,
showLabels: boolean
) => {
const containerWidth = this.clientWidth;
const containerHeight = this.clientHeight;
return [
{
id: "network",
type: "graph",
layout: physicsEnabled ? "force" : "none",
draggable: true,
roam: true,
selectedMode: "single",
label: {
show: showLabels,
position: "right",
},
emphasis: {
focus: "adjacency",
},
force: {
repulsion: [400, 600],
edgeLength: [200, 300],
gravity: 0.1,
layoutAnimation: !reducedMotion && data.nodes.length < 100,
},
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10,
data: data.nodes.map((node) => {
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] =
{
id: node.id,
name: node.name,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
fixed: node.fixed,
};
if (this._nodePositions[node.id]) {
echartsNode.x = this._nodePositions[node.id].x;
echartsNode.y = this._nodePositions[node.id].y;
} else if (typeof node.polarDistance === "number") {
// set the position of the node at polarDistance from the center in a random direction
const angle = Math.random() * 2 * Math.PI;
echartsNode.x =
containerWidth / 2 +
((Math.cos(angle) * containerWidth) / 2) * node.polarDistance;
echartsNode.y =
containerHeight / 2 +
((Math.sin(angle) * containerHeight) / 2) * node.polarDistance;
this._nodePositions[node.id] = {
x: echartsNode.x,
y: echartsNode.y,
};
}
return echartsNode;
}),
links: data.links.map((link) => ({
...link,
value: link.reverseValue
? Math.max(link.value ?? 0, link.reverseValue)
: link.value,
// remove arrow for bidirectional links
symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work
})),
categories: data.categories || [],
},
] as any;
}
);
private _togglePhysics() {
if (this._baseChart?.chart) {
this._baseChart.chart
// @ts-ignore private method but no other way to get the graph positions
.getModel()
.getSeriesByIndex(0)
.getGraph()
.eachNode((node: any) => {
const layout = node.getLayout();
if (layout) {
this._nodePositions[node.id] = {
x: layout[0],
y: layout[1],
};
}
});
}
this._physicsEnabled = !this._physicsEnabled;
}
private _toggleLabels() {
this._showLabels = !this._showLabels;
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-chart-base {
height: 100%;
--chart-max-height: 100%;
}
ha-icon-button,
::slotted(ha-icon-button) {
margin-right: 12px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-network-graph": HaNetworkGraph;
}
interface HASSDomEvents {
"node-selected": { id: string };
}
}
+51 -3
View File
@@ -105,10 +105,41 @@ export class HaSankeyChart extends LitElement {
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))];
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
const depthMap = new Map<number, number>();
indexes.sort().forEach((index, i) => {
const sections: Node[][] = [];
indexes.forEach((index, i) => {
depthMap.set(index, i);
const nodesWithIndex = filteredNodes.filter((n) => n.index === index);
if (nodesWithIndex.length > 0) {
sections.push(
sections.length > 0
? nodesWithIndex.sort((a, b) => {
// sort by the order of their parents in the previous section with orphans at the end
const aParentIndex = this._findParentIndex(
a.id,
data.links,
sections
);
const bParentIndex = this._findParentIndex(
b.id,
data.links,
sections
);
if (aParentIndex === bParentIndex) {
return 0;
}
if (aParentIndex === -1) {
return 1;
}
if (bParentIndex === -1) {
return -1;
}
return aParentIndex - bParentIndex;
})
: nodesWithIndex
);
}
});
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
@@ -117,7 +148,7 @@ export class HaSankeyChart extends LitElement {
return {
id: "sankey",
type: "sankey",
nodes: filteredNodes.map((node) => ({
nodes: sections.flat().map((node) => ({
id: node.id,
value: node.value,
itemStyle: {
@@ -227,6 +258,23 @@ export class HaSankeyChart extends LitElement {
return links;
}
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parent = links.find((l) => l.target === id)?.source;
if (!parent) {
return -1;
}
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
return offset + index;
}
offset += section.length;
}
return -1;
}
static styles = css`
:host {
display: block;
@@ -82,6 +82,8 @@ export class StateHistoryChartLine extends LitElement {
private _chartTime: Date = new Date();
private _previousYAxisLabelValue = 0;
protected render() {
return html`
<ha-chart-base
@@ -227,14 +229,20 @@ export class StateHistoryChartLine extends LitElement {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return Math.abs(value) < 1 ? value : Math.floor(value);
};
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return Math.abs(value) < 1 ? value : Math.ceil(value);
};
}
this._chartOptions = {
xAxis: {
@@ -258,35 +266,11 @@ export class StateHistoryChartLine extends LitElement {
},
axisLabel: {
margin: 5,
formatter: (value: number) => {
const formatOptions =
value >= 1 || value <= -1
? undefined
: {
// show the first significant digit for tiny values
maximumFractionDigits: Math.max(
2,
-Math.floor(Math.log10(Math.abs(value % 1 || 1)))
),
};
const label = formatNumber(
value,
this.hass.locale,
formatOptions
);
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
formatter: this._formatYAxisLabel,
},
} as YAXisOption,
legend: {
type: "custom",
show: this.showNames,
},
grid: {
@@ -744,14 +728,41 @@ export class StateHistoryChartLine extends LitElement {
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
}
private _formatYAxisLabel = (value: number) => {
const formatOptions =
value >= 1 || value <= -1
? undefined
: {
// show the first significant digit for tiny values
maximumFractionDigits: Math.max(
2,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
),
};
const label = formatNumber(value, this.hass.locale, formatOptions);
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
this._previousYAxisLabelValue = value;
return label;
};
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
return Math.max(value, Number.EPSILON);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
return (values: any) => Math.max(value(values), Number.EPSILON);
}
}
return value;
+11 -4
View File
@@ -241,14 +241,20 @@ export class StatisticsChart extends LitElement {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return Math.abs(value) < 1 ? value : Math.floor(value);
};
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return Math.abs(value) < 1 ? value : Math.ceil(value);
};
}
const endTime = this.endTime ?? new Date();
let startTime = this.startTime;
@@ -308,6 +314,7 @@ export class StatisticsChart extends LitElement {
},
},
legend: {
type: "custom",
show: !this.hideLegend,
data: this._legendData,
},
@@ -618,10 +625,10 @@ export class StatisticsChart extends LitElement {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, 0.1);
return Math.max(value, Number.EPSILON);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1);
return (values: any) => Math.max(value(values), Number.EPSILON);
}
}
return value;
+1 -1
View File
@@ -60,7 +60,7 @@ export class HaAssistChip extends AssistChip {
opacity: var(--ha-assist-chip-active-container-opacity);
}
.label {
font-family: Roboto, sans-serif;
font-family: var(--ha-font-family-body);
}
`,
];
+113 -27
View File
@@ -164,6 +164,8 @@ export class HaDataTable extends LitElement {
@state() private _collapsedGroups: string[] = [];
@state() private _lastSelectedRowId: string | null = null;
private _checkableRowsCount?: number;
private _checkedRows: string[] = [];
@@ -187,6 +189,7 @@ export class HaDataTable extends LitElement {
public clearSelection(): void {
this._checkedRows = [];
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -194,6 +197,7 @@ export class HaDataTable extends LitElement {
this._checkedRows = this._filteredData
.filter((data) => data.selectable !== false)
.map((data) => data[this.id]);
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -207,6 +211,7 @@ export class HaDataTable extends LitElement {
this._checkedRows.push(id);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -217,6 +222,7 @@ export class HaDataTable extends LitElement {
this._checkedRows.splice(index, 1);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -261,6 +267,7 @@ export class HaDataTable extends LitElement {
if (this.columns[columnId].direction) {
this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId;
this._lastSelectedRowId = null;
fireEvent(this, "sorting-changed", {
column: columnId,
@@ -286,6 +293,7 @@ export class HaDataTable extends LitElement {
if (properties.has("filter")) {
this._debounceSearch(this.filter);
this._lastSelectedRowId = null;
}
if (properties.has("data")) {
@@ -296,9 +304,11 @@ export class HaDataTable extends LitElement {
if (!this.hasUpdated && this.initialCollapsedGroups) {
this._collapsedGroups = this.initialCollapsedGroups;
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} else if (properties.has("groupColumn")) {
this._collapsedGroups = [];
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
@@ -312,6 +322,14 @@ export class HaDataTable extends LitElement {
this._sortFilterData();
}
if (
properties.has("_filter") ||
properties.has("sortColumn") ||
properties.has("sortDirection")
) {
this._lastSelectedRowId = null;
}
if (properties.has("selectable") || properties.has("hiddenColumns")) {
this._filteredData = [...this._filteredData];
}
@@ -542,7 +560,7 @@ export class HaDataTable extends LitElement {
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@change=${this._handleRowCheckboxClick}
@click=${this._handleRowCheckboxClicked}
.rowId=${row[this.id]}
.disabled=${row.selectable === false}
.checked=${this._checkedRows.includes(String(row[this.id]))}
@@ -722,8 +740,10 @@ export class HaDataTable extends LitElement {
}, {});
const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => {
const collapsed = collapsedGroups.includes(groupName);
groupedItems.push({
append: true,
selectable: false,
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
@@ -732,9 +752,10 @@ export class HaDataTable extends LitElement {
>
<ha-icon-button
.path=${mdiChevronUp}
class=${collapsedGroups.includes(groupName)
? "collapsed"
: ""}
.label=${this.hass.localize(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
)}
class=${collapsed ? "collapsed" : ""}
>
</ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY
@@ -750,7 +771,7 @@ export class HaDataTable extends LitElement {
}
if (appendRow) {
items.push({ append: true, content: appendRow });
items.push({ append: true, selectable: false, content: appendRow });
}
if (hasFab) {
@@ -800,23 +821,84 @@ export class HaDataTable extends LitElement {
this._checkedRows = [];
this._checkedRowsChanged();
}
this._lastSelectedRowId = null;
}
private _handleRowCheckboxClick = (ev: Event) => {
private _handleRowCheckboxClicked = (ev: Event) => {
const checkbox = ev.currentTarget as HaCheckbox;
const rowId = (checkbox as any).rowId;
if (checkbox.checked) {
if (this._checkedRows.includes(rowId)) {
return;
const groupedData = this._groupData(
this._filteredData,
this.localizeFunc || this.hass.localize,
this.appendRow,
this.hasFab,
this.groupColumn,
this.groupOrder,
this._collapsedGroups
);
if (
groupedData.find((data) => data[this.id] === rowId)?.selectable === false
) {
return;
}
const rowIndex = groupedData.findIndex((data) => data[this.id] === rowId);
if (
ev instanceof MouseEvent &&
ev.shiftKey &&
this._lastSelectedRowId !== null
) {
const lastSelectedRowIndex = groupedData.findIndex(
(data) => data[this.id] === this._lastSelectedRowId
);
if (lastSelectedRowIndex > -1 && rowIndex > -1) {
this._checkedRows = [
...this._checkedRows,
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
];
}
} else if (!checkbox.checked) {
if (!this._checkedRows.includes(rowId)) {
this._checkedRows = [...this._checkedRows, rowId];
}
this._checkedRows = [...this._checkedRows, rowId];
} else {
this._checkedRows = this._checkedRows.filter((row) => row !== rowId);
}
if (rowIndex > -1) {
this._lastSelectedRowId = rowId;
}
this._checkedRowsChanged();
};
private _selectRange(
groupedData: DataTableRowData[],
startIndex: number,
endIndex: number
) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const checkedRows: string[] = [];
for (let i = start; i <= end; i++) {
const row = groupedData[i];
if (
row &&
row.selectable !== false &&
!this._checkedRows.includes(row[this.id])
) {
checkedRows.push(row[this.id]);
}
}
return checkedRows;
}
private _handleRowClick = (ev: Event) => {
if (
ev
@@ -858,6 +940,7 @@ export class HaDataTable extends LitElement {
if (this.filter) {
return;
}
this._lastSelectedRowId = null;
this._debounceSearch(ev.detail.value);
}
@@ -894,11 +977,13 @@ export class HaDataTable extends LitElement {
} else {
this._collapsedGroups = [...this._collapsedGroups, groupName];
}
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
};
public expandAllGroups() {
this._collapsedGroups = [];
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
@@ -916,6 +1001,7 @@ export class HaDataTable extends LitElement {
delete grouped.undefined;
}
this._collapsedGroups = Object.keys(grouped);
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
@@ -928,12 +1014,12 @@ export class HaDataTable extends LitElement {
height: 100%;
}
.mdc-data-table__content {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 400;
line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em;
text-decoration: inherit;
text-transform: inherit;
@@ -1048,12 +1134,12 @@ export class HaDataTable extends LitElement {
}
.mdc-data-table__cell {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: 400;
line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em;
text-decoration: inherit;
text-transform: inherit;
@@ -1170,12 +1256,12 @@ export class HaDataTable extends LitElement {
}
.mdc-data-table__header-cell {
font-family: Roboto, sans-serif;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
font-size: 0.875rem;
line-height: 1.375rem;
font-weight: 500;
font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-normal);
font-weight: var(--ha-font-weight-medium);
letter-spacing: 0.0071428571em;
text-decoration: inherit;
text-transform: inherit;
@@ -1199,7 +1285,7 @@ export class HaDataTable extends LitElement {
padding-inline-start: 12px;
padding-inline-end: initial;
width: 100%;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
display: flex;
align-items: center;
cursor: pointer;
@@ -12,6 +12,7 @@ import type { EntityRegistryEntry } from "../../data/entity_registry";
import type { HomeAssistant } from "../../types";
import "../ha-list-item";
import "../ha-select";
import { stopPropagation } from "../../common/dom/stop_propagation";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";
@@ -103,6 +104,7 @@ export abstract class HaDeviceAutomationPicker<
.label=${this.label}
.value=${value}
@selected=${this._automationChanged}
@closed=${stopPropagation}
.disabled=${this._automations.length === 0}
>
${value === NO_AUTOMATION_KEY
+195 -151
View File
@@ -1,33 +1,28 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { stringCompare } from "../../common/string/compare";
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "../../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item";
interface Device {
name: string;
area: string;
id: string;
}
type ScorableDevice = ScorableTextItem & Device;
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -35,25 +30,35 @@ export type HaDevicePickerDeviceFilterFunc = (
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
const rowRenderer: ComboBoxLitRenderer<Device> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.name}</span>
${item.area
? html`<span slot="supporting-text">${item.area}</span>`
: nothing}
</ha-combo-box-item>
`;
interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
}
@customElement("ha-device-picker")
export class HaDevicePicker extends LitElement {
@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 required = false;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
* Show only devices with entities from specific domains.
* @type {Array}
@@ -92,38 +97,52 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaDevicePickerEntityFilterFunc;
@property({ type: Boolean }) public disabled = false;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _opened?: boolean;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@query("ha-combo-box", true) public comboBox!: HaComboBox;
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._loadConfigEntries();
}
private _init = false;
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _getItems = () =>
this._getDevices(
this.hass.devices,
this.hass.entities,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeDevices
);
private _getDevices = memoizeOne(
(
devices: DeviceRegistryEntry[],
areas: HomeAssistant["areas"],
entities: EntityRegistryDisplayEntry[],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
configEntryLookup: Record<string, ConfigEntry>,
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeDevices: this["excludeDevices"]
): ScorableDevice[] => {
if (!devices.length) {
return [
{
id: "no_devices",
area: "",
name: this.hass.localize("ui.components.device-picker.no_devices"),
strings: [],
},
];
}
): DevicePickerItem[] => {
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
@@ -214,133 +233,158 @@ export class HaDevicePicker extends LitElement {
);
}
const outputDevices = inputDevices.map((device) => {
const name = computeDeviceNameDisplay(
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
const deviceName = computeDeviceNameDisplay(
device,
this.hass,
deviceEntityLookup[device.id]
);
const { area } = getDeviceContext(device, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
const domainName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return {
id: device.id,
name:
name ||
label: "",
primary:
deviceName ||
this.hass.localize("ui.components.device-picker.unnamed_device"),
area:
device.area_id && areas[device.area_id]
? areas[device.area_id].name
: this.hass.localize("ui.components.device-picker.no_area"),
strings: [name || ""],
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
sorting_label: deviceName || "zzz",
};
});
if (!outputDevices.length) {
return [
{
id: "no_devices",
area: "",
name: this.hass.localize("ui.components.device-picker.no_match"),
strings: [],
},
];
}
if (outputDevices.length === 1) {
return outputDevices;
}
return outputDevices.sort((a, b) =>
stringCompare(a.name || "", b.name || "", this.hass.locale.language)
);
return outputDevices;
}
);
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
}
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const devices = this._getDevices(
Object.values(this.hass.devices),
this.hass.areas,
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeDevices
);
this.comboBox.items = devices;
this.comboBox.filteredItems = devices;
const { area } = getDeviceContext(device, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
/>`
: nothing}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
}
}
);
private _rowRenderer: ComboBoxLitRenderer<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
})}
/>
`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
protected render() {
const placeholder =
this.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);
protected render(): TemplateResult {
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.device-picker.device")
: this.label}
.value=${this._value}
.helper=${this.helper}
.renderer=${rowRenderer}
.disabled=${this.disabled}
.required=${this.required}
item-id-path="id"
item-value-path="id"
item-label-path="name"
@opened-changed=${this._openedChanged}
@value-changed=${this._deviceChanged}
@filter-changed=${this._filterChanged}
></ha-combo-box>
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private get _value() {
return this.value || "";
public async open() {
await this.updateComplete;
await this._picker?.open();
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.toLowerCase();
target.filteredItems = filterString.length
? fuzzyFilterSort<ScorableDevice>(filterString, target.items || [])
: target.items;
}
private _deviceChanged(ev: ValueChangedEvent<string>) {
private _valueChanged(ev) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === "no_devices") {
newValue = "";
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _setValue(value: string) {
const value = ev.detail.value;
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
fireEvent(this, "value-changed", { value });
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "./ha-device-picker";
import type {
HaDevicePickerDeviceFilterFunc,
+2 -2
View File
@@ -4,8 +4,8 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HaEntityComboBoxEntityFilterFunc } from "./ha-entity-combo-box";
import "./ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {
@@ -72,7 +72,7 @@ class HaEntitiesPicker extends LitElement {
public excludeEntities?: string[];
@property({ attribute: false })
public entityFilter?: HaEntityComboBoxEntityFilterFunc;
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: false, type: Array }) public createDomains?: string[];
@@ -1,510 +0,0 @@
import { mdiMagnify, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import type { HelperDomain } from "../../panels/config/helpers/const";
import { isHelperDomain } from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item";
import "../ha-icon-button";
import "../ha-svg-icon";
import "./state-badge";
interface EntityComboBoxItem {
// Force empty label to always display empty value by default in the search field
id: string;
label: "";
primary: string;
secondary?: string;
domain_name?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
stateObj?: HassEntity;
}
export type HaEntityComboBoxEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
const NO_ENTITIES_ID = "___no-entities___";
@customElement("ha-entity-combo-box")
export class HaEntityComboBox extends LitElement {
@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 required = false;
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
* Show entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* Show only entities with these unit of measuments.
* @type {Array}
* @attr include-unit-of-measurement
*/
@property({ type: Array, attribute: "include-unit-of-measurement" })
public includeUnitOfMeasurement?: string[];
/**
* List of allowed entities to show.
* @type {Array}
* @attr include-entities
*/
@property({ type: Array, attribute: "include-entities" })
public includeEntities?: string[];
/**
* List of entities to be excluded.
* @type {Array}
* @attr exclude-entities
*/
@property({ type: Array, attribute: "exclude-entities" })
public excludeEntities?: string[];
@property({ attribute: false })
public entityFilter?: HaEntityComboBoxEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@state() private _opened = false;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _initialItems = false;
private _items: EntityComboBoxItem[] = [];
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.hass.loadBackendTranslation("title");
}
private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
`
: html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${item.stateObj.entity_id}
</span>
`
: nothing}
${item.domain_name && !showEntityId
? html`
<div slot="trailing-supporting-text">${item.domain_name}</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _getItems = memoizeOne(
(
_opened: boolean,
hass: this["hass"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"],
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"],
createDomains: this["createDomains"]
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(hass.states);
const createItems = createDomains?.length
? createDomains.map((domain) => {
const primary = hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? hass.localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
)
: domainToName(hass.localize, domain),
}
);
return {
id: CREATE_ID + domain,
label: "",
primary: primary,
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
})
: [];
if (!entityIds.length) {
return [
{
id: NO_ENTITIES_ID,
label: "",
primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_entities"
),
icon_path: mdiMagnify,
},
...createItems,
];
}
if (includeEntities) {
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
const isRTL = computeRTL(this.hass);
items = entityIds
.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: entityId,
label: "",
primary: primary,
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
stateObj: stateObj,
};
})
.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
this.hass.locale.language
)
);
if (includeDeviceClasses) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.device_class &&
includeDeviceClasses.includes(
item.stateObj.attributes.device_class
))
);
}
if (includeUnitOfMeasurement) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes(
item.stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilter) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj && entityFilter!(item.stateObj))
);
}
if (!items.length) {
return [
{
id: NO_ENTITIES_ID,
label: "",
primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_match"
),
icon_path: mdiMagnify,
},
...createItems,
];
}
if (createItems?.length) {
items.push(...createItems);
}
return items;
}
);
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
public willUpdate(changedProps: PropertyValues) {
if (!this._initialItems || (changedProps.has("_opened") && this._opened)) {
this._items = this._getItems(
this._opened,
this.hass,
this.includeDomains,
this.excludeDomains,
this.entityFilter,
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities,
this.createDomains
);
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
if (changedProps.has("createDomains") && this.createDomains?.length) {
this.hass.loadFragmentTranslation("config");
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
item-value-path="id"
.hass=${this.hass}
.value=${this._value}
.label=${this.label === undefined
? this.hass.localize("ui.components.entity.entity-picker.entity")
: this.label}
.helper=${this.helper}
.allowCustomValue=${this.allowCustomEntity}
.filteredItems=${this._items}
.renderer=${this._rowRenderer}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
`;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
// Clear the input field to prevent showing the old value next time
this.comboBox.setTextFieldValue("");
const newValue = ev.detail.value?.trim();
if (newValue && newValue.startsWith(CREATE_ID)) {
const domain = newValue.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
},
});
return;
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _fuseIndex = memoizeOne((states: EntityComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.trim().toLowerCase() as string;
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, {}, index);
const results = fuse.multiTermsSearch(filterString);
if (results) {
if (results.length === 0) {
target.filteredItems = [
{
id: NO_ENTITIES_ID,
label: "",
primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_match"
),
icon_path: mdiMagnify,
},
] as EntityComboBoxItem[];
} else {
target.filteredItems = results.map((result) => result.item);
}
} else {
target.filteredItems = this._items;
}
}
private _setValue(value: string | undefined) {
if (!value || !isValidEntityId(value)) {
return;
}
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-combo-box": HaEntityComboBox;
}
}
+337 -191
View File
@@ -1,27 +1,45 @@
import { mdiClose, mdiMenuDown, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { mdiPlus, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { domainToName } from "../../data/integration";
import {
isHelperDomain,
type HelperDomain,
} from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-icon-button";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-svg-icon";
import "./ha-entity-combo-box";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type {
HaEntityComboBox,
HaEntityComboBoxEntityFilterFunc,
} from "./ha-entity-combo-box";
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon";
import "./state-badge";
interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
}
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -36,6 +54,9 @@ export class HaEntityPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ type: Boolean, attribute: "show-entity-id" })
public showEntityId = false;
@property() public label?: string;
@property() public value?: string;
@@ -44,6 +65,9 @@ export class HaEntityPicker extends LitElement {
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
@@ -95,50 +119,32 @@ export class HaEntityPicker extends LitElement {
public excludeEntities?: string[];
@property({ attribute: false })
public entityFilter?: HaEntityComboBoxEntityFilterFunc;
public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@query("#anchor") private _anchor?: HaMdListItem;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("#input") private _input?: HaEntityComboBox;
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
// Load title translations so it is available when the combo-box opens
this.hass.loadBackendTranslation("title");
}
@state() private _opened = false;
private _renderContent() {
const entityId = this.value || "";
if (!this.value) {
return html`
<span slot="headline" class="placeholder"
>${this.placeholder ??
this.hass.localize(
"ui.components.entity.entity-picker.placeholder"
)}</span
>
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
private _valueRenderer: PickerValueRenderer = (value) => {
const entityId = value || "";
const stateObj = this.hass.states[entityId];
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
if (!stateObj) {
return html`
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<ha-svg-icon
slot="start"
.path=${mdiShape}
style="margin: 0 4px"
></ha-svg-icon>
<span slot="headline">${entityId}</span>
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
@@ -163,169 +169,309 @@ export class HaEntityPicker extends LitElement {
></state-badge>
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
};
private get _showEntityId() {
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
}
protected render() {
private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this._showEntityId;
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
${!this._opened
? html`<ha-combo-box-item
.disabled=${this.disabled}
id="anchor"
type="button"
compact
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>`
: html`<ha-entity-combo-box
id="input"
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomEntity=${this.allowCustomEntity}
.label=${this.hass.localize("ui.common.search")}
.value=${this.value}
.createDomains=${this.createDomains}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeUnitOfMeasurement=${this.includeUnitOfMeasurement}
.includeEntities=${this.includeEntities}
.excludeEntities=${this.excludeEntities}
.entityFilter=${this.entityFilter}
hide-clear-icon
@opened-changed=${this._debounceOpenedChanged}
@input=${stopPropagation}
></ha-entity-combo-box>`}
${this._renderHelper()}
</div>
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon
slot="start"
style="margin: 0 4px"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${item.stateObj.entity_id}
</span>
`
: nothing}
${item.domain_name && !showEntityId
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
}
};
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
private _getAdditionalItems = () =>
this._getCreateItems(this.hass.localize, this.createDomains);
private _clear(e) {
e.stopPropagation();
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
private _getCreateItems = memoizeOne(
(
localize: this["hass"]["localize"],
createDomains: this["createDomains"]
) => {
if (!createDomains?.length) {
return [];
}
private async _showPicker() {
if (this.disabled) {
return;
return createDomains.map((domain) => {
const primary = localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
)
: domainToName(localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: localize("ui.components.entity.entity-picker.new_entity"),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
}
this._opened = true;
await this.updateComplete;
this._input?.focus();
this._input?.open();
}
// Multiple calls to _openedChanged can be triggered in quick succession
// when the menu is opened
private _debounceOpenedChanged = debounce(
(ev) => this._openedChanged(ev),
10
);
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._anchor?.focus();
private _getItems = () =>
this._getEntities(
this.hass,
this.includeDomains,
this.excludeDomains,
this.entityFilter,
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities
);
private _getEntities = memoizeOne(
(
hass: this["hass"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"],
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"]
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(hass.states);
if (includeEntities) {
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
const isRTL = computeRTL(this.hass);
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return {
id: entityId,
primary: primary,
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj,
};
});
if (includeDeviceClasses) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.device_class &&
includeDeviceClasses.includes(
item.stateObj.attributes.device_class
))
);
}
if (includeUnitOfMeasurement) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes(
item.stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilter) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj && entityFilter!(item.stateObj))
);
}
return items;
}
);
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.entity.entity-picker.no_match"
);
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.helper=${this.helper}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
static get styles(): CSSResultGroup {
return [
css`
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
.container {
position: relative;
display: block;
}
ha-combo-box-item {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: 4px;
border-end-end-radius: 0;
border-end-start-radius: 0;
--md-list-item-one-line-container-height: 56px;
--md-list-item-two-line-container-height: 56px;
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--ha-md-list-item-gap: 8px;
/* Remove the default focus ring */
--md-focus-ring-width: 0px;
--md-focus-ring-duration: 0s;
}
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
search,
filteredItems
) => {
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex(
(item) => item.stateObj?.entity_id === search
);
if (index === -1) {
return filteredItems;
}
/* Add Similar focus style as the text field */
ha-combo-box-item:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
ha-combo-box-item:focus:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
public async open() {
await this.updateComplete;
await this._picker?.open();
}
ha-combo-box-item ha-svg-icon[slot="start"] {
margin: 0 4px;
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.edit {
--mdc-icon-size: 20px;
width: 32px;
}
label {
display: block;
margin: 0 0 8px;
}
.placeholder {
color: var(--secondary-text-color);
padding: 0 8px;
}
`,
];
private _valueChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
return;
}
if (value.startsWith(CREATE_ID)) {
const domain = value.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
},
});
return;
}
if (!isValidEntityId(value)) {
return;
}
this._setValue(value);
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
}
@@ -267,7 +267,7 @@ export class HaStateLabelBadge extends LitElement {
cursor: pointer;
}
.big {
font-size: 70%;
font-size: var(--ha-font-size-xs);
}
ha-label-badge {
--ha-label-badge-color: var(--label-badge-red);
@@ -1,481 +0,0 @@
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import type { StatisticsMetaData } from "../../data/recorder";
import { getStatisticIds, getStatisticLabel } from "../../data/recorder";
import { HaFuse } from "../../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item";
import "../ha-svg-icon";
import "./state-badge";
import { documentationUrl } from "../../util/documentation-url";
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticItem {
// Force empty label to always display empty value by default in the search field
id: string;
statistic_id?: string;
label: "";
primary: string;
secondary?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
type?: StatisticItemType;
stateObj?: HassEntity;
}
const MISSING_ID = "___missing-entity___";
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
@customElement("ha-statistic-combo-box")
export class HaStatisticComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property({ attribute: "statistic-types" })
public statisticTypes?: "mean" | "sum";
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ type: Boolean }) public disabled = false;
/**
* Show only statistics natively stored with these units of measurements.
* @type {Array}
* @attr include-statistics-unit-of-measurement
*/
@property({
type: Array,
attribute: "include-statistics-unit-of-measurement",
})
public includeStatisticsUnitOfMeasurement?: string | string[];
/**
* Show only statistics with these unit classes.
* @attr include-unit-class
*/
@property({ attribute: "include-unit-class" })
public includeUnitClass?: string | string[];
/**
* Show only statistics with these device classes.
* @attr include-device-class
*/
@property({ attribute: "include-device-class" })
public includeDeviceClass?: string | string[];
/**
* Show only statistics on entities.
* @type {Boolean}
* @attr entities-only
*/
@property({ type: Boolean, attribute: "entities-only" })
public entitiesOnly = false;
/**
* List of statistics to be excluded.
* @type {Array}
* @attr exclude-statistics
*/
@property({ type: Array, attribute: "exclude-statistics" })
public excludeStatistics?: string[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
@state() private _opened = false;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _initialItems = false;
private _items: StatisticItem[] = [];
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.hass.loadBackendTranslation("title");
}
private _rowRenderer: ComboBoxLitRenderer<StatisticItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon
style="margin: 0 4px"
slot="start"
.path=${item.icon_path}
></ha-svg-icon>
`
: item.stateObj
? html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
<span slot="headline">${item.primary} </span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.id && showEntityId
? html`<span slot="supporting-text" class="code">
${item.statistic_id}
</span>`
: nothing}
</ha-combo-box-item>
`;
};
private _getItems = memoizeOne(
(
_opened: boolean,
hass: this["hass"],
statisticIds: StatisticsMetaData[],
includeStatisticsUnitOfMeasurement?: string | string[],
includeUnitClass?: string | string[],
includeDeviceClass?: string | string[],
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticItem[] => {
if (!statisticIds.length) {
return [
{
id: "",
label: "",
primary: this.hass.localize(
"ui.components.statistic-picker.no_statistics"
),
},
];
}
if (includeStatisticsUnitOfMeasurement) {
const includeUnits: (string | null)[] = ensureArray(
includeStatisticsUnitOfMeasurement
);
statisticIds = statisticIds.filter((meta) =>
includeUnits.includes(meta.statistics_unit_of_measurement)
);
}
if (includeUnitClass) {
const includeUnitClasses: (string | null)[] =
ensureArray(includeUnitClass);
statisticIds = statisticIds.filter((meta) =>
includeUnitClasses.includes(meta.unit_class)
);
}
if (includeDeviceClass) {
const includeDeviceClasses: (string | null)[] =
ensureArray(includeDeviceClass);
statisticIds = statisticIds.filter((meta) => {
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
return true;
}
return includeDeviceClasses.includes(
stateObj.attributes.device_class || ""
);
});
}
const isRTL = computeRTL(this.hass);
const output: StatisticItem[] = [];
statisticIds.forEach((meta) => {
if (
excludeStatistics &&
meta.statistic_id !== value &&
excludeStatistics.includes(meta.statistic_id)
) {
return;
}
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
if (!entitiesOnly) {
const id = meta.statistic_id;
const label = getStatisticLabel(this.hass, meta.statistic_id, meta);
const type =
meta.statistic_id.includes(":") &&
!meta.statistic_id.includes(".")
? "external"
: "no_state";
if (type === "no_state") {
output.push({
id,
primary: label,
secondary: this.hass.localize(
"ui.components.statistic-picker.no_state"
),
label: "",
type,
sorting_label: label,
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
output.push({
id,
statistic_id: id,
primary: label,
secondary: domainName,
label: "",
type,
sorting_label: label,
search_labels: [label, domainName, id],
icon_path: mdiChartLine,
});
}
}
return;
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
output.push({
id,
statistic_id: id,
label: "",
primary,
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
id,
].filter(Boolean) as string[],
});
});
if (!output.length) {
return [
{
id: "",
primary: this.hass.localize(
"ui.components.statistic-picker.no_match"
),
label: "",
},
];
}
if (output.length > 1) {
output.sort((a, b) => {
const aPrefix = TYPE_ORDER.indexOf(a.type || "no_state");
const bPrefix = TYPE_ORDER.indexOf(b.type || "no_state");
return caseInsensitiveStringCompare(
`${aPrefix}_${a.sorting_label || ""}`,
`${bPrefix}_${b.sorting_label || ""}`,
this.hass.locale.language
);
});
}
output.push({
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
label: "",
icon_path: mdiHelpCircle,
});
return output;
}
);
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
public willUpdate(changedProps: PropertyValues) {
if (
(!this.hasUpdated && !this.statisticIds) ||
changedProps.has("statisticTypes")
) {
this._getStatisticIds();
}
if (
this.statisticIds &&
(!this._initialItems || (changedProps.has("_opened") && this._opened))
) {
this._items = this._getItems(
this._opened,
this.hass,
this.statisticIds!,
this.includeStatisticsUnitOfMeasurement,
this.includeUnitClass,
this.includeDeviceClass,
this.entitiesOnly,
this.excludeStatistics,
this.value
);
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
}
protected render(): TemplateResult | typeof nothing {
if (this._items.length === 0) {
return nothing;
}
return html`
<ha-combo-box
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.statistic-picker.statistic")
: this.label}
.value=${this._value}
.renderer=${this._rowRenderer}
.disabled=${this.disabled}
.allowCustomValue=${this.allowCustomEntity}
.filteredItems=${this._items}
item-value-path="id"
item-id-path="id"
item-label-path="label"
@opened-changed=${this._openedChanged}
@value-changed=${this._statisticChanged}
@filter-changed=${this._filterChanged}
></ha-combo-box>
`;
}
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
}
private get _value() {
return this.value || "";
}
private _statisticChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === MISSING_ID) {
newValue = "";
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _fuseIndex = memoizeOne((states: StatisticItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.trim().toLowerCase() as string;
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, {}, index);
const results = fuse.multiTermsSearch(filterString);
if (results) {
target.filteredItems = results.map((result) => result.item);
} else {
target.filteredItems = this._items;
}
}
private _setValue(value: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-statistic-combo-box": HaStatisticComboBox;
}
}
+323 -204
View File
@@ -1,45 +1,48 @@
import { mdiChartLine, mdiClose, mdiMenuDown, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon";
import "./ha-entity-combo-box";
import type { HaEntityComboBox } from "./ha-entity-combo-box";
import "./ha-statistic-combo-box";
import "./state-badge";
interface StatisticItem {
primary: string;
secondary?: string;
iconPath?: string;
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
const MISSING_ID = "___missing-entity___";
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string;
stateObj?: HassEntity;
type?: StatisticItemType;
}
@customElement("ha-statistic-picker")
@@ -70,6 +73,9 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
/**
* Show only statistics natively stored with these units of measurements.
* @type {Array}
@@ -114,11 +120,7 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@query("#anchor") private _anchor?: HaMdListItem;
@query("#input") private _input?: HaEntityComboBox;
@state() private _opened = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public willUpdate(changedProps: PropertyValues) {
if (
@@ -133,6 +135,167 @@ export class HaStatisticPicker extends LitElement {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
}
private _getItems = () =>
this._getStatisticsItems(
this.hass,
this.statisticIds,
this.includeStatisticsUnitOfMeasurement,
this.includeUnitClass,
this.includeDeviceClass,
this.entitiesOnly,
this.excludeStatistics,
this.value
);
private _getAdditionalItems(): StatisticComboBoxItem[] {
return [
{
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
icon_path: mdiHelpCircle,
},
];
}
private _getStatisticsItems = memoizeOne(
(
hass: HomeAssistant,
statisticIds?: StatisticsMetaData[],
includeStatisticsUnitOfMeasurement?: string | string[],
includeUnitClass?: string | string[],
includeDeviceClass?: string | string[],
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticComboBoxItem[] => {
if (!statisticIds) {
return [];
}
if (includeStatisticsUnitOfMeasurement) {
const includeUnits: (string | null)[] = ensureArray(
includeStatisticsUnitOfMeasurement
);
statisticIds = statisticIds.filter((meta) =>
includeUnits.includes(meta.statistics_unit_of_measurement)
);
}
if (includeUnitClass) {
const includeUnitClasses: (string | null)[] =
ensureArray(includeUnitClass);
statisticIds = statisticIds.filter((meta) =>
includeUnitClasses.includes(meta.unit_class)
);
}
if (includeDeviceClass) {
const includeDeviceClasses: (string | null)[] =
ensureArray(includeDeviceClass);
statisticIds = statisticIds.filter((meta) => {
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
return true;
}
return includeDeviceClasses.includes(
stateObj.attributes.device_class || ""
);
});
}
const isRTL = computeRTL(this.hass);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
if (
excludeStatistics &&
meta.statistic_id !== value &&
excludeStatistics.includes(meta.statistic_id)
) {
return;
}
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
if (!entitiesOnly) {
const id = meta.statistic_id;
const label = getStatisticLabel(this.hass, meta.statistic_id, meta);
const type =
meta.statistic_id.includes(":") &&
!meta.statistic_id.includes(".")
? "external"
: "no_state";
const sortingPrefix = `${TYPE_ORDER.indexOf(type)}`;
if (type === "no_state") {
output.push({
id,
primary: label,
secondary: this.hass.localize(
"ui.components.statistic-picker.no_state"
),
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
output.push({
id,
statistic_id: id,
primary: label,
secondary: domainName,
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, id],
icon_path: mdiChartLine,
});
}
}
return;
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
id,
statistic_id: id,
primary,
secondary,
a11y_label: a11yLabel,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
id,
].filter(Boolean) as string[],
});
});
return output;
}
);
private _statisticMetaData = memoizeOne(
(statisticId: string, statisticIds: StatisticsMetaData[]) => {
if (!statisticIds) {
@@ -144,26 +307,11 @@ export class HaStatisticPicker extends LitElement {
}
);
private _renderContent() {
const statisticId = this.value || "";
if (!this.value) {
return html`
<span slot="headline" class="placeholder"
>${this.placeholder ??
this.hass.localize(
"ui.components.statistic-picker.placeholder"
)}</span
>
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
private _valueRenderer: PickerValueRenderer = (value) => {
const statisticId = value;
const item = this._computeItem(statisticId);
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
return html`
${item.stateObj
? html`
@@ -173,29 +321,19 @@ export class HaStatisticPicker extends LitElement {
slot="start"
></state-badge>
`
: item.iconPath
? html`<ha-svg-icon
slot="start"
.path=${item.iconPath}
></ha-svg-icon>`
: item.icon_path
? html`
<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
};
private _computeItem(statisticId: string): StatisticItem {
private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
@@ -211,11 +349,24 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
id: statisticId,
statistic_id: statisticId,
primary,
secondary,
stateObj,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
statisticId,
].filter(Boolean) as string[],
};
}
@@ -230,175 +381,143 @@ export class HaStatisticPicker extends LitElement {
: "no_state";
if (type === "external") {
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic);
const domain = statisticId.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
return {
id: statisticId,
statistic_id: statisticId,
primary: label,
secondary: domainName,
iconPath: mdiChartLine,
type: "external",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, statisticId],
icon_path: mdiChartLine,
};
}
}
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic);
return {
primary: statisticId,
iconPath: mdiShape,
id: statisticId,
primary: label,
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, statisticId],
icon_path: mdiShape,
};
}
protected render() {
private _rowRenderer: ComboBoxLitRenderer<StatisticComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
${!this._opened
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-combo-box-item
.disabled=${this.disabled}
id="anchor"
type="button"
compact
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>
<ha-svg-icon
style="margin: 0 4px"
slot="start"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<ha-statistic-combo-box
id="input"
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomEntity=${this.allowCustomEntity}
.label=${this.hass.localize("ui.common.search")}
.value=${this.value}
.includeStatisticsUnitOfMeasurement=${this
.includeStatisticsUnitOfMeasurement}
.includeUnitClass=${this.includeUnitClass}
.includeDeviceClass=${this.includeDeviceClass}
.statisticTypes=${this.statisticTypes}
.statisticIds=${this.statisticIds}
.excludeStatistics=${this.excludeStatistics}
hide-clear-icon
@opened-changed=${this._debounceOpenedChanged}
@input=${stopPropagation}
></ha-statistic-combo-box>
`}
${this._renderHelper()}
</div>
: item.stateObj
? html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
<span slot="headline">${item.primary} </span>
${item.secondary || item.type
? html`<span slot="supporting-text"
>${item.secondary} - ${item.type}</span
>`
: nothing}
${item.statistic_id && showEntityId
? html`<span slot="supporting-text" class="code">
${item.statistic_id}
</span>`
: nothing}
</ha-combo-box-item>
`;
};
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
private _searchFn: PickerComboBoxSearchFn<StatisticComboBoxItem> = (
search,
filteredItems
) => {
// If there is exact match for entity id or statistic id, put it first
const index = filteredItems.findIndex(
(item) =>
item.stateObj?.entity_id === search || item.statistic_id === search
);
if (index === -1) {
return filteredItems;
}
private _clear(e) {
e.stopPropagation();
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
private async _showPicker() {
if (this.disabled) {
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (value === MISSING_ID) {
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
return;
}
this._opened = true;
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete;
this._input?.focus();
this._input?.open();
}
// Multiple calls to _openedChanged can be triggered in quick succession
// when the menu is opened
private _debounceOpenedChanged = debounce(
(ev) => this._openedChanged(ev),
10
);
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._anchor?.focus();
}
}
static get styles(): CSSResultGroup {
return [
css`
.container {
position: relative;
display: block;
}
ha-combo-box-item {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: 4px;
border-end-end-radius: 0;
border-end-start-radius: 0;
--md-list-item-one-line-container-height: 56px;
--md-list-item-two-line-container-height: 56px;
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--ha-md-list-item-gap: 8px;
/* Remove the default focus ring */
--md-focus-ring-width: 0px;
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
ha-combo-box-item:focus:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-combo-box-item ha-svg-icon[slot="start"] {
margin: 0 4px;
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.edit {
--mdc-icon-size: 20px;
width: 32px;
}
label {
display: block;
margin: 0 0 8px;
}
.placeholder {
color: var(--secondary-text-color);
padding: 0 8px;
}
`,
];
await this._picker?.open();
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ class StateInfo extends LitElement {
.name.in-dialog,
:host([secondary-line]) .name {
line-height: 20px;
line-height: var(--ha-line-height-condensed);
}
.time-ago,
+1 -1
View File
@@ -129,7 +129,7 @@ class HaAlert extends LitElement {
}
.title {
margin-top: 2px;
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.action mwc-button,
.action ha-icon-button {
+1 -1
View File
@@ -56,7 +56,7 @@ export class HaAnsiToHtml extends LitElement {
overflow-wrap: break-word;
}
.bold {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.italic {
font-style: italic;
+218 -210
View File
@@ -1,16 +1,16 @@
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { stringCompare } from "../common/string/compare";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import { computeRTL } from "../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../data/area_registry";
import type {
@@ -19,29 +19,33 @@ import type {
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloorAreaLookup } from "../data/floor_registry";
import {
getFloorAreaLookup,
type FloorRegistryEntry,
} from "../data/floor_registry";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
import "./ha-tree-indicator";
type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry;
const SEPARATOR = "________";
interface FloorAreaEntry {
id: string | null;
name: string;
icon: string | null;
strings: string[];
interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
floor?: FloorRegistryEntry;
area?: AreaRegistryEntry;
}
interface AreaFloorValue {
id: string;
type: "floor" | "area";
level: number | null;
hasFloor?: boolean;
lastArea?: boolean;
}
@customElement("ha-area-floor-picker")
@@ -50,12 +54,15 @@ export class HaAreaFloorPicker extends LitElement {
@property() public label?: string;
@property() public value?: string;
@property({ attribute: false }) public value?: AreaFloorValue;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
/**
* Show only areas with entities from specific domains.
* @type {Array}
@@ -106,66 +113,53 @@ export class HaAreaFloorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _init = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
await this._picker?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const item = this._parseValue(value);
const area = item.type === "area" && this.hass.areas[value];
if (area) {
const areaName = computeAreaName(area);
return html`
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<slot name="headline">${areaName}</slot>
`;
}
const floor = item.type === "floor" && this.hass.floors[value];
if (floor) {
const floorName = computeFloorName(floor);
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
private _rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) => {
const rtl = computeRTL(this.hass);
return html`
<ha-combo-box-item
type="button"
style=${item.type === "area" && item.hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && item.hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.lastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor"
? html`<ha-floor-icon slot="start" .floor=${item}></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
};
private _getAreas = memoizeOne(
private _getAreasAndFloors = memoizeOne(
(
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
@@ -173,19 +167,11 @@ export class HaAreaFloorPicker extends LitElement {
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"]
): FloorAreaEntry[] => {
if (!areas.length && !floors.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_areas"),
icon: null,
strings: [],
level: null,
},
];
}
): FloorComboBoxItem[] => {
const floors = Object.values(haFloors);
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
@@ -326,19 +312,6 @@ export class HaAreaFloorPicker extends LitElement {
);
}
if (!outputAreas.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
strings: [],
level: null,
},
];
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
@@ -360,151 +333,186 @@ export class HaAreaFloorPicker extends LitElement {
return stringCompare(floorA.name, floorB.name);
});
const output: FloorAreaEntry[] = [];
const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
output.push({
id: floor.floor_id,
const floorName = computeFloorName(floor);
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return [area.area_id, areaName, ...area.aliases];
})
.flat();
items.push({
id: this._formatValue({ id: floor.floor_id, type: "floor" }),
type: "floor",
name: floor.name,
icon: floor.icon,
strings: [floor.floor_id, ...floor.aliases, floor.name],
level: floor.level,
primary: floorName,
floor: floor,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
output.push(
...floorAreas.map((area, index, array) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
hasFloor: true,
level: null,
lastArea: index === array.length - 1,
}))
items.push(
...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: this._formatValue({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
area: area,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
};
})
);
});
if (!output.length && !unassisgnedAreas.length) {
output.push({
id: "no_areas",
type: "area",
name: this.hass.localize(
"ui.components.area-picker.unassigned_areas"
),
icon: null,
strings: [],
level: null,
});
}
output.push(
...unassisgnedAreas.map((area) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
level: null,
}))
items.push(
...unassisgnedAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: this._formatValue({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
};
})
);
return output;
return items;
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const areas = this._getAreas(
Object.values(this.hass.floors),
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
this.comboBox.items = areas;
this.comboBox.filteredItems = areas;
}
}
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item,
{ index },
combobox
) => {
const nextItem = combobox.filteredItems?.[index + 1];
const isLastArea =
!nextItem ||
nextItem.type === "floor" ||
(nextItem.type === "area" && !nextItem.area?.floor_id);
const rtl = computeRTL(this.hass);
const hasFloor = item.type === "area" && item.area?.floor_id;
return html`
<ha-combo-box-item
type="button"
style=${item.type === "area" && hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${isLastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor" && item.floor
? html`<ha-floor-icon
slot="start"
.floor=${item.floor}
></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`}
${item.primary}
</ha-combo-box-item>
`;
};
private _getItems = () =>
this._getAreasAndFloors(
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
private _formatValue = memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
);
private _parseValue = memoizeOne((value: string): AreaFloorValue => {
const [type, id] = value.split(SEPARATOR);
return { id, type: type as "floor" | "area" };
});
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const value = this.value ? this._formatValue(this.value) : undefined;
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
.helper=${this.helper}
item-value-path="id"
item-id-path="id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${this._rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.placeholder=${placeholder}
.value=${value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
>
</ha-combo-box>
</ha-generic-picker>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableAreaFloorEntry>(
filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private async _areaChanged(ev: ValueChangedEvent<string>) {
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
const value = ev.detail.value;
if (newValue === "no_areas") {
if (!value) {
this._setValue(undefined);
return;
}
const selected = this.comboBox.selectedItem;
const selected = this._parseValue(value);
this._setValue(selected);
}
fireEvent(this, "value-changed", {
value: {
id: selected.id,
type: selected.type,
},
});
private _setValue(value?: AreaFloorValue) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
}
+172 -235
View File
@@ -1,15 +1,14 @@
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import type { AreaRegistryEntry } from "../data/area_registry";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { createAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
@@ -21,26 +20,15 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
`;
const ADD_NEW_ID = "___ADD_NEW___";
const NO_ITEMS_ID = "___NO_ITEMS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
@customElement("ha-area-picker")
export class HaAreaPicker extends LitElement {
@@ -99,41 +87,68 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
await this._picker?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
// Recompute value renderer when the areas change
private _computeValueRenderer = memoizeOne(
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
(value) => {
const area = this.hass.areas[value];
if (!area) {
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${area}</span>
`;
}
const { floor } = getAreaContext(area, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const floorName = floor ? computeFloorName(floor) : undefined;
const icon = area.icon;
return html`
${icon
? html`<ha-icon slot="start" .icon=${icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<span slot="headline">${areaName}</span>
${floorName
? html`<span slot="supporting-text">${floorName}</span>`
: nothing}
`;
}
);
private _getAreas = memoizeOne(
(
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeAreas: this["excludeAreas"]
): AreaRegistryEntry[] => {
): PickerComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
if (
includeDomains ||
excludeDomains ||
@@ -263,225 +278,147 @@ export class HaAreaPicker extends LitElement {
);
}
if (!outputAreas.length) {
outputAreas = [
{
area_id: NO_ITEMS_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_areas"),
picture: null,
icon: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
}
const items = outputAreas.map<PickerComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {
id: area.area_id,
primary: areaName || area.area_id,
secondary: floorName,
icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox,
sorting_label: areaName,
search_labels: [
areaName,
floorName,
area.area_id,
...area.aliases,
].filter((v): v is string => Boolean(v)),
};
});
return noAdd
? outputAreas
: [
...outputAreas,
{
area_id: ADD_NEW_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null,
icon: "mdi:plus",
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
return items;
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const areas = this._getAreas(
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeAreas
).map((area) => ({
...area,
strings: [area.area_id, ...area.aliases, area.name],
}));
this.comboBox.items = areas;
this.comboBox.filteredItems = areas;
private _getItems = () =>
this._getAreas(
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas
);
private _allAreaNames = memoizeOne(
(areas: HomeAssistant["areas"]) =>
Object.values(areas)
.map((area) => computeAreaName(area)?.toLowerCase())
.filter(Boolean) as string[]
);
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
}
}
const allAreas = this._allAreaNames(this.hass.areas);
if (searchString && !allAreas.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
}
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas);
return html`
<ha-combo-box
<ha-generic-picker
.hass=${this.hass}
.helper=${this.helper}
item-value-path="area_id"
item-id-path="area_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-combo-box>
</ha-generic-picker>
`;
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableAreaRegistryEntry>(
filterString,
target.items?.filter(
(item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (!this.noAdd) {
this.comboBox.filteredItems = [
{
area_id: NO_ITEMS_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
area_id: ADD_NEW_SUGGESTION_ID,
floor_id: null,
name: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _areaChanged(ev: ValueChangedEvent<string>) {
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
const value = ev.detail.value;
if (newValue === NO_ITEMS_ID) {
newValue = "";
this.comboBox.setInputValue("");
if (!value) {
this._setValue(undefined);
return;
}
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
const suggestedName = value.substring(ADD_NEW_ID.length);
showAreaRegistryDetailDialog(this, {
suggestedName: suggestedName,
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this.hass, values);
this._setValue(area.area_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.area-picker.failed_create_area"
),
text: err.message,
});
}
},
});
}
(ev.target as any).value = this._value;
this.hass.loadFragmentTranslation("config");
showAreaRegistryDetailDialog(this, {
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this.hass, values);
const areas = [...Object.values(this.hass.areas), area];
this.comboBox.filteredItems = this._getAreas(
areas,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeAreas
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(area.area_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.area-picker.failed_create_area"
),
text: err.message,
});
}
},
});
this._suggestion = undefined;
this.comboBox.setInputValue("");
this._setValue(value);
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
}
+183 -188
View File
@@ -5,8 +5,11 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../types";
import {
type PipelineRunEvent,
runAssistPipeline,
type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
} from "../data/assist_pipeline";
import { supportsFeature } from "../common/entity/supports-feature";
import { ConversationEntityFeature } from "../data/conversation";
@@ -90,7 +93,7 @@ export class HaAssistChat extends LitElement {
super.disconnectedCallback();
this._audioRecorder?.close();
this._audioRecorder = undefined;
this._audio?.pause();
this._unloadAudio();
this._conversation = [];
this._conversationId = null;
}
@@ -109,25 +112,24 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html`
${controlHA
? nothing
: html`
<ha-alert>
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
`}
<div class="messages">
<div class="messages-container" id="scroll-container">
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html`
<div class="messages" id="scroll-container">
${controlHA
? nothing
: html`
<ha-alert>
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
`}
<div class="spacer"></div>
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
`
)}
</div>
)}
</div>
<div class="input" slot="primaryAction">
<ha-textfield
@@ -273,8 +275,8 @@ export class HaAssistChat extends LitElement {
}
private async _startListening() {
this._unloadAudio();
this._processing = true;
this._audio?.pause();
if (!this._audioRecorder) {
this._audioRecorder = new AudioRecorder((audio) => {
if (this._audioBuffer) {
@@ -293,27 +295,36 @@ export class HaAssistChat extends LitElement {
await this._audioRecorder.start();
this._addMessage(userMessage);
this.requestUpdate("_audioRecorder");
let continueConversation = false;
let hassMessage = {
who: "hass",
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
const hassMessageProcesser = this._createAddHassMessageProcessor();
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
(event: PipelineRunEvent) => {
if (event.type === "run-start") {
this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id;
this._audio = new Audio(event.data.tts_output!.url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
if (hassMessageProcesser.continueConversation) {
this._startListening();
}
});
this._audio.addEventListener("pause", this._unloadAudio);
this._audio.addEventListener("canplaythrough", () =>
this._audio?.play()
);
this._audio.addEventListener("error", () => {
this._unloadAudio();
showAlertDialog(this, { title: "Error playing audio." });
});
}
// When we start STT stage, the WS has a binary handler
if (event.type === "stt-start" && this._audioBuffer) {
else if (event.type === "stt-start" && this._audioBuffer) {
// Send the buffer over the WS to the STT engine.
for (const buffer of this._audioBuffer) {
this._sendAudioChunk(buffer);
@@ -322,91 +333,26 @@ export class HaAssistChat extends LitElement {
}
// Stop recording if the server is done with STT stage
if (event.type === "stt-end") {
else if (event.type === "stt-end") {
this._stt_binary_handler_id = undefined;
this._stopListening();
userMessage.text = event.data.stt_output.text;
this.requestUpdate("_conversation");
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
}
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (currentDeltaRole && delta.role && hassMessage.text !== "…") {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
continueConversation =
event.data.intent_output.continue_conversation;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
}
this.requestUpdate("_conversation");
}
if (event.type === "tts-end") {
const url = event.data.tts_output.url;
this._audio = new Audio(url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
if (continueConversation) {
this._startListening();
}
});
this._audio.addEventListener("pause", this._unloadAudio);
this._audio.addEventListener("canplaythrough", this._playAudio);
this._audio.addEventListener("error", this._audioError);
}
if (event.type === "run-end") {
// Add the response message placeholder to the chat when we know the STT is done
hassMessageProcesser.addMessage();
} else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
} else if (event.type === "run-end") {
this._stt_binary_handler_id = undefined;
unsub();
}
if (event.type === "error") {
} else if (event.type === "error") {
this._unloadAudio();
this._stt_binary_handler_id = undefined;
if (userMessage.text === "…") {
userMessage.text = event.data.message;
userMessage.error = true;
} else {
hassMessage.text = event.data.message;
hassMessage.error = true;
hassMessageProcesser.setError(event.data.message);
}
this._stopListening();
this.requestUpdate("_conversation");
@@ -464,90 +410,33 @@ export class HaAssistChat extends LitElement {
this.hass.connection.socket!.send(data);
}
private _playAudio = () => {
this._audio?.play();
};
private _audioError = () => {
showAlertDialog(this, { title: "Error playing audio." });
this._audio?.removeAttribute("src");
};
private _unloadAudio = () => {
this._audio?.removeAttribute("src");
if (!this._audio) {
return;
}
this._audio.pause();
this._audio.removeAttribute("src");
this._audio = undefined;
};
private async _processText(text: string) {
this._unloadAudio();
this._processing = true;
this._audio?.pause();
this._addMessage({ who: "user", text });
let hassMessage = {
who: "hass",
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
const hassMessageProcesser = this._createAddHassMessageProcessor();
hassMessageProcesser.addMessage();
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message and previous message has content
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (
currentDeltaRole &&
delta.role === "assistant" &&
hassMessage.text !== "…"
) {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
}
this.requestUpdate("_conversation");
unsub();
}
if (event.type === "error") {
hassMessage.text = event.data.message;
hassMessage.error = true;
this.requestUpdate("_conversation");
hassMessageProcesser.setError(event.data.message);
unsub();
}
},
@@ -560,20 +449,126 @@ export class HaAssistChat extends LitElement {
}
);
} catch {
hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error");
hassMessage.error = true;
this.requestUpdate("_conversation");
hassMessageProcesser.setError(
this.hass.localize("ui.dialogs.voice_command.error")
);
} finally {
this._processing = false;
}
}
private _createAddHassMessageProcessor() {
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (progress.hassMessage.text === "…") {
return;
}
progress.hassMessage.text = progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
);
progress.hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(progress.hassMessage);
};
const isAssistantDelta = (
_delta: any
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResult = (
_delta: any
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const tools: Record<
string,
ConversationChatLogAssistantDelta["tool_calls"][0]
> = {};
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
error: false,
},
addMessage: () => {
this._addMessage(progress.hassMessage);
},
setError: (error: string) => {
progressToNextMessage();
progress.hassMessage.text = error;
progress.hassMessage.error = true;
this.requestUpdate("_conversation");
},
processEvent: (event: PipelineRunEvent) => {
if (event.type === "intent-progress" && event.data.chat_log_delta) {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
progressToNextMessage();
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
tools[toolCall.id] = toolCall;
}
}
} else if (isToolResult(delta)) {
if (tools[delta.tool_call_id]) {
delete tools[delta.tool_call_id];
}
}
} else if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
progress.continueConversation =
event.data.intent_output.continue_conversation;
const response =
event.data.intent_output.response.speech?.plain.speech;
if (!response) {
return;
}
if (event.data.intent_output.response.response_type === "error") {
progress.setError(response);
} else {
progress.hassMessage.text = response;
this.requestUpdate("_conversation");
}
}
},
};
return progress;
}
static styles = css`
:host {
flex: 1;
display: flex;
flex-direction: column;
}
ha-alert {
margin-bottom: 8px;
}
ha-textfield {
display: block;
}
@@ -581,30 +576,30 @@ export class HaAssistChat extends LitElement {
flex: 1;
display: block;
box-sizing: border-box;
position: relative;
}
.messages-container {
position: absolute;
bottom: 0px;
right: 0px;
left: 0px;
padding: 0px 10px 16px;
box-sizing: border-box;
overflow-y: auto;
max-height: 100%;
display: flex;
flex-direction: column;
padding: 0 12px 16px;
}
.spacer {
flex: 1;
}
.message {
white-space: pre-line;
font-size: 18px;
font-size: var(--ha-font-size-l);
clear: both;
margin: 8px 0;
padding: 8px;
border-radius: 15px;
}
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.message {
font-size: 16px;
font-size: var(--ha-font-size-l);
}
}
@@ -619,7 +614,7 @@ export class HaAssistChat extends LitElement {
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
float: var(--float-end);
align-self: flex-end;
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--chat-background-color-user, var(--primary-color));
@@ -631,7 +626,7 @@ export class HaAssistChat extends LitElement {
margin-right: 24px;
margin-inline-end: 24px;
margin-inline-start: initial;
float: var(--float-start);
align-self: flex-start;
border-bottom-left-radius: 0px;
background-color: var(
--chat-background-color-hass,
+4 -4
View File
@@ -95,9 +95,9 @@ export class HaBadge extends LitElement {
text-align: center;
}
.label {
font-size: 10px;
font-size: var(--ha-font-size-xs);
font-style: normal;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
line-height: 10px;
letter-spacing: 0.1px;
color: var(--secondary-text-color);
@@ -105,8 +105,8 @@ export class HaBadge extends LitElement {
.content {
font-size: var(--ha-badge-font-size, var(--ha-font-size-s));
font-style: normal;
font-weight: 500;
line-height: 16px;
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
letter-spacing: 0.1px;
color: var(--primary-text-color);
}
+19 -13
View File
@@ -81,27 +81,27 @@ export class HaBaseTimeInput extends LitElement {
/**
* Label for the day input
*/
@property({ attribute: false }) dayLabel = "";
@property({ type: String, attribute: "day-label" }) dayLabel = "";
/**
* Label for the hour input
*/
@property({ attribute: false }) hourLabel = "";
@property({ type: String, attribute: "hour-label" }) hourLabel = "";
/**
* Label for the min input
*/
@property({ attribute: false }) minLabel = "";
@property({ type: String, attribute: "min-label" }) minLabel = "";
/**
* Label for the sec input
*/
@property({ attribute: false }) secLabel = "";
@property({ type: String, attribute: "sec-label" }) secLabel = "";
/**
* Label for the milli sec input
*/
@property({ attribute: false }) millisecLabel = "";
@property({ type: String, attribute: "ms-label" }) millisecLabel = "";
/**
* show the sec field
@@ -342,7 +342,7 @@ export class HaBaseTimeInput extends LitElement {
padding-right: 3px;
}
ha-textfield {
width: 55px;
width: 60px;
flex-grow: 1;
text-align: center;
--mdc-shape-small: 0;
@@ -381,15 +381,21 @@ export class HaBaseTimeInput extends LitElement {
border-bottom-width: 1px;
}
label {
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-family: var(
--mdc-typography-body2-font-family,
var(--mdc-typography-font-family, Roboto, sans-serif)
var(--mdc-typography-font-family, var(--ha-font-family-body))
);
font-size: var(--mdc-typography-body2-font-size, var(--ha-font-size-s));
line-height: var(
--mdc-typography-body2-line-height,
var(--ha-line-height-condensed)
);
font-weight: var(
--mdc-typography-body2-font-weight,
var(--ha-font-weight-normal)
);
font-size: var(--mdc-typography-body2-font-size, 0.875rem);
line-height: var(--mdc-typography-body2-line-height, 1.25rem);
font-weight: var(--mdc-typography-body2-font-weight, 400);
letter-spacing: var(
--mdc-typography-body2-letter-spacing,
0.0178571429em
@@ -403,7 +409,7 @@ export class HaBaseTimeInput extends LitElement {
}
ha-input-helper-text {
padding-top: 8px;
line-height: normal;
line-height: var(--ha-line-height-condensed);
}
`;
}
+1 -1
View File
@@ -92,7 +92,7 @@ export class HaBigNumber extends LitElement {
}
.value .unit {
font-size: 0.33em;
line-height: 1.26;
line-height: var(--ha-line-height-condensed);
}
/* Accessibility */
.visually-hidden {
+3 -3
View File
@@ -41,14 +41,14 @@ export class HaCard extends LitElement {
:host ::slotted(.card-header) {
color: var(--ha-card-header-color, var(--primary-text-color));
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, 24px);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: 48px;
line-height: var(--ha-line-height-expanded);
padding: 12px 16px 16px;
display: block;
margin-block-start: 0px;
margin-block-end: 0px;
font-weight: normal;
font-weight: var(--ha-font-weight-normal);
}
:host ::slotted(.card-content:not(:first-child)),
+1 -1
View File
@@ -154,7 +154,7 @@ class HaClimateState extends LitElement {
}
.state-label {
font-weight: bold;
font-weight: var(--ha-font-weight-bold);
}
.unit {
+5 -5
View File
@@ -21,13 +21,13 @@ export class HaComboBoxItem extends HaMdListItem {
--state-icon-color: var(--secondary-text-color);
}
[slot="headline"] {
line-height: 22px;
font-size: 14px;
line-height: var(--ha-line-height-normal);
font-size: var(--ha-font-size-m);
white-space: nowrap;
}
[slot="supporting-text"] {
line-height: 18px;
font-size: 12px;
line-height: var(--ha-line-height-normal);
font-size: var(--ha-font-size-s);
white-space: nowrap;
}
::slotted(state-badge),
@@ -39,7 +39,7 @@ export class HaComboBoxItem extends HaMdListItem {
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-xs);
}
[slot="trailing-supporting-text"] {
::slotted(.domain) {
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal);
+24
View File
@@ -0,0 +1,24 @@
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { HaTextField } from "./ha-textfield";
@customElement("ha-combo-box-textfield")
export class HaComboBoxTextField extends HaTextField {
@property({ type: Boolean, attribute: "disable-set-value" })
public disableSetValue = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value")) {
if (this.disableSetValue) {
this.value = changedProps.get("value") as string;
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-combo-box-textfield": HaComboBoxTextField;
}
}
+29 -8
View File
@@ -12,11 +12,12 @@ import type {
import { registerStyles } from "@vaadin/vaadin-themable-mixin/register-styles";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import "./ha-combo-box-textfield";
import "./ha-icon-button";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -108,9 +109,14 @@ export class HaComboBox extends LitElement {
@property({ type: Boolean, attribute: "hide-clear-icon" })
public hideClearIcon = false;
@property({ type: Boolean, attribute: "clear-initial-value" })
public clearInitialValue = false;
@query("vaadin-combo-box-light", true) private _comboBox!: ComboBoxLight;
@query("ha-textfield", true) private _inputElement!: HaTextField;
@query("ha-combo-box-textfield", true) private _inputElement!: HaTextField;
@state({ type: Boolean }) private _disableSetValue = false;
private _overlayMutationObserver?: MutationObserver;
@@ -171,7 +177,7 @@ export class HaComboBox extends LitElement {
@value-changed=${this._valueChanged}
attr-for-value="value"
>
<ha-textfield
<ha-combo-box-textfield
label=${ifDefined(this.label)}
placeholder=${ifDefined(this.placeholder)}
?disabled=${this.disabled}
@@ -191,9 +197,10 @@ export class HaComboBox extends LitElement {
.invalid=${this.invalid}
.helper=${this.helper}
helperPersistent
.disableSetValue=${this._disableSetValue}
>
<slot name="icon" slot="leadingIcon"></slot>
</ha-textfield>
</ha-combo-box-textfield>
${this.value && !this.hideClearIcon
? html`<ha-svg-icon
role="button"
@@ -246,8 +253,20 @@ export class HaComboBox extends LitElement {
// delay this so we can handle click event for toggle button before setting _opened
setTimeout(() => {
this.opened = opened;
fireEvent(this, "opened-changed", { value: ev.detail.value });
}, 0);
fireEvent(this, "opened-changed", { value: ev.detail.value });
if (this.clearInitialValue) {
this.setTextFieldValue("");
if (opened) {
// Wait 100ms to be sure vaddin-combo-box-light already tried to set the value
setTimeout(() => {
this._disableSetValue = false;
}, 100);
} else {
this._disableSetValue = true;
}
}
if (opened) {
const overlay = document.querySelector<HTMLElement>(
@@ -326,8 +345,10 @@ export class HaComboBox extends LitElement {
// @ts-ignore
this._comboBox._closeOnBlurIsPrevented = true;
}
if (!this.opened) {
return;
}
const newValue = ev.detail.value;
if (newValue !== this.value) {
fireEvent(this, "value-changed", { value: newValue || undefined });
}
@@ -342,10 +363,10 @@ export class HaComboBox extends LitElement {
position: relative;
--vaadin-combo-box-overlay-max-height: calc(45vh - 56px);
}
ha-textfield {
ha-combo-box-textfield {
width: 100%;
}
ha-textfield > ha-icon-button {
ha-combo-box-textfield > ha-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
color: var(--secondary-text-color);
+2 -2
View File
@@ -58,8 +58,8 @@ export class HaControlButton extends LitElement {
padding: var(--control-button-padding);
box-sizing: border-box;
line-height: inherit;
font-family: Roboto;
font-weight: 500;
font-family: var(--ha-font-family-body);
font-weight: var(--ha-font-weight-medium);
outline: none;
overflow: hidden;
background: none;
+1 -1
View File
@@ -194,7 +194,7 @@ export class HaControlNumberButton extends LitElement {
color: var(--primary-text-color);
-webkit-tap-highlight-color: transparent;
font-style: normal;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
transition: color 180ms ease-in-out;
}
:host([disabled]) {
+2 -2
View File
@@ -179,7 +179,7 @@ export class HaControlSelectMenu extends SelectBase {
--control-select-menu-padding: 6px 10px;
--mdc-icon-size: 20px;
--ha-ripple-color: var(--secondary-text-color);
font-size: 14px;
font-size: var(--ha-font-size-m);
line-height: 1.4;
width: auto;
color: var(--primary-text-color);
@@ -208,7 +208,7 @@ export class HaControlSelectMenu extends SelectBase {
width: 100%;
user-select: none;
font-style: normal;
font-weight: 400;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px;
}
.content {
+1 -1
View File
@@ -207,7 +207,7 @@ export class HaControlSelect extends LitElement {
outline: none;
transition: box-shadow 180ms ease-in-out;
font-style: normal;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color);
user-select: none;
-webkit-tap-highlight-color: transparent;
+1 -1
View File
@@ -368,7 +368,7 @@ export class HaControlSlider extends LitElement {
--control-slider-background-opacity: 0.2;
--control-slider-thickness: 40px;
--control-slider-border-radius: 10px;
--control-slider-tooltip-font-size: 14px;
--control-slider-tooltip-font-size: var(--ha-font-size-m);
height: var(--control-slider-thickness);
width: 100%;
border-radius: var(--control-slider-border-radius);
+4 -4
View File
@@ -53,12 +53,12 @@ export class HaDialogHeader extends LitElement {
white-space: nowrap;
}
.header-title {
font-size: 22px;
line-height: 28px;
font-weight: 400;
font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed);
font-weight: var(--ha-font-weight-normal);
}
.header-subtitle {
font-size: 14px;
font-size: var(--ha-font-size-m);
line-height: 20px;
color: var(--secondary-text-color);
}
+3 -6
View File
@@ -85,12 +85,12 @@ export class HaDialog extends DialogBase {
var(--dialog-backdrop-filter, none)
);
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
--mdc-typography-headline6-font-weight: 400;
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
--mdc-typography-headline6-font-size: 1.574rem;
}
.mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end);
padding-bottom: max(env(safe-area-inset-bottom), 24px);
padding: 12px 24px max(var(--safe-area-inset-bottom), 12px) 24px;
}
.mdc-dialog__actions span:nth-child(1) {
flex: var(--secondary-action-button-flex, unset);
@@ -107,9 +107,6 @@ export class HaDialog extends DialogBase {
.mdc-dialog__title:has(span) {
padding: 12px 12px 0;
}
.mdc-dialog__actions {
padding: 12px 24px 12px 24px;
}
.mdc-dialog__title::before {
content: unset;
}
@@ -120,7 +117,7 @@ export class HaDialog extends DialogBase {
:host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: max(
var(--dialog-content-padding, 24px),
env(safe-area-inset-bottom)
var(--safe-area-inset-bottom)
);
}
.mdc-dialog .mdc-dialog__surface {
+5 -5
View File
@@ -52,11 +52,11 @@ class HaDurationInput extends LitElement {
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
dayLabel="dd"
hourLabel="hh"
minLabel="mm"
secLabel="ss"
millisecLabel="ms"
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
`;
}
+3 -2
View File
@@ -188,7 +188,7 @@ export class HaExpansionPanel extends LitElement {
align-items: center;
cursor: pointer;
overflow: hidden;
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
outline: none;
}
#summary.noCollapse {
@@ -202,6 +202,7 @@ export class HaExpansionPanel extends LitElement {
.header,
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
}
.container {
@@ -218,7 +219,7 @@ export class HaExpansionPanel extends LitElement {
.secondary {
display: block;
color: var(--secondary-text-color);
font-size: 12px;
font-size: var(--ha-font-size-s);
}
`;
}
+3 -3
View File
@@ -294,7 +294,7 @@ export class HaFileUpload extends LitElement {
}
.supports {
color: var(--secondary-text-color);
font-size: 12px;
font-size: var(--ha-font-size-s);
}
:host([disabled]) .secondary {
color: var(--disabled-text-color);
@@ -324,7 +324,7 @@ export class HaFileUpload extends LitElement {
box-sizing: border-box;
}
.header {
font-weight: 500;
font-weight: var(--ha-font-weight-medium);
}
.progress {
color: var(--secondary-text-color);
@@ -333,7 +333,7 @@ export class HaFileUpload extends LitElement {
background: none;
border: none;
padding: 0;
font-size: 14px;
font-size: var(--ha-font-size-m);
color: var(--primary-color);
text-decoration: underline;
cursor: pointer;
+3 -3
View File
@@ -208,10 +208,10 @@ export class HaFilterBlueprints extends LitElement {
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: 16px;
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
+3 -3
View File
@@ -303,10 +303,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: 16px;
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
+3 -3
View File
@@ -232,10 +232,10 @@ export class HaFilterDevices extends LitElement {
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: 16px;
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
+3 -3
View File
@@ -189,10 +189,10 @@ export class HaFilterDomains extends LitElement {
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: 16px;
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
+3 -3
View File
@@ -246,10 +246,10 @@ export class HaFilterEntities extends LitElement {
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: 16px;
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
+3 -3
View File
@@ -303,10 +303,10 @@ export class HaFilterFloorAreas extends LitElement {
min-width: 16px;
box-sizing: border-box;
border-radius: 50%;
font-weight: 400;
font-size: 11px;
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: 16px;
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

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