Compare commits

..

139 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
84c8420286 Initial plan 2025-10-29 11:11:06 +00:00
renovate[bot]
1361fc36bf Update dependency @lezer/highlight to v1.2.3 (#27691)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-29 10:44:54 +00:00
Tobias Bieniek
505ef2bd11 home dashboard: Allow users to choose weather entity if they have more than one (#27643) 2025-10-29 11:36:05 +01:00
Petar Petrov
c0cc66c1ab Fix next flow config flow showing an empty dialog (#27682) 2025-10-29 09:53:14 +01:00
ildar170975
7cfbc521c7 Dev tools -> Templates: max-height fix for cm-editor (#27461) 2025-10-29 09:52:45 +01:00
Ezra Freedman
e064ce56cc Fix calendar all day date display (#27689) 2025-10-29 09:42:50 +01:00
renovate[bot]
8d688aa3a9 Update Node.js to v22.21.1 (#27686)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 19:46:07 +00:00
Aidan Timson
d122483449 Fix entities card size and add grid contstraints (#27684)
* Add grid card options

* Allow overflow

* Use ha-scrollbar

* Use title/header for min rows calculation

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

* Format

* Remove entities length check

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-28 20:35:55 +01:00
Aidan Timson
f17bbc3f79 Fix activity card height and add constraints for grid layout (#27683)
* Fix logbook height

* Add grid option constraints

* Reverse
2025-10-28 17:02:50 +02:00
karwosts
c88f8fcce0 Shift stats in history by 1 hour (#27633) 2025-10-28 15:53:22 +02:00
Tobias Bieniek
8efabde916 Add floor icons to home dashboard headings (#27639)
* Add floor icons to home dashboard headings

This displays floor icons next to floor names in the home dashboard to provide visual consistency with the areas overview dashboard. The icons use either the custom floor icon if configured, or fall back to level-based default icons (e.g., `mdi:home-floor-0`, `mdi:home-floor-1`).

* Remove floor icon fallback from home dashboard headings

as requested in https://github.com/home-assistant/frontend/pull/27639#issuecomment-3452048655
2025-10-28 15:50:50 +02:00
Niklas Wagner
e821e1ec83 Allow selecting multiple states in trigger condition (#27455)
* Allow selecting multiple states in trigger condition

* Make from/to select exlusive to each other

* Simplify code

* fix: returning correct type

* Remove unnecessary any type
2025-10-28 15:43:34 +02:00
Wendelin
dc7516da94 Bottom-sheet swipe to close (#27537)
* WIP new add automation element

* WIP new add dialog

* revert merge

* Add tabs

* fix height

* Add max-height

* Add keybindings and blocks search separation

* Fix device translation

* add swipe to close for bottom sheet

* fix translations, scroll issues, RTL

* update target picker selector

* Fix bottom sheet padding

* Simplify scroll lock

* Simplify scroll lock

* Improve swipe gesture

* Fix methods

* Fix race condition

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-10-28 13:47:11 +02:00
Aidan Timson
a545a377a7 Fix typos and improve grammar on ha-dialogs design docs (#27681) 2025-10-28 12:38:47 +01:00
Aidan Timson
3634dbcbbf Add media player volume buttons card feature (#27624)
* Add media player volume buttons card feature

* Sort import

* Add uom

* Update src/panels/lovelace/card-features/hui-media-player-volume-buttons-card-feature.ts

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-28 13:29:56 +02:00
renovate[bot]
75af4f939e Update vitest monorepo to v4.0.3 (#27673)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 14:42:03 +00:00
Wendelin
453a2ac7f3 Use generic picker for language picker (#27631)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-10-27 14:02:13 +00:00
karwosts
8fbd0226fc Media selector for view backgrounds (#27544)
* Media selector for view backgrounds

* Bring back preview image
2025-10-27 15:26:20 +02:00
Aidan Timson
2a8d935601 Migrate dialog-device-registry-detail to ha-wa-dialog (#27668) 2025-10-27 14:30:22 +02:00
Aidan Timson
a6328fb6d7 Use space tokens in ha-more-info-dialog (#27666) 2025-10-27 13:44:49 +02:00
Aidan Timson
a78b61006f Use space tokens in ha-automation-row (#27665) 2025-10-27 13:40:37 +02:00
Aidan Timson
d506aa23b6 Use space tokens in ha-markdown (#27664) 2025-10-27 13:40:04 +02:00
Aidan Timson
48b4df43ab Use space tokens in ha-quick-bar (#27663) 2025-10-27 13:39:41 +02:00
Aidan Timson
8cdcd9cb55 Use space tokens in ha-card (#27662) 2025-10-27 13:38:36 +02:00
ildar170975
a1e2ac1d99 Label picker/selector: add a decription to a list (#27635)
add a decription to getLabels()
2025-10-27 11:11:53 +02:00
Simon Lamon
8ecddbc42c Adjust primary action buttons for create/update category/area dialogs (#27651)
Adjust primary action buttons
2025-10-27 10:52:02 +02:00
dependabot[bot]
6f70ef52a5 Bump actions/upload-artifact from 4.6.2 to 5.0.0 (#27660)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.2 to 5.0.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](ea165f8d65...330a01c490)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 10:49:12 +02:00
renovate[bot]
7dff02d7c8 Update dependency @types/sortablejs to v1.15.9 (#27659)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 06:35:59 +00:00
Simon Lamon
8bbd7a6a06 Add min and max values for hardware graphs (#27649) 2025-10-27 08:31:24 +02:00
karwosts
5c73a06f76 Regenerate service-picker valueRenderer on localize update (#27640)
* Regenerate service-picker valueRenderer on localize update

* memoize services
2025-10-27 08:30:17 +02:00
Simon Lamon
9943dae82c Adjust line height in weather card (#27653) 2025-10-27 08:29:17 +02:00
renovate[bot]
70bf049df0 Update babel monorepo to v7.28.5 (#27655)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 08:28:29 +02:00
renovate[bot]
f9d9fbb7f0 Update vitest monorepo to v4.0.2 (#27656)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 08:28:00 +02:00
dependabot[bot]
9cb84d3f37 Bump github/codeql-action from 4.30.9 to 4.31.0 (#27661)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.30.9 to 4.31.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](16140ae1a1...4e94bd11f7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-27 08:27:30 +02:00
renovate[bot]
c1bcf27cf8 Update dependency @types/qrcode to v1.5.6 (#27658)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-27 08:26:41 +02:00
renovate[bot]
164ec2a9b5 Update vitest monorepo to v4 (major) (#27638)
Update vitest monorepo to v4

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 15:34:28 +01:00
renovate[bot]
20001a551c Update CodeMirror (#27647)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 15:28:28 +01:00
renovate[bot]
b7f85bf733 Update dependency @rsdoctor/rspack-plugin to v1.3.4 (#27648)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 15:28:00 +01:00
renovate[bot]
b303e9441b Update dependency lint-staged to v16.2.6 (#27644)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-26 07:19:35 +02:00
ildar170975
8f4bd0f620 Fix horiz spacings in ha-select (#27634)
* fix horiz paddings

* prettier

* prettier
2025-10-25 13:37:25 +03:00
Petar Petrov
596346bf59 Improve label layout for pie chart on mobile (#27632) 2025-10-24 16:34:38 +02:00
Paul Bottein
769cea92aa Don't force ratio for area picture (#27630) 2025-10-24 16:12:14 +03:00
Wendelin
f825016514 Add color and icon defaults for config labels table (#27622)
* Add color and icon defaults for config labels table

* use space vars
2025-10-24 14:00:06 +02:00
Aidan Timson
c6fd45bd6a Use space tokens in card features editor (#27625) 2025-10-24 13:34:10 +02:00
Aidan Timson
6c4f4af75c Use space tokens in tile card (#27626) 2025-10-24 13:33:54 +02:00
Aidan Timson
cd5c3ef2f6 Use space tokens in area card (#27627) 2025-10-24 13:33:39 +02:00
dependabot[bot]
636a6fa02e Bump vite from 7.1.6 to 7.1.12 (#27628)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.6 to 7.1.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.1.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.1.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.1.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-24 13:17:37 +02:00
Wendelin
21b83426d6 Migrate generic-picker to new design (#27594)
* WIP new combo box

* Use new combo box for generic picker

* Fix esc close and clean up

* Fix empty search list item

* Fix picker usages

* Improve labels picker

* Patch WA to make esc on popover work correctly

* Apply suggestion from @piitaya

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

* Fix NO_MATCHING_ITEMS_FOUND_ID

* Fix possible undefined boolean props

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2025-10-24 12:52:56 +02:00
Wendelin
c139ec22f9 Use space vars (#27623)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-10-24 09:43:49 +00:00
ildar170975
a6ef3a26da Fix padding for "search-input-outlined" in filters (#27621) 2025-10-24 09:41:50 +00:00
Wendelin
221ca56121 Add automation element dialog: fix blocks only search result (#27618)
Fix exact block search
2025-10-24 11:25:39 +03:00
ildar170975
4e6e3629a8 target picker: use slugify() for tooltips (#27619) 2025-10-24 08:59:14 +01:00
ildar170975
fe94ae0243 ha-media-player-browse: use slugify() for tooltips (#27617) 2025-10-24 09:18:10 +02:00
Wendelin
8a1a22d4bd New design for automation add trigger/condition/action dialog (#27529)
* WIP new add automation element

* WIP new add dialog

* revert merge

* Add tabs

* fix height

* Add max-height

* Add keybindings and blocks search separation

* Fix device translation

* fix translations, scroll issues, RTL

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-24 05:53:19 +00:00
renovate[bot]
153a578986 Update dependency typescript-eslint to v8.46.2 (#27610)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 21:18:34 +02:00
renovate[bot]
04bb10d0a2 Update dependency lint-staged to v16.2.5 (#27609)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 21:18:20 +02:00
Bram Kragten
35e52de2c1 Handle service description might be undefined (#27606) 2025-10-23 21:15:43 +02:00
Petar Petrov
b0862fddaa Fix resizing in pie chart (#27608) 2025-10-23 17:39:43 +03:00
Wendelin
77735f5310 Fix automation sidebar editor rerendering (#27607) 2025-10-23 16:06:53 +02:00
Wendelin
e388756533 ha-wa-dialog show header border on scroll (#27605) 2025-10-23 15:55:46 +02:00
Wendelin
e9ca9bb781 Target picker fix entities count for labels (#27603) 2025-10-23 15:53:48 +02:00
Tobias Bieniek
e48918442c Invert floor sort order to match physical layout (#27580) 2025-10-23 15:39:58 +02:00
Wendelin
52f37f41f0 Revert "Sidebar profile picture fix alignment in RTL languages" (#27604) 2025-10-23 14:31:59 +01:00
Paul Bottein
4687006fec Add description support to fields in object selector (#27602)
* Add description support to fields in object selector

* Use object

* Rename helper to description
2025-10-23 12:23:28 +00:00
Paul Bottein
aca4ca3066 Align state content picker with entity name picker (#27530)
* Align state content picker with entity name picker

* Fix state content property
2025-10-23 15:16:56 +03:00
Wendelin
3a2c00622a Sidebar profile picture fix alignment in RTL languages (#27578)
* Fix floor entities count

* Fix rtl profile picture
2025-10-23 15:09:15 +03:00
Wendelin
699c25a6c3 Show less information in picked targets (#27600)
Do not show num device and entity domain
2025-10-23 09:55:51 +02:00
ildar170975
1ad226d608 Fix min/max-height in error-log-card to prevent extra scrollbar (#27591) 2025-10-23 09:11:56 +02:00
karwosts
992a4cd98a Improve automation save timeout (#27584)
* Improve automation save timeout

* junk

* fix error handling

* fix error handling

* translate fix

* Fix typo
2025-10-23 08:59:30 +03:00
renovate[bot]
fd217f8ea5 Update dependency eslint-plugin-unused-imports to v4.3.0 (#27598)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-23 08:49:28 +03:00
Wendelin
dede14e578 Use ha-filter-chips for target picker (#27521)
* Use filter-chips for target picker

* Single select filter

* fix filter radius
2025-10-22 21:08:30 +02:00
ildar170975
fa7aca67e5 codemirror: show a cursor while drag-n-drop (#27592)
* add dropCursor

* add dropCursor
2025-10-22 13:25:47 +02:00
karwosts
6abdfa6d5c Set numeric keypads to LTR (#27588) 2025-10-22 09:43:56 +02:00
renovate[bot]
0a70e2abda Update dependency eslint to v9.38.0 (#27582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 12:50:25 +02:00
renovate[bot]
1ec589e9b6 Update Node.js to v22.21.0 (#27583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 12:50:05 +02:00
renovate[bot]
2d2b5633c4 Update dependency jsdom to v27.0.1 (#27586)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-21 12:49:46 +02:00
dependabot[bot]
76df75c306 Bump actions/setup-node from 5.0.0 to 6.0.0 (#27568)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 5.0.0 to 6.0.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](a0853c2454...2028fbc5c2)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-20 11:14:56 +00:00
dependabot[bot]
027ded61c2 Bump home-assistant/wheels from 2025.09.1 to 2025.10.0 (#27566)
Bumps [home-assistant/wheels](https://github.com/home-assistant/wheels) from 2025.09.1 to 2025.10.0.
- [Release notes](https://github.com/home-assistant/wheels/releases)
- [Commits](https://github.com/home-assistant/wheels/compare/2025.09.1...2025.10.0)

---
updated-dependencies:
- dependency-name: home-assistant/wheels
  dependency-version: 2025.10.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-20 13:14:25 +02:00
dependabot[bot]
a718589ba0 Bump relative-ci/agent-action from 3.0.1 to 3.1.0 (#27569)
Bumps [relative-ci/agent-action](https://github.com/relative-ci/agent-action) from 3.0.1 to 3.1.0.
- [Release notes](https://github.com/relative-ci/agent-action/releases)
- [Commits](1707825cbf...8504826a02)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-20 11:14:02 +00:00
renovate[bot]
5b5dc9d853 Lock file maintenance (#27560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 11:08:09 +00:00
Wendelin
2a49b5e15a Fix target-picker floor entities count (#27577)
Fix floor entities count
2025-10-20 14:05:37 +03:00
Jan-Philipp Benecke
fa4dd1c5ea Make target area of slider track larger (#27571)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-20 11:02:12 +00:00
dependabot[bot]
37a3af2e8b Bump github/codeql-action from 4.30.8 to 4.30.9 (#27567)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.30.8 to 4.30.9.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](f443b600d9...16140ae1a1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-20 13:55:49 +03:00
renovate[bot]
fbfcef1573 Update dependency @lezer/highlight to v1.2.2 (#27573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 13:54:13 +03:00
renovate[bot]
4eecd37aaf Update dependency marked to v16.4.1 (#27574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-20 10:52:32 +00:00
TheJulianJES
c798521ab8 Hide "add hardware" button for hardware integrations (#27572) 2025-10-20 10:52:06 +00:00
Petar Petrov
e432f0a8ee Fix date test to work on Oct 20 (#27575)
* Fix date test to work on Oct 20

* tweak
2025-10-20 10:39:04 +00:00
Tobias Bieniek
e3a1d0abe2 data/floor_registry: Use 9999 fallback for null floor levels (#27559)
Change the fallback for null floor levels from 0 to 9999, ensuring floors
without a defined level appear at the bottom when sorted (after all numbered
floors, including negative basement levels).

This matches the original intent from #20206 which added support for floors
without levels.
2025-10-20 11:43:59 +03:00
Iván Pereira
8080ba696c Add tooltip instead of title for dashboard button (#27563) 2025-10-19 16:23:58 +00:00
Christopher Fenner
7bd8f321a4 Display Zigbee Connection on device page (#27380)
* Update ha-device-info-card.ts

* Update src/panels/config/devices/device-detail/ha-device-info-card.ts

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

* Fix filter syntax in _getAddresses method

* Fix formatting issue in _getAddresses method

* Remove IEEE address from Zigbee info panel

Remove IEEE address display from Zigbee info panel.

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-10-19 16:20:01 +02:00
Niklas Wagner
4e958302b4 Allow selecting multiple states in state condition (#27453)
* Allow selecting multiple states in state condition

* Make use of the ensureArray function
2025-10-19 12:23:22 +00:00
Paul Bottein
8a42d15bde Use entity naming in more cards and badges (#27541)
* Add support for button card, glance card and entities card

* Add tests

* Add support for attribute and button row

* Add support to heading badge

* Undo changes from rows

* Add comment
2025-10-19 14:08:28 +02:00
wrfz
ef0da0a7ee Add placeholder text for ha-selector-device (#27551)
* Add placeholder text for ha-selector-device

Added for:
- ha-selector-device
- ha-selector-entity
2025-10-19 14:06:04 +02:00
renovate[bot]
ae053c20b0 Update dependency @rsdoctor/rspack-plugin to v1.3.3 (#27558)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-18 21:31:52 +02:00
Tobias Bieniek
5f71938d60 Consolidate floor sorting with floorCompare() (#27553)
* data/floor_registry: Fix `floorCompare()` argument type

* data/floor_registry: Add test suite for `floorCompare()` fn

* data/floor_registry: Add level-based sorting to `floorCompare()`

Update `floorCompare()` to include level-based sorting between custom order
and name sorting, matching the pattern used in the `getFloors()` helper.

Sort priority:
1. Custom order (if provided)
2. Floor level (lower levels first, with 0 fallback for null)
3. Floor name (alphabetical)

This makes `floorCompare()` consistent with the floor sorting logic used
throughout the codebase and prepares it for actual use.

* areas-strategy-helper: Use `floorCompare()` in `getFloors()` helper

Replace duplicated floor sorting logic in `getFloors()` with the
centralized `floorCompare()` function, matching the pattern used
for areas with `areaCompare()`.

This eliminates code duplication and ensures consistent floor sorting
across the codebase.

* data/area_floor: Use `floorCompare()` in `getAreasAndFloors()`

Replace duplicated floor sorting logic in `getAreasAndFloors()` with
`floorCompare()`, passing `haFloors` directly since it's already in
the correct format (Record<string, FloorRegistryEntry>).

This ensures consistent floor sorting across the codebase.
2025-10-18 18:16:30 +00:00
Tobias Bieniek
82ac26b326 Remove redundant sorting in area and floor registry fetching (#27552)
The initial sorting in `fetchAreaRegistry` and `fetchFloorRegistry` serves
no purpose because the data is immediately converted to an object/Record in
`connection-mixin.ts`, which loses any array ordering. All UI components that
need sorted lists call `Object.values()` and sort the data themselves.
2025-10-18 11:39:06 +03:00
Jan-Philipp Benecke
80b92b9813 Improve styling of ZHA manage device dialog (#27556) 2025-10-18 09:26:58 +03:00
Jan-Philipp Benecke
904a083f61 Use progress button for config save button in ZHA dashboard (#27547)
* Use progress button for config save button in ZHA dashboard

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

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

* Apply suggestion from @jpbede

* Revert

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-17 19:27:01 +03:00
renovate[bot]
d75ee09d55 Update dependency @lokalise/node-api to v15.3.1 (#27555)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 19:21:08 +03:00
Jan-Philipp Benecke
a8e0d506b6 Make target area of slider thumb larger (#27550) 2025-10-17 19:12:08 +03:00
renovate[bot]
01dd731622 Update dependency typescript-eslint to v8.46.1 (#27546)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-17 08:30:29 +03:00
Paul Bottein
dc20702d36 Remove title for common controls section for home dashboard (#27545) 2025-10-16 21:45:53 +02:00
Aidan Timson
f32ca9be29 Set header bar min height and make sure items are centered (#27542) 2025-10-16 16:14:08 +02:00
Aidan Timson
8c4c4157a8 Remove unnecessary on-surface-default semantic color (#27536) 2025-10-16 16:07:26 +02:00
Wendelin
c8419d4c3d Improve target picker section title (#27539) 2025-10-16 15:37:04 +02:00
Paul Bottein
089316b8ae Fix duplicated name in entity name picker and fix missing entity id support (#27538) 2025-10-16 15:47:47 +03:00
Wendelin
8d03ac5f64 Fix target picker device/floor icon (#27515)
* Fix device icon alginment

* Expand target item group if new target is in there

* Remove sticky header animation

* Fix type attribute

* Fix floor icon

* Reflect collapsed

* fix 0 entities target

* Improve empty search
2025-10-16 14:09:05 +03:00
Wendelin
e0e1f6f920 use popover with trap focus (#27533)
* use popover with focus trap

* update attribute

* Use new WA
2025-10-16 14:03:00 +03:00
Paul Bottein
d4c98cae3a Update drag icon (#27514) 2025-10-16 11:33:25 +02:00
renovate[bot]
46d0eb4f44 Update dependency @codemirror/view to v6.38.6 (#27531)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-16 10:35:26 +02:00
karwosts
07812f8d84 Support media-source links for view background (#27522) 2025-10-16 08:42:39 +03:00
Paul Bottein
96f54d348f Fix entity badge name (#27520) 2025-10-16 08:38:42 +03:00
Paul Bottein
6084ab116f Use empty string for no name instead of empty array for name (#27523) 2025-10-16 08:35:38 +03:00
Simon Lamon
6b7acd8d3b Only show backup ad when cloud is enabled (#27524) 2025-10-16 08:34:23 +03:00
karwosts
e35b155c66 Delete image selector (#27519) 2025-10-15 13:32:10 +00:00
Paul Bottein
437d02c12f Group dashboards by type (#27517)
Group dashboard by type
2025-10-15 16:11:37 +03:00
Paul Bottein
9cd74fbff8 Make custom text more discoverable in entity name picker (#27505)
* Make custom text more discoverable in entity name picker

* Fix custom option selection

* Rename label
2025-10-15 10:07:40 +02:00
karwosts
33a7aacd83 Use media selector in picture-glance and picture-elements (#27506) 2025-10-15 08:31:19 +03:00
karwosts
39546615bb Missing translation on back button (#27510) 2025-10-15 06:09:30 +02:00
J. Nick Koston
be51cbc944 Add support for next_flow on abort (#27491) 2025-10-14 11:45:11 -10:00
Wendelin
77874aa2d7 New target picker (#27284)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-14 16:16:34 +02:00
Aidan Timson
4808463d5f Estimate backup size (#27423)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-10-14 16:13:46 +02:00
karwosts
5fb3cab247 Add media support to hui-image and picture-entity-card (#27450)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-10-14 13:06:23 +00:00
Petar Petrov
d1093b187f Improved Sankey layout (#26787)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-14 13:53:11 +02:00
Petar Petrov
fd7f0d3841 Add pie chart mode to energy devices graph (#27282)
* Add pie chart mode to energy devices graph

* universal transition

* format

* Add hide_compound_stats option to energy-devices-graph-card (#27263)

* Add hide_compound_stats option to energy-devices-graph-card

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

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

* format

---------

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

* Save chart type in storage

* show untracked compound energy and total energy

* Update dependency lint-staged to v16.2.3 (#27285)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Update dependency @codemirror/view to v6.38.4 (#27288)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Add a sub-editor to hui-entity-editor (#27157)

* Add a sub-editor to hui-entity-editor

* item styling

* fix compare order

* handle label click in pie chart

* order compare data based on current data

* show untracked energy in tooltip

* Apply suggestions from code review

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

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: karwosts <32912880+karwosts@users.noreply.github.com>
2025-10-14 11:31:11 +00:00
hanwg
36aa74e4a5 Add menu item to copy config entry id (#27394) 2025-10-14 10:26:42 +00:00
Paul Bottein
938128d1c3 Don't add audio track if webrtc player is muted (#25767) 2025-10-14 10:21:59 +00:00
Paul Bottein
2a5d4ac578 Rename security panel to safety panel (#27502) 2025-10-14 12:09:01 +02:00
Jan-Philipp Benecke
be63ff7702 Improve ZHA config dashboard styling (#27492)
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2025-10-14 11:56:19 +02:00
renovate[bot]
132c68bf20 Update dependency lint-staged to v16.2.4 (#27499)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 11:04:28 +03:00
renovate[bot]
16499bbd6b Update dependency @rsdoctor/rspack-plugin to v1.3.2 (#27498)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 10:49:45 +03:00
renovate[bot]
c7eddfed8f Update dependency @types/leaflet to v1.9.21 (#27497)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-14 10:19:30 +03:00
Jan-Philipp Benecke
150842e431 Fix button wrapping in Z-Wave JS config dashboard (#27493) 2025-10-14 06:10:20 +02:00
Copilot
9eb5360a68 Update add-on auto-update strings to use full phrase (#27484)
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2025-10-13 16:38:38 +02:00
Aidan Timson
e9e32c7d91 Migrate restart wait to ha-wa-dialog (#27476)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-10-13 13:52:44 +00:00
Wendelin
c83d760e82 Enable keyboard resizing of automation sidebar (#27473) 2025-10-13 15:50:27 +02:00
Paul Bottein
489b7f9227 Revert "Add plus and minus button for media player more info" (#27409) 2025-10-13 15:43:44 +02:00
Paul Bottein
ad2ba63155 Use entity naming in cards and badges (#27428) 2025-10-13 15:43:17 +02:00
Petar Petrov
29bc894dbd Improve sampling in trend feature and sensor card (#27190) 2025-10-13 15:36:33 +02:00
261 changed files with 11455 additions and 4887 deletions

View File

@@ -26,7 +26,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -61,7 +61,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -26,7 +26,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -60,7 +60,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -78,7 +78,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -89,7 +89,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: frontend-bundle-stats
path: build/stats/*.json
@@ -102,7 +102,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -113,7 +113,7 @@ jobs:
env:
IS_TEST: "true"
- name: Upload bundle stats
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: supervisor-bundle-stats
path: build/stats/*.json

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
uses: github/codeql-action/autobuild@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -57,4 +57,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@f443b600d91635bebf5b0d9ebc620189c0d6fba5 # v4.30.8
uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0

View File

@@ -27,7 +27,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -62,7 +62,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -28,7 +28,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: wheels
path: dist/home_assistant_frontend*.whl
if-no-files-found: error
- name: Upload translations
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: translations
path: translations.tar.gz

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@1707825cbfcc7452b2913d273414705415ae64d4 # v3.0.1
uses: relative-ci/agent-action@8504826a02078b05756e4c07e380023cc2c4274a # v3.1.0
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}

View File

@@ -34,7 +34,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -75,7 +75,7 @@ jobs:
# home-assistant/wheels doesn't support SHA pinning
- name: Build wheels
uses: home-assistant/wheels@2025.09.1
uses: home-assistant/wheels@2025.10.0
with:
abi: cp313
tag: musllinux_1_2
@@ -93,7 +93,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -122,7 +122,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version-file: ".nvmrc"
cache: yarn

2
.nvmrc
View File

@@ -1 +1 @@
22.20.0
22.21.1

View File

@@ -5,14 +5,14 @@ subtitle: Dialogs provide important prompts in a user flow.
# Material Design 3
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guideliness. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
Our dialogs are based on the latest version of Material Design. Please note that we have made some well-considered adjustments to these guidelines. Specs and guidelines can be found on its [website](https://m3.material.io/components/dialogs/overview).
# Guidelines
## Design
- Dialogs have a max width of 560px. Alert and confirmation dialogs got a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guideliness.
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
@@ -26,7 +26,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
- A best practice is to always use a title, even if it is optional by Material guidelines.
- People mainly read the title and a button. Put the most important information in those two.
- Try to avoid user generated content in the title, this could make the title unreadable long.
- Try to avoid user generated content in the title, this could make the title unreadably long.
- If users become unsure, they read the description. Make sure this explains what will happen.
- Strive for minimalism.

View File

@@ -46,22 +46,9 @@ class HassioIngressView extends LitElement {
private _fetchDataTimeout?: number;
private _messageListener = (ev: MessageEvent) => {
if (this._addon?.webui_ha_aware && ev.data?.type === "toggle-sidebar") {
this._toggleMenu();
}
};
public connectedCallback() {
super.connectedCallback();
window.addEventListener("message", this._messageListener);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("message", this._messageListener);
if (this._sessionKeepAlive) {
clearInterval(this._sessionKeepAlive);
this._sessionKeepAlive = undefined;
@@ -96,25 +83,17 @@ class HassioIngressView extends LitElement {
</hass-subpage>`;
}
// If webui_ha_aware is true, or if narrow or sidebar is always hidden,
// don't render the header and just render the iframe
if (
this._addon.webui_ha_aware ||
this.narrow ||
this.hass.dockedSidebar === "always_hidden"
) {
return iframe;
}
return html`<div class="header">
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
.path=${mdiMenu}
@click=${this._toggleMenu}
></ha-icon-button>
<div class="main-title">${this._addon.name}</div>
</div>
${iframe}`;
return html`${this.narrow || this.hass.dockedSidebar === "always_hidden"
? html`<div class="header">
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.sidebar_toggle")}
.path=${mdiMenu}
@click=${this._toggleMenu}
></ha-icon-button>
<div class="main-title">${this._addon.name}</div>
</div>
${iframe}`
: iframe}`;
}
protected async firstUpdated(): Promise<void> {

View File

@@ -28,13 +28,13 @@
"dependencies": {
"@babel/runtime": "7.28.4",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.19.0",
"@codemirror/commands": "6.9.0",
"@codemirror/autocomplete": "6.19.1",
"@codemirror/commands": "6.10.0",
"@codemirror/language": "6.11.3",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.38.5",
"@codemirror/view": "6.38.6",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.2",
@@ -52,8 +52,8 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.4",
"@lezer/highlight": "1.2.1",
"@home-assistant/webawesome": "3.0.0-beta.6.ha.6",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
"@lit-labs/virtualizer": "2.1.1",
@@ -122,7 +122,7 @@
"lit": "3.3.1",
"lit-html": "3.3.1",
"luxon": "3.7.2",
"marked": "16.4.0",
"marked": "16.4.1",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -148,16 +148,16 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.28.4",
"@babel/core": "7.28.5",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.3",
"@babel/preset-env": "7.28.3",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.5",
"@lokalise/node-api": "15.3.0",
"@lokalise/node-api": "15.3.1",
"@octokit/auth-oauth-device": "8.0.2",
"@octokit/plugin-retry": "8.0.2",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.3.1",
"@rsdoctor/rspack-plugin": "1.3.4",
"@rspack/core": "1.5.8",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -167,30 +167,30 @@
"@types/culori": "4.0.1",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.20",
"@types/leaflet": "1.9.21",
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.1",
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.5",
"@types/sortablejs": "1.15.8",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.2.4",
"@vitest/coverage-v8": "4.0.3",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.1",
"eslint": "9.37.0",
"eslint": "9.38.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-lit": "2.1.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.2.0",
"eslint-plugin-unused-imports": "4.3.0",
"eslint-plugin-wc": "3.0.2",
"fancy-log": "2.0.0",
"fs-extra": "11.3.2",
@@ -201,9 +201,9 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "27.0.0",
"jsdom": "27.0.1",
"jszip": "3.10.1",
"lint-staged": "16.2.3",
"lint-staged": "16.2.6",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -217,9 +217,9 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.46.0",
"typescript-eslint": "8.46.2",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"vitest": "4.0.3",
"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"

View File

@@ -9,6 +9,11 @@ import { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
export const DEFAULT_ENTITY_NAME = [
{ type: "device" },
{ type: "entity" },
] satisfies EntityNameItem[];
export type EntityNameItem =
| {
type: "entity" | "device" | "area" | "floor";
@@ -24,14 +29,14 @@ export interface EntityNameOptions {
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: EntityNameItem | EntityNameItem[],
name: EntityNameItem | EntityNameItem[] | undefined,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
options?: EntityNameOptions
) => {
let items = ensureArray(name);
let items = ensureArray(name || DEFAULT_ENTITY_NAME);
const separator = options?.separator ?? DEFAULT_SEPARATOR;

View File

@@ -8,10 +8,10 @@ interface AreaContext {
}
export const getAreaContext = (
area: AreaRegistryEntry,
hass: HomeAssistant
hassFloors: HomeAssistant["floors"]
): AreaContext => {
const floorId = area.floor_id;
const floor = floorId ? hass.floors[floorId] : undefined;
const floor = floorId ? hassFloors[floorId] : undefined;
return {
area: area,

View File

@@ -0,0 +1,116 @@
export interface SwipeGestureResult {
velocity: number;
delta: number;
isSwipe: boolean;
isDownwardSwipe: boolean;
}
export interface SwipeGestureConfig {
velocitySwipeThreshold?: number;
movementTimeThreshold?: number;
}
const VELOCITY_SWIPE_THRESHOLD = 0.5; // px/ms
const MOVEMENT_TIME_THRESHOLD = 100; // ms
/**
* Recognizes swipe gestures and calculates velocity for touch interactions.
* Tracks touch movement and provides velocity-based and position-based gesture detection.
*/
export class SwipeGestureRecognizer {
private _startY = 0;
private _delta = 0;
private _startTime = 0;
private _lastY = 0;
private _lastTime = 0;
private _velocityThreshold: number;
private _movementTimeThreshold: number;
constructor(config: SwipeGestureConfig = {}) {
this._velocityThreshold =
config.velocitySwipeThreshold ?? VELOCITY_SWIPE_THRESHOLD; // px/ms
this._movementTimeThreshold =
config.movementTimeThreshold ?? MOVEMENT_TIME_THRESHOLD; // ms
}
/**
* Initialize gesture tracking with starting touch position
*/
public start(clientY: number): void {
const now = Date.now();
this._startY = clientY;
this._startTime = now;
this._lastY = clientY;
this._lastTime = now;
this._delta = 0;
}
/**
* Update gesture state during movement
* Returns the current delta (negative when dragging down)
*/
public move(clientY: number): number {
const now = Date.now();
this._delta = this._startY - clientY;
this._lastY = clientY;
this._lastTime = now;
return this._delta;
}
/**
* Calculate final gesture result when touch ends
*/
public end(): SwipeGestureResult {
const velocity = this.getVelocity();
const hasSignificantVelocity = Math.abs(velocity) > this._velocityThreshold;
return {
velocity,
delta: this._delta,
isSwipe: hasSignificantVelocity,
isDownwardSwipe: velocity > 0,
};
}
/**
* Get current drag delta (negative when dragging down)
*/
public getDelta(): number {
return this._delta;
}
/**
* Calculate velocity based on recent movement
* Returns 0 if no recent movement detected
* Positive velocity means downward swipe
*/
public getVelocity(): number {
const now = Date.now();
const timeSinceLastMove = now - this._lastTime;
// Only consider velocity if the last movement was recent
if (timeSinceLastMove >= this._movementTimeThreshold) {
return 0;
}
const timeDelta = this._lastTime - this._startTime;
return timeDelta > 0 ? (this._lastY - this._startY) / timeDelta : 0;
}
/**
* Reset all tracking state
*/
public reset(): void {
this._startY = 0;
this._delta = 0;
this._startTime = 0;
this._lastY = 0;
this._lastTime = 0;
}
}

View File

@@ -6,6 +6,7 @@ import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "./ha-progress-button";
import type { HomeAssistant } from "../../types";
import { fireEvent } from "../../common/dom/fire_event";
import type { Appearance } from "../ha-button";
@customElement("ha-call-service-button")
class HaCallServiceButton extends LitElement {
@@ -25,12 +26,14 @@ class HaCallServiceButton extends LitElement {
@property() public confirmation?;
@property() public appearance: Appearance = "plain";
public render(): TemplateResult {
return html`
<ha-progress-button
.progress=${this.progress}
.disabled=${this.disabled}
appearance="plain"
.appearance=${this.appearance}
@click=${this._buttonTapped}
tabindex="0"
>

View File

@@ -1,21 +1,22 @@
import type { LineSeriesOption } from "echarts";
export function downSampleLineData(
data: LineSeriesOption["data"],
chartWidth: number,
export function downSampleLineData<
T extends [number, number] | NonNullable<LineSeriesOption["data"]>[number],
>(
data: T[] | undefined,
maxDetails: number,
minX?: number,
maxX?: number
) {
if (!data || data.length < 10) {
return data;
): T[] {
if (!data) {
return [];
}
const width = chartWidth * window.devicePixelRatio;
if (data.length <= width) {
if (data.length <= maxDetails) {
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 step = Math.ceil((max - min) / Math.floor(maxDetails));
const frames = new Map<
number,
{
@@ -47,7 +48,7 @@ export function downSampleLineData(
}
// Convert frames back to points
const result: typeof data = [];
const result: T[] = [];
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

View File

@@ -22,7 +22,7 @@ import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { themesContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
@@ -88,9 +88,19 @@ export class HaChartBase extends LitElement {
private _lastTapTime?: number;
private _shouldResizeChart = false;
// @ts-ignore
private _resizeController = new ResizeController(this, {
callback: () => this.chart?.resize(),
callback: () => {
if (this.chart) {
if (!this.chart.getZr().animation.isFinished()) {
this._shouldResizeChart = true;
} else {
this.chart.resize();
}
}
},
});
private _loading = false;
@@ -346,7 +356,7 @@ export class HaChartBase extends LitElement {
if (this.chart) {
this.chart.dispose();
}
const echarts = (await import("../../resources/echarts")).default;
const echarts = (await import("../../resources/echarts/echarts")).default;
if (this.extraComponents?.length) {
echarts.use(this.extraComponents);
@@ -366,6 +376,7 @@ export class HaChartBase extends LitElement {
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
}
this.chart.on("finished", this._handleChartRenderFinished);
if (this._isTouchDevice) {
this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) {
@@ -805,7 +816,7 @@ export class HaChartBase extends LitElement {
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
this.clientWidth,
this.clientWidth * window.devicePixelRatio,
minX,
maxX
),
@@ -945,6 +956,13 @@ export class HaChartBase extends LitElement {
});
}
private _handleChartRenderFinished = () => {
if (this._shouldResizeChart) {
this.chart?.resize();
this._shouldResizeChart = false;
}
};
static styles = css`
:host {
display: block;

View File

@@ -6,7 +6,7 @@ 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 type { ECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";

View File

@@ -1,13 +1,13 @@
import { customElement, property, state } from "lit/decorators";
import { LitElement, html, css } from "lit";
import type { EChartsType } from "echarts/core";
import type { CallbackDataParams } from "echarts/types/dist/shared";
import type { SankeySeriesOption } from "echarts/types/dist/echarts";
import { SankeyChart } from "echarts/charts";
import type { CallbackDataParams } from "echarts/types/src/util/types";
import memoizeOne from "memoize-one";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
@@ -39,7 +39,7 @@ type ProcessedLink = Link & {
const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 8;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
@customElement("ha-sankey-chart")
@@ -164,6 +164,7 @@ export class HaSankeyChart extends LitElement {
lineStyle: {
color: "gradient",
opacity: 0.4,
curveness: 0.5,
},
layoutIterations: 0,
label: {

View File

@@ -11,7 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,

View File

@@ -15,8 +15,8 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts";
import echarts from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
import { measureTextWidth } from "../../util/text";

View File

@@ -29,7 +29,7 @@ import {
getStatisticMetadata,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";

View File

@@ -1,9 +1,9 @@
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { FilterChip } from "@material/web/chips/internal/filter-chip";
import { styles } from "@material/web/chips/internal/filter-styles";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
@@ -30,6 +30,7 @@ export class HaFilterChip extends FilterChip {
var(--rgb-primary-text-color),
0.15
);
border-radius: var(--ha-border-radius-md);
}
`,
];

View File

@@ -1,4 +1,4 @@
import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -129,7 +129,7 @@ export class DialogDataTableSettings extends LitElement {
${canMove && isVisible
? html`<ha-svg-icon
class="handle"
.path=${mdiDrag}
.path=${mdiDragHorizontalVariant}
slot="graphic"
></ha-svg-icon>`
: nothing}

View File

@@ -5,24 +5,18 @@ 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,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
getDevices,
type DevicePickerItem,
type DeviceRegistryEntry,
} from "../../data/device_registry";
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
@@ -30,11 +24,6 @@ export type HaDevicePickerDeviceFilterFunc = (
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
}
@customElement("ha-device-picker")
export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -104,6 +93,8 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
private _getDevicesMemoized = memoizeOne(getDevices);
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._loadConfigEntries();
@@ -117,162 +108,18 @@ export class HaDevicePicker extends LitElement {
}
private _getItems = () =>
this._getDevices(
this.hass.devices,
this.hass.entities,
this._getDevicesMemoized(
this.hass,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeDevices
this.excludeDevices,
this.value
);
private _getDevices = memoizeOne(
(
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"]
): DevicePickerItem[] => {
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
let inputDevices = devices.filter(
(device) => device.id === this.value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
}
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
}
if (excludeDevices) {
inputDevices = inputDevices.filter(
(device) => !excludeDevices!.includes(device.id)
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
}
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
}
if (deviceFilter) {
inputDevices = inputDevices.filter(
(device) =>
// We always want to include the device of the current value
device.id === this.value || deviceFilter!(device)
);
}
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,
label: "",
primary:
deviceName ||
this.hass.localize("ui.components.device-picker.unnamed_device"),
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
sorting_label: deviceName || "zzz",
};
});
return outputDevices;
}
);
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;

View File

@@ -0,0 +1 @@
export const ANY_STATE_VALUE = "__ANY_STATE_IGNORE_ATTRIBUTES__";

View File

@@ -1,13 +1,13 @@
import { mdiDrag } from "@mdi/js";
import { mdiDragHorizontalVariant } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-sortable";
import "./ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {
@@ -118,7 +118,7 @@ class HaEntitiesPicker extends LitElement {
? html`
<ha-svg-icon
class="entity-handle"
.path=${mdiDrag}
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing}
@@ -147,6 +147,7 @@ class HaEntitiesPicker extends LitElement {
.createDomains=${this.createDomains}
.required=${this.required && !currentEntities.length}
@value-changed=${this._addEntity}
add-button
></ha-entity-picker>
</div>
`;

View File

@@ -1,4 +1,3 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -8,8 +7,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
interface AttributeOption {
value: string;
label: string;

View File

@@ -1,5 +1,5 @@
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDrag, mdiPlus } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
@@ -20,11 +20,13 @@ import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-input-helper-text";
import "../ha-sortable";
interface EntityNameOption {
primary: string;
secondary?: string;
field_label: string;
value: string;
}
@@ -41,6 +43,23 @@ const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
const formatOptionValue = (item: EntityNameItem) => {
if (item.type === "text" && item.text) {
return item.text;
}
return `___${item.type}___`;
};
const parseOptionValue = (value: string): EntityNameItem => {
if (value.startsWith("___") && value.endsWith("___")) {
const type = value.slice(3, -3);
if (KNOWN_TYPES.has(type)) {
return { type: type as EntityNameType };
}
}
return { type: "text", text: value };
};
@customElement("ha-entity-name-picker")
export class HaEntityNamePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -68,8 +87,8 @@ export class HaEntityNamePicker extends LitElement {
private _editIndex?: number;
private _validOptions = memoizeOne((entityId?: string) => {
const options = new Set<string>();
private _validTypes = memoizeOne((entityId?: string) => {
const options = new Set<string>(["text"]);
if (!entityId) {
return options;
}
@@ -101,33 +120,43 @@ export class HaEntityNamePicker extends LitElement {
return [];
}
const options = this._validOptions(entityId);
const types = this._validTypes(entityId);
const items = (
["entity", "device", "area", "floor"] as const
).map<EntityNameOption>((name) => {
const stateObj = this.hass.states[entityId];
const isValid = options.has(name);
const isValid = types.has(name);
const primary = this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}`
);
const secondary =
stateObj && isValid
(stateObj && isValid
? this.hass.formatEntityName(stateObj, { type: name })
: this.hass.localize(
`ui.components.entity.entity-name-picker.types.${name}_missing` as LocalizeKeys
) || "-";
)) || "-";
return {
primary,
secondary,
value: name,
field_label: primary,
value: formatOptionValue({ type: name }),
};
});
return items;
});
private _customNameOption = memoizeOne((text: string) => ({
primary: this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
),
secondary: `"${text}"`,
field_label: text,
value: formatOptionValue({ type: "text", text }),
}));
private _formatItem = (item: EntityNameItem) => {
if (item.type === "text") {
return `"${item.text}"`;
@@ -141,9 +170,9 @@ export class HaEntityNamePicker extends LitElement {
};
protected render() {
const value = this._value;
const value = this._items;
const options = this._getOptions(this.entityId);
const validOptions = this._validOptions(this.entityId);
const validTypes = this._validTypes(this.entityId);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
@@ -157,12 +186,11 @@ export class HaEntityNamePicker extends LitElement {
>
<ha-chip-set>
${repeat(
this._value,
this._items,
(item) => item,
(item: EntityNameItem, idx) => {
const label = this._formatItem(item);
const isValid =
item.type === "text" || validOptions.has(item.type);
const isValid = validTypes.has(item.type);
return html`
<ha-input-chip
data-idx=${idx}
@@ -173,7 +201,10 @@ export class HaEntityNamePicker extends LitElement {
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
<span>${label}</span>
</ha-input-chip>
`;
@@ -207,14 +238,13 @@ export class HaEntityNamePicker extends LitElement {
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled || !this.entityId}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.helper=${this.helper}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="primary"
item-label-path="field_label"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@@ -223,9 +253,20 @@ export class HaEntityNamePicker extends LitElement {
</ha-combo-box>
</mwc-menu-surface>
</div>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`
<ha-input-helper-text .disabled=${this.disabled}>
${this.helper}
</ha-input-helper-text>
`
: nothing;
}
private _onClosed(ev) {
ev.stopPropagation();
this._opened = false;
@@ -254,13 +295,16 @@ export class HaEntityNamePicker extends LitElement {
this._opened = true;
}
private get _value(): EntityNameItem[] {
private get _items(): EntityNameItem[] {
return this._toItems(this.value);
}
private _toItems = memoizeOne((value?: typeof this.value) => {
if (typeof value === "string") {
return [{ type: "text", text: value } as const];
if (value === "") {
return [];
}
return [{ type: "text", text: value } satisfies EntityNameItem];
}
return value ? ensureArray(value) : [];
});
@@ -268,7 +312,7 @@ export class HaEntityNamePicker extends LitElement {
private _toValue = memoizeOne(
(items: EntityNameItem[]): typeof this.value => {
if (items.length === 0) {
return [];
return "";
}
if (items.length === 1) {
const item = items[0];
@@ -284,20 +328,21 @@ export class HaEntityNamePicker extends LitElement {
const options = this._comboBox.items || [];
const initialItem =
this._editIndex != null ? this._value[this._editIndex] : undefined;
this._editIndex != null ? this._items[this._editIndex] : undefined;
const initialValue = initialItem
? initialItem.type === "text"
? initialItem.text
: initialItem.type
: "";
const initialValue = initialItem ? formatOptionValue(initialItem) : "";
const filteredItems = this._filterSelectedOptions(options, initialValue);
if (initialItem?.type === "text" && initialItem.text) {
filteredItems.push(this._customNameOption(initialItem.text));
}
this._comboBox.filteredItems = filteredItems;
this._comboBox.setInputValue(initialValue);
} else {
this._opened = false;
this._comboBox.setInputValue("");
}
}
@@ -305,15 +350,16 @@ export class HaEntityNamePicker extends LitElement {
options: EntityNameOption[],
current?: string
) => {
const value = this._value;
const items = this._items;
const types = value.map((item) => item.type) as string[];
const excludedValues = new Set(
items
.filter((item) => UNIQUE_TYPES.has(item.type))
.map((item) => formatOptionValue(item))
);
const filteredOptions = options.filter(
(option) =>
!UNIQUE_TYPES.has(option.value) ||
!types.includes(option.value) ||
option.value === current
(option) => !excludedValues.has(option.value) || option.value === current
);
return filteredOptions;
};
@@ -324,20 +370,14 @@ export class HaEntityNamePicker extends LitElement {
const options = this._comboBox.items || [];
const currentItem =
this._editIndex != null ? this._value[this._editIndex] : undefined;
this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentValue = currentItem
? currentItem.type === "text"
? currentItem.text
: currentItem.type
: "";
const currentValue = currentItem ? formatOptionValue(currentItem) : "";
this._comboBox.filteredItems = this._filterSelectedOptions(
options,
currentValue
);
let filteredItems = this._filterSelectedOptions(options, currentValue);
if (!filter) {
this._comboBox.filteredItems = filteredItems;
return;
}
@@ -349,16 +389,16 @@ export class HaEntityNamePicker extends LitElement {
ignoreDiacritics: true,
};
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
const filteredItems = fuse.search(filter).map((result) => result.item);
const fuse = new Fuse(filteredItems, fuseOptions);
filteredItems = fuse.search(filter).map((result) => result.item);
filteredItems.push(this._customNameOption(input));
this._comboBox.filteredItems = filteredItems;
}
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const value = this._value;
const value = this._items;
const newValue = value.concat();
const element = newValue.splice(oldIndex, 1)[0];
newValue.splice(newIndex, 0, element);
@@ -369,7 +409,7 @@ export class HaEntityNamePicker extends LitElement {
private async _removeItem(ev) {
ev.stopPropagation();
const value = [...this._value];
const value = [...this._items];
const idx = parseInt(ev.target.dataset.idx, 10);
value.splice(idx, 1);
this._setValue(value);
@@ -385,11 +425,9 @@ export class HaEntityNamePicker extends LitElement {
return;
}
const item: EntityNameItem = KNOWN_TYPES.has(value as any)
? { type: value as EntityNameType }
: { type: "text", text: value };
const item: EntityNameItem = parseOptionValue(value);
const newValue = [...this._value];
const newValue = [...this._items];
if (this._editIndex != null) {
newValue[this._editIndex] = item;
@@ -483,6 +521,11 @@ export class HaEntityNamePicker extends LitElement {
.sortable-drag {
cursor: grabbing;
}
ha-input-helper-text {
display: block;
margin: var(--ha-space-2) 0 0;
}
`;
}

View File

@@ -1,15 +1,17 @@
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 { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import {
getEntities,
type EntityComboBoxItem,
} from "../../data/entity_registry";
import { domainToName } from "../../data/integration";
import {
isHelperDomain,
@@ -20,21 +22,11 @@ import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { 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")
@@ -121,6 +113,9 @@ export class HaEntityPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "add-button", type: Boolean })
public addButton = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
protected firstUpdated(changedProperties: PropertyValues): void {
@@ -255,8 +250,10 @@ export class HaEntityPicker extends LitElement {
}
);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getItems = () =>
this._getEntities(
this._getEntitiesMemoized(
this.hass,
this.includeDomains,
this.excludeDomains,
@@ -264,128 +261,10 @@ export class HaEntityPicker extends LitElement {
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities
this.excludeEntities,
this.value
);
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(hass);
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domainName = domainToName(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 ??
@@ -405,7 +284,7 @@ export class HaEntityPicker extends LitElement {
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.value=${this.addButton ? undefined : this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
@@ -413,6 +292,9 @@ export class HaEntityPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add")
: undefined}
>
</ha-generic-picker>
`;

View File

@@ -1,23 +1,39 @@
import { mdiDrag } from "@mdi/js";
import "@material/mwc-menu/mwc-menu-surface";
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
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 { computeDomain } from "../../common/entity/compute_domain";
import {
STATE_DISPLAY_SPECIAL_CONTENT,
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS,
} from "../../state-display/state-display";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import "../ha-sortable";
import "../chips/ha-input-chip";
import "../chips/ha-assist-chip";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-sortable";
interface StateContentOption {
primary: string;
value: string;
}
const rowRenderer: ComboBoxLitRenderer<StateContentOption> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
const HIDDEN_ATTRIBUTES = [
"access_token",
@@ -74,7 +90,7 @@ const HIDDEN_ATTRIBUTES = [
];
@customElement("ha-entity-state-content-picker")
class HaEntityStatePicker extends LitElement {
export class HaStateContentPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string;
@@ -95,26 +111,28 @@ class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
@state() private _opened = false;
@query(".container", true) private _container?: HTMLDivElement;
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
protected shouldUpdate(changedProps: PropertyValues) {
return !(!changedProps.has("_opened") && this._opened);
}
@state() private _opened = false;
private options = memoizeOne(
private _editIndex?: number;
private _options = memoizeOne(
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
const domain = entityId ? computeDomain(entityId) : undefined;
return [
{
label: this.hass.localize("ui.components.state-content-picker.state"),
primary: this.hass.localize(
"ui.components.state-content-picker.state"
),
value: "state",
},
...(allowName
? [
{
label: this.hass.localize(
primary: this.hass.localize(
"ui.components.state-content-picker.name"
),
value: "name",
@@ -122,13 +140,13 @@ class HaEntityStatePicker extends LitElement {
]
: []),
{
label: this.hass.localize(
primary: this.hass.localize(
"ui.components.state-content-picker.last_changed"
),
value: "last_changed",
},
{
label: this.hass.localize(
primary: this.hass.localize(
"ui.components.state-content-picker.last_updated"
),
value: "last_updated",
@@ -137,7 +155,7 @@ class HaEntityStatePicker extends LitElement {
? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
).map((content) => ({
label: this.hass.localize(
primary: this.hass.localize(
`ui.components.state-content-picker.${content}`
),
value: content,
@@ -146,105 +164,201 @@ class HaEntityStatePicker extends LitElement {
...Object.keys(stateObj?.attributes ?? {})
.filter((a) => !HIDDEN_ATTRIBUTES.includes(a))
.map((attribute) => ({
primary: this.hass.formatEntityAttributeName(stateObj!, attribute),
value: attribute,
label: this.hass.formatEntityAttributeName(stateObj!, attribute),
})),
];
] satisfies StateContentOption[];
}
);
private _filter = "";
protected render() {
if (!this.hass) {
return nothing;
}
const value = this._value;
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const options = this.options(this.entityId, stateObj, this.allowName);
const optionItems = options.filter(
(option) => !this._value.includes(option.value)
);
const options = this._options(this.entityId, stateObj, this.allowName);
return html`
${value?.length
? html`
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
>
<ha-chip-set>
${repeat(
this._value,
(item) => item,
(item, idx) => {
const label =
options.find((option) => option.value === item)?.label ||
item;
return html`
<ha-input-chip
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
selected
>
<ha-svg-icon slot="icon" .path=${mdiDrag}></ha-svg-icon>
${label}
</ha-input-chip>
`;
}
)}
</ha-chip-set>
</ha-sortable>
`
: nothing}
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container ${this.disabled ? "disabled" : ""}">
<ha-sortable
no-style
@item-moved=${this._moveItem}
.disabled=${this.disabled}
handle-selector="button.primary.action"
filter=".add"
>
<ha-chip-set>
${repeat(
this._value,
(item) => item,
(item: string, idx) => {
const label = options.find((o) => o.value === item)?.primary;
const isValid = !!label;
return html`
<ha-input-chip
data-idx=${idx}
@remove=${this._removeItem}
@click=${this._editItem}
.label=${label || item}
.selected=${!this.disabled}
.disabled=${this.disabled}
class=${!isValid ? "invalid" : ""}
>
<ha-svg-icon
slot="icon"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</ha-input-chip>
`;
}
)}
${this.disabled
? nothing
: html`
<ha-assist-chip
@click=${this._addItem}
.disabled=${this.disabled}
label=${this.hass.localize(
"ui.components.entity.entity-state-content-picker.add"
)}
class="add"
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-assist-chip>
`}
</ha-chip-set>
</ha-sortable>
<ha-combo-box
item-value-path="value"
item-label-path="label"
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.items=${optionItems}
allow-custom-value
@filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged}
></ha-combo-box>
<mwc-menu-surface
.open=${this._opened}
@closed=${this._onClosed}
@opened=${this._onOpened}
@input=${stopPropagation}
.anchor=${this._container}
>
<ha-combo-box
.hass=${this.hass}
.value=${""}
.autofocus=${this.autofocus}
.disabled=${this.disabled || !this.entityId}
.required=${this.required && !value.length}
.helper=${this.helper}
.items=${options}
allow-custom-value
item-id-path="value"
item-value-path="value"
item-label-path="primary"
.renderer=${rowRenderer}
@opened-changed=${this._openedChanged}
@value-changed=${this._comboBoxValueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
</mwc-menu-surface>
</div>
`;
}
private _onClosed(ev) {
ev.stopPropagation();
this._opened = false;
this._editIndex = undefined;
}
private async _onOpened(ev) {
if (!this._opened) {
return;
}
ev.stopPropagation();
this._opened = true;
await this._comboBox?.focus();
await this._comboBox?.open();
}
private async _addItem(ev) {
ev.stopPropagation();
this._opened = true;
}
private async _editItem(ev) {
ev.stopPropagation();
const idx = parseInt(ev.currentTarget.dataset.idx, 10);
this._editIndex = idx;
this._opened = true;
}
private get _value() {
return !this.value ? [] : ensureArray(this.value);
}
private _toValue = memoizeOne((value: string[]): typeof this.value => {
if (value.length === 0) {
return undefined;
}
if (value.length === 1) {
return value[0];
}
return value;
});
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
this._comboBox.filteredItems = this._comboBox.items;
const open = ev.detail.value;
if (open) {
const options = this._comboBox.items || [];
const initialValue =
this._editIndex != null ? this._value[this._editIndex] : "";
const filteredItems = this._filterSelectedOptions(options, initialValue);
this._comboBox.filteredItems = filteredItems;
this._comboBox.setInputValue(initialValue);
} else {
this._opened = false;
}
}
private _filterChanged(ev?: CustomEvent): void {
this._filter = ev?.detail.value || "";
private _filterSelectedOptions = (
options: StateContentOption[],
current?: string
) => {
const value = this._value;
const filteredItems = this._comboBox.items?.filter((item) => {
const label = item.label || item.value;
return label.toLowerCase().includes(this._filter?.toLowerCase());
});
return options.filter(
(option) => !value.includes(option.value) || option.value === current
);
};
if (this._filter) {
filteredItems?.unshift({ label: this._filter, value: this._filter });
private _filterChanged(ev: ValueChangedEvent<string>) {
const input = ev.detail.value;
const filter = input?.toLowerCase() || "";
const options = this._comboBox.items || [];
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : "";
this._comboBox.filteredItems = this._filterSelectedOptions(
options,
currentValue
);
if (!filter) {
return;
}
const fuseOptions: IFuseOptions<StateContentOption> = {
keys: ["primary", "secondary", "value"],
isCaseSensitive: false,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
};
const fuse = new Fuse(this._comboBox.filteredItems, fuseOptions);
const filteredItems = fuse.search(filter).map((result) => result.item);
this._comboBox.filteredItems = filteredItems;
}
@@ -257,43 +371,40 @@ class HaEntityStatePicker extends LitElement {
newValue.splice(newIndex, 0, element);
this._setValue(newValue);
await this.updateComplete;
this._filterChanged();
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private async _removeItem(ev) {
ev.stopPropagation();
const value: string[] = [...this._value];
value.splice(ev.target.idx, 1);
const value = [...this._value];
const idx = parseInt(ev.target.dataset.idx, 10);
value.splice(idx, 1);
this._setValue(value);
await this.updateComplete;
this._filterChanged();
this._filterChanged({ detail: { value: "" } } as ValueChangedEvent<string>);
}
private _comboBoxValueChanged(ev: CustomEvent): void {
private _comboBoxValueChanged(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
const newValue = ev.detail.value;
const value = ev.detail.value;
if (this.disabled || newValue === "") {
if (this.disabled || value === "") {
return;
}
const currentValue = this._value;
const newValue = [...this._value];
if (currentValue.includes(newValue)) {
return;
if (this._editIndex != null) {
newValue[this._editIndex] = value;
} else {
newValue.push(value);
}
setTimeout(() => {
this._filterChanged();
this._comboBox.setInputValue("");
}, 0);
this._setValue([...currentValue, newValue]);
this._setValue(newValue);
}
private _setValue(value: string[]) {
const newValue =
value.length === 0 ? undefined : value.length === 1 ? value[0] : value;
const newValue = this._toValue(value);
this.value = newValue;
fireEvent(this, "value-changed", {
value: newValue,
@@ -303,10 +414,64 @@ class HaEntityStatePicker extends LitElement {
static styles = css`
:host {
position: relative;
width: 100%;
}
.container {
position: relative;
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: var(--ha-border-radius-sm);
border-end-end-radius: var(--ha-border-radius-square);
border-end-start-radius: var(--ha-border-radius-square);
}
.container: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;
}
.container.disabled:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
.container:focus-within:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
label {
display: block;
margin: 0 0 var(--ha-space-2);
}
.add {
order: 1;
}
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-chip-set {
padding: 8px 0;
padding: var(--ha-space-2) var(--ha-space-2);
}
.invalid {
text-decoration: line-through;
}
.sortable-fallback {
@@ -326,6 +491,6 @@ class HaEntityStatePicker extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-entity-state-content-picker": HaEntityStatePicker;
"ha-entity-state-content-picker": HaStateContentPicker;
}
}

View File

@@ -1,4 +1,3 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
@@ -9,8 +8,6 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;
interface StateOption {
value: string;
label: string;

View File

@@ -4,6 +4,7 @@ import { customElement, property } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { ANY_STATE_VALUE } from "./const";
import { ensureArray } from "../../common/array/ensure-array";
import type { HomeAssistant } from "../../types";
import "./ha-entity-state-picker";
@@ -57,6 +58,7 @@ export class HaEntityStatesPicker extends LitElement {
const value = this.value || [];
const hide = [...(this.hideStates || []), ...value];
const hideValue = value.includes(ANY_STATE_VALUE);
return html`
${repeat(
@@ -84,7 +86,7 @@ export class HaEntityStatesPicker extends LitElement {
`
)}
<div>
${this.disabled && value.length
${(this.disabled && value.length) || hideValue
? nothing
: keyed(
value.length,

View File

@@ -46,7 +46,7 @@ export class HaAnalytics extends LitElement {
</span>
<ha-switch
@change=${this._handleRowClick}
.checked=${baseEnabled}
.checked=${!!baseEnabled}
.preference=${"base"}
.disabled=${loading}
name="base"
@@ -70,7 +70,7 @@ export class HaAnalytics extends LitElement {
<ha-switch
.id="switch-${preference}"
@change=${this._handleRowClick}
.checked=${this.analytics?.preferences[preference]}
.checked=${!!this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
>
@@ -102,7 +102,7 @@ export class HaAnalytics extends LitElement {
</span>
<ha-switch
@change=${this._handleRowClick}
.checked=${this.analytics?.preferences.diagnostics}
.checked=${!!this.analytics?.preferences.diagnostics}
.preference=${"diagnostics"}
.disabled=${loading}
name="diagnostics"

View File

@@ -8,21 +8,13 @@ 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 { computeRTL } from "../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
getFloorAreaLookup,
type FloorRegistryEntry,
} from "../data/floor_registry";
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item";
@@ -30,24 +22,12 @@ 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";
const SEPARATOR = "________";
interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
floor?: FloorRegistryEntry;
area?: AreaRegistryEntry;
}
interface AreaFloorValue {
id: string;
type: "floor" | "area";
}
@customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -154,243 +134,6 @@ export class HaAreaFloorPicker extends LitElement {
`;
};
private _getAreasAndFloors = memoizeOne(
(
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"]
): 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;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
if (excludeFloors) {
outputAreas = outputAreas.filter(
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
);
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => {
if (floorA.level !== floorB.level) {
return (floorA.level ?? 0) - (floorB.level ?? 0);
}
return stringCompare(floorA.name, floorB.name);
});
const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
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",
primary: floorName,
floor: floor,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
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],
};
})
);
});
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 items;
}
);
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item,
{ index },
@@ -445,12 +188,16 @@ export class HaAreaFloorPicker extends LitElement {
`;
};
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private _getItems = () =>
this._getAreasAndFloors(
this._getAreasAndFloorsMemoized(
this.hass.states,
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this._formatValue,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,

View File

@@ -107,7 +107,7 @@ export class HaAreaPicker extends LitElement {
`;
}
const { floor } = getAreaContext(area, this.hass);
const { floor } = getAreaContext(area, this.hass.floors);
const areaName = area ? computeAreaName(area) : undefined;
const floorName = floor ? computeFloorName(floor) : undefined;
@@ -279,7 +279,7 @@ export class HaAreaPicker extends LitElement {
}
const items = outputAreas.map<PickerComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass);
const { floor } = getAreaContext(area, this.hass.floors);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {

View File

@@ -44,7 +44,7 @@ export class HaAreasDisplayEditor extends LitElement {
);
const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area, this.hass!);
const { floor } = getAreaContext(area, this.hass.floors);
return {
value: area.area_id,
label: area.name,

View File

@@ -1,4 +1,4 @@
import { mdiDrag, mdiTextureBox } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -105,7 +105,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
<ha-svg-icon
class="handle"
slot="icons"
.path=${mdiDrag}
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`}
<ha-items-display-editor
@@ -138,7 +138,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass!);
const { floor } = getAreaContext(area, this.hass.floors);
const floorId = floor?.floor_id ?? UNASSIGNED_FLOOR;
if (!acc[floorId]) {

View File

@@ -118,7 +118,7 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding: 0 8px;
padding: var(--ha-space-0) var(--ha-space-2);
min-height: 48px;
align-items: center;
cursor: pointer;
@@ -134,12 +134,12 @@ export class HaAutomationRow extends LitElement {
.expand-button {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: -8px;
margin-left: calc(var(--ha-space-2) * -1);
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
border-radius: var(--ha-border-radius-md);
padding: 4px;
padding: var(--ha-space-1);
display: flex;
justify-content: center;
align-items: center;
@@ -149,7 +149,7 @@ export class HaAutomationRow extends LitElement {
color: var(--ha-color-on-neutral-quiet);
}
:host([building-block]) ::slotted([slot="leading-icon"]) {
--mdc-icon-size: 20px;
--mdc-icon-size: var(--ha-space-5);
color: var(--white-color);
transform: rotate(-45deg);
}
@@ -170,7 +170,7 @@ export class HaAutomationRow extends LitElement {
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
margin: 0 12px;
margin: var(--ha-space-0) var(--ha-space-3);
}
:host([sort-selected]) .row {
outline: solid;

View File

@@ -1,6 +1,8 @@
import { css, html, LitElement, type PropertyValues } from "lit";
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { haStyleScrollbar } from "../resources/styles";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -8,8 +10,17 @@ export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
export class HaBottomSheet extends LitElement {
@property({ type: Boolean }) public open = false;
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@state() private _drawerOpen = false;
@query("#drawer") private _drawer!: HTMLElement;
private _gestureRecognizer = new SwipeGestureRecognizer();
private _isDragging = false;
private _handleAfterHide() {
this.open = false;
const ev = new Event("closed", {
@@ -29,42 +40,186 @@ export class HaBottomSheet extends LitElement {
render() {
return html`
<wa-drawer
id="drawer"
placement="bottom"
.open=${this._drawerOpen}
@wa-after-hide=${this._handleAfterHide}
without-header
@touchstart=${this._handleTouchStart}
>
<slot></slot>
<slot name="header"></slot>
<div id="body" class="body ha-scrollbar">
<slot></slot>
</div>
</wa-drawer>
`;
}
static styles = css`
wa-drawer {
--wa-color-surface-raised: var(
--ha-bottom-sheet-surface-background,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
--spacing: 0;
--size: auto;
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
private _handleTouchStart = (ev: TouchEvent) => {
// Check if any element inside drawer in the composed path has scrollTop > 0
for (const path of ev.composedPath()) {
const el = path as HTMLElement;
if (el === this._drawer) {
break;
}
if (el.scrollTop > 0) {
return;
}
}
wa-drawer::part(dialog) {
border-top-left-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
border-top-right-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
max-height: 90vh;
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
this._startResizing(ev.touches[0].clientY);
};
private _startResizing(clientY: number) {
// register event listeners for drag handling
document.addEventListener("touchmove", this._handleTouchMove, {
passive: false,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
this._gestureRecognizer.start(clientY);
}
private _handleTouchMove = (ev: TouchEvent) => {
const currentY = ev.touches[0].clientY;
const delta = this._gestureRecognizer.move(currentY);
if (delta < 0) {
ev.preventDefault();
this._isDragging = true;
requestAnimationFrame(() => {
if (this._isDragging) {
this.style.setProperty(
"--dialog-transform",
`translateY(${delta * -1}px)`
);
}
});
}
`;
};
private _animateSnapBack() {
// Add transition for smooth animation
this.style.setProperty(
"--dialog-transition",
`transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease-out`
);
// Reset transform to snap back
this.style.removeProperty("--dialog-transform");
// Remove transition after animation completes
setTimeout(() => {
this.style.removeProperty("--dialog-transition");
}, BOTTOM_SHEET_ANIMATION_DURATION_MS);
}
private _handleTouchEnd = () => {
this._unregisterResizeHandlers();
this._isDragging = false;
const result = this._gestureRecognizer.end();
// If velocity exceeds threshold, use velocity direction to determine action
if (result.isSwipe) {
if (result.isDownwardSwipe) {
// Downward swipe - close the bottom sheet
this._drawerOpen = false;
} else {
// Upward swipe - keep open and animate back
this._animateSnapBack();
}
return;
}
// If velocity is below threshold, use position-based logic
// Get the drawer height to calculate 50% threshold
const drawerBody = this._drawer.shadowRoot?.querySelector(
'[part="body"]'
) as HTMLElement;
const drawerHeight = drawerBody?.offsetHeight || 0;
// delta is negative when dragging down
// Close if dragged down past 50% of the drawer height
if (
drawerHeight > 0 &&
result.delta < 0 &&
Math.abs(result.delta) > drawerHeight * 0.5
) {
this._drawerOpen = false;
} else {
this._animateSnapBack();
}
};
private _unregisterResizeHandlers = () => {
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
};
disconnectedCallback() {
super.disconnectedCallback();
this._unregisterResizeHandlers();
this._isDragging = false;
}
static styles = [
haStyleScrollbar,
css`
wa-drawer {
--wa-color-surface-raised: transparent;
--spacing: 0;
--size: var(--ha-bottom-sheet-height, auto);
--show-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
--hide-duration: ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms;
}
wa-drawer::part(dialog) {
max-height: var(--ha-bottom-sheet-max-height, 90vh);
align-items: center;
transform: var(--dialog-transform);
transition: var(--dialog-transition);
}
wa-drawer::part(body) {
max-width: var(--ha-bottom-sheet-max-width);
width: 100%;
border-top-left-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
border-top-right-radius: var(
--ha-bottom-sheet-border-radius,
var(--ha-dialog-border-radius, var(--ha-border-radius-2xl))
);
background-color: var(
--ha-bottom-sheet-surface-background,
var(--ha-dialog-surface-background, var(--mdc-theme-surface, #fff)),
);
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
:host([flexcontent]) wa-drawer::part(body) {
display: flex;
flex-direction: column;
}
:host([flexcontent]) .body {
flex: 1;
max-width: 100%;
display: flex;
flex-direction: column;
padding: var(
--ha-bottom-sheet-padding,
0 var(--safe-area-inset-right) var(--safe-area-inset-bottom)
var(--safe-area-inset-left)
);
}
`,
];
}
declare global {

View File

@@ -31,6 +31,9 @@ export class HaButtonToggleGroup extends LitElement {
@property({ type: Boolean, reflect: true, attribute: "no-wrap" })
public nowrap = false;
@property({ type: Boolean, reflect: true, attribute: "full-width" })
public fullWidth = false;
@property() public variant:
| "brand"
| "neutral"
@@ -38,6 +41,13 @@ export class HaButtonToggleGroup extends LitElement {
| "warning"
| "danger" = "brand";
@property({ attribute: "active-variant" }) public activeVariant?:
| "brand"
| "neutral"
| "success"
| "warning"
| "danger";
protected render(): TemplateResult {
return html`
<wa-button-group childSelector="ha-button">
@@ -46,7 +56,9 @@ export class HaButtonToggleGroup extends LitElement {
html`<ha-button
iconTag="ha-svg-icon"
class="icon"
.variant=${this.variant}
.variant=${this.active !== button.value || !this.activeVariant
? this.variant
: this.activeVariant}
.size=${this.size}
.value=${button.value}
@click=${this._handleClick}
@@ -78,6 +90,19 @@ export class HaButtonToggleGroup extends LitElement {
:host([no-wrap]) wa-button-group::part(base) {
flex-wrap: nowrap;
}
wa-button-group {
padding: var(--ha-button-toggle-group-padding);
}
:host([full-width]) wa-button-group,
:host([full-width]) wa-button-group::part(base) {
width: 100%;
}
:host([full-width]) ha-button {
flex: 1;
}
`;
}

View File

@@ -86,7 +86,8 @@ export class HaCameraStream extends LitElement {
const streams = this._streams(
this._capabilities?.frontend_stream_types,
this._hlsStreams,
this._webRtcStreams
this._webRtcStreams,
this.muted
);
return html`${repeat(
streams,
@@ -190,7 +191,8 @@ export class HaCameraStream extends LitElement {
(
supportedTypes?: StreamType[],
hlsStreams?: { hasAudio: boolean; hasVideo: boolean },
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean }
webRtcStreams?: { hasAudio: boolean; hasVideo: boolean },
muted?: boolean
): Stream[] => {
if (__DEMO__) {
return [{ type: MJPEG_STREAM, visible: true }];
@@ -220,9 +222,10 @@ export class HaCameraStream extends LitElement {
if (
hlsStreams.hasVideo &&
hlsStreams.hasAudio &&
!webRtcStreams.hasAudio
!webRtcStreams.hasAudio &&
!muted
) {
// webRTC stream is missing audio, use HLS
// webRTC stream is missing audio and audio is not muted, use HLS
return [{ type: STREAM_TYPE_HLS, visible: true }];
}
if (webRtcStreams.hasVideo) {

View File

@@ -44,26 +44,26 @@ export class HaCard extends LitElement {
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
padding: 12px 16px 16px;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
display: block;
margin-block-start: 0px;
margin-block-end: 0px;
margin-block-start: var(--ha-space-0);
margin-block-end: var(--ha-space-0);
font-weight: var(--ha-font-weight-normal);
}
:host ::slotted(.card-content:not(:first-child)),
slot:not(:first-child)::slotted(.card-content) {
padding-top: 0px;
margin-top: -8px;
padding-top: var(--ha-space-0);
margin-top: calc(var(--ha-space-2) * -1);
}
:host ::slotted(.card-content) {
padding: 16px;
padding: var(--ha-space-4);
}
:host ::slotted(.card-actions) {
border-top: 1px solid var(--divider-color, #e8e8e8);
padding: 8px;
padding: var(--ha-space-2);
}
`;

View File

@@ -239,6 +239,7 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.crosshairCursor(),
this._loadedCodeMirror.highlightSelectionMatches(),
this._loadedCodeMirror.highlightActiveLine(),
this._loadedCodeMirror.dropCursor(),
this._loadedCodeMirror.indentationMarkers({
thickness: 0,
activeThickness: 1,

View File

@@ -6,6 +6,9 @@ export class HaDialogHeader extends LitElement {
@property({ type: String, attribute: "subtitle-position" })
public subtitlePosition: "above" | "below" = "below";
@property({ type: Boolean, reflect: true, attribute: "show-border" })
public showBorder = false;
protected render() {
const titleSlot = html`<div class="header-title">
<slot name="title"></slot>
@@ -49,12 +52,16 @@ export class HaDialogHeader extends LitElement {
display: flex;
flex-direction: row;
align-items: center;
padding: 4px;
padding: 0 var(--ha-space-1);
box-sizing: border-box;
}
.header-content {
flex: 1;
padding: 10px 4px;
padding: 10px var(--ha-space-1);
display: flex;
flex-direction: column;
justify-content: center;
min-height: var(--ha-space-12);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -63,7 +70,7 @@ export class HaDialogHeader extends LitElement {
.header-title {
height: var(
--ha-dialog-header-title-height,
calc(var(--ha-font-size-xl) + 4px)
calc(var(--ha-font-size-xl) + var(--ha-space-1))
);
font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed);
@@ -76,19 +83,19 @@ export class HaDialogHeader extends LitElement {
}
@media all and (min-width: 450px) and (min-height: 500px) {
.header-bar {
padding: 16px;
padding: 0 var(--ha-space-2);
}
}
.header-navigation-icon {
flex: none;
min-width: 8px;
min-width: var(--ha-space-2);
height: 100%;
display: flex;
flex-direction: row;
}
.header-action-items {
flex: none;
min-width: 8px;
min-width: var(--ha-space-2);
height: 100%;
display: flex;
flex-direction: row;

View File

@@ -49,6 +49,7 @@ export class HaExpansionPanel extends LitElement {
tabindex=${this.noCollapse ? -1 : 0}
aria-expanded=${this.expanded}
aria-controls="sect1"
part="summary"
>
${this.leftChevron ? chevronIcon : nothing}
<slot name="leading-icon"></slot>
@@ -170,6 +171,11 @@ export class HaExpansionPanel extends LitElement {
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
border-radius: var(--ha-border-radius-circle);
}
#summary:focus-visible ha-svg-icon.summary-icon {
background-color: var(--ha-color-fill-neutral-normal-active);
}
:host([left-chevron]) .summary-icon,

View File

@@ -248,7 +248,7 @@ export class HaFilterDevices extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -199,7 +199,7 @@ export class HaFilterDomains extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -264,7 +264,7 @@ export class HaFilterEntities extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -217,7 +217,7 @@ export class HaFilterIntegrations extends LitElement {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -256,7 +256,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
}
search-input-outlined {
display: block;
padding: 0 8px;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];

View File

@@ -1,10 +1,14 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
@@ -15,12 +19,12 @@ import type {
PickerComboBoxSearchFn,
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-generic-picker")
export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -53,7 +57,7 @@ export class HaGenericPicker extends LitElement {
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@@ -64,58 +68,142 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@query("ha-picker-field") private _field?: HaPickerField;
@property({ attribute: "popover-placement" })
public popoverPlacement:
| "bottom"
| "top"
| "left"
| "right"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state() private _opened = false;
@state() private _pickerWrapperOpen = false;
@state() private _popoverWidth = 0;
@state() private _openedNarrow = false;
private _narrow = false;
// helper to set new value after closing picker, to avoid flicker
private _newValue?: string;
private _unsubscribeTinyKeys?: () => void;
protected render() {
return html`
${this.label
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
: nothing}
<div class="container">
${!this._opened
<div id="picker">
<slot name="field">
${this.addButtonLabel && !this.value
? html`<ha-button
size="small"
appearance="filled"
@click=${this.open}
.disabled=${this.disabled}
>
<ha-svg-icon
.path=${mdiPlaylistPlus}
slot="start"
></ha-svg-icon>
${this.addButtonLabel}
</ha-button>`
: html`<ha-picker-field
type="button"
class=${this._opened ? "opened" : ""}
compact
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
.placeholder=${this.placeholder}
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
</ha-picker-field>`}
</slot>
</div>
${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
? html`
<ha-picker-field
type="button"
compact
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
.placeholder=${this.placeholder}
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
</ha-picker-field>
${this._renderComboBox()}
</wa-popover>
`
: html`
<ha-picker-combo-box
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ??
this.hass.localize("ui.common.search")}
.value=${this.value}
hide-clear-icon
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
></ha-picker-combo-box>
`}
: this._pickerWrapperOpen || this._opened
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>`
: nothing}
</div>
${this._renderHelper()}
`;
}
private _renderComboBox(dialogMode = false) {
if (!this._opened) {
return nothing;
}
return html`
<ha-picker-combo-box
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ??
(this.hass?.localize("ui.common.search") || "Search")}
.value=${this.value}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
.mode=${dialogMode ? "dialog" : "popover"}
></ha-picker-combo-box>
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
@@ -124,13 +212,33 @@ export class HaGenericPicker extends LitElement {
: nothing;
}
private _dialogOpened = () => {
this._opened = true;
requestAnimationFrame(() => {
this._comboBox?.focus();
});
};
private _hidePicker(ev) {
ev.stopPropagation();
if (this._newValue) {
fireEvent(this, "value-changed", { value: this._newValue });
this._newValue = undefined;
}
this._opened = false;
this._pickerWrapperOpen = false;
this._unsubscribeTinyKeys?.();
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
return;
}
fireEvent(this, "value-changed", { value });
this._pickerWrapperOpen = false;
this._newValue = value;
}
private _clear(e) {
@@ -143,25 +251,45 @@ export class HaGenericPicker extends LitElement {
fireEvent(this, "value-changed", { value });
}
public async open() {
public async open(ev?: Event) {
ev?.stopPropagation();
if (this.disabled) {
return;
}
this._opened = true;
await this.updateComplete;
this._comboBox?.focus();
this._comboBox?.open();
this._openedNarrow = this._narrow;
this._popoverWidth = this._containerElement?.offsetWidth || 250;
this._pickerWrapperOpen = true;
this._unsubscribeTinyKeys = tinykeys(this, {
Escape: this._handleEscClose,
});
}
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._field?.focus();
}
connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
}
public disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this._unsubscribeTinyKeys?.();
}
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
if (!this._openedNarrow && this._pickerWrapperOpen) {
this._popoverWidth = this._containerElement?.offsetWidth || 250;
}
};
private _handleEscClose = (ev: KeyboardEvent) => {
ev.stopPropagation();
};
static get styles(): CSSResultGroup {
return [
css`
@@ -178,7 +306,45 @@ export class HaGenericPicker extends LitElement {
}
ha-input-helper-text {
display: block;
margin: 8px 0 0;
margin: var(--ha-space-2) 0 0;
}
wa-popover {
--wa-space-l: var(--ha-space-0);
}
wa-popover::part(body) {
width: max(var(--body-width), 250px);
max-width: max(var(--body-width), 250px);
max-height: 500px;
height: 70vh;
overflow: hidden;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: var(--ha-space-0);
--ha-bottom-sheet-surface-background: var(--card-background-color);
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
}
ha-picker-field.opened {
--mdc-text-field-idle-line-color: var(--primary-color);
}
`,
];

View File

@@ -1,5 +1,5 @@
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -178,7 +178,7 @@ export class HaItemDisplayEditor extends LitElement {
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
.path=${mdiDragHorizontalVariant}
slot="end"
></ha-svg-icon>
`

View File

@@ -2,19 +2,19 @@ import { mdiLabel, mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
customElement,
property,
query,
queryAssignedElements,
state,
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import type { LabelRegistryEntry } from "../data/label_registry";
import {
createLabelRegistryEntry,
getLabels,
subscribeLabelRegistry,
} from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
@@ -90,6 +90,9 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
@state() private _labels?: LabelRegistryEntry[];
@queryAssignedElements({ flatten: true })
private _slotNodes?: NodeListOf<HTMLElement>;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
@@ -137,201 +140,22 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
}
);
private _getLabels = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined,
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeLabels: this["excludeLabels"]
): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
private _getLabelsMemoized = memoizeOne(getLabels);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.labels.length > 0);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputLabels = labels;
const usedLabels = new Set<string>();
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
inputDevices.forEach((device) => {
device.labels.forEach((label) => usedLabels.add(label));
});
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
inputEntities.forEach((entity) => {
entity.labels.forEach((label) => usedLabels.add(label));
});
}
if (areaIds) {
areaIds.forEach((areaId) => {
const area = haAreas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (inputDevices || inputEntities) {
outputLabels = outputLabels.filter((label) =>
usedLabels.has(label.label_id)
);
}
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
primary: label.name,
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,
search_labels: [label.name, label.label_id, label.description].filter(
(v): v is string => Boolean(v)
),
}));
return items;
private _getItems = () => {
if (!this._labels || this._labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
);
private _getItems = () =>
this._getLabels(
return this._getLabelsMemoized(
this.hass,
this._labels,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -339,6 +163,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
@@ -395,12 +220,14 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
return html`
<ha-generic-picker
.disabled=${this.disabled}
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.label-picker.no_match"
)}
.addButtonLabel=${this.hass.localize("ui.components.label-picker.add")}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
@@ -408,6 +235,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
>
<slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot>
</ha-generic-picker>
`;
}

View File

@@ -1,3 +1,4 @@
import { mdiPlaylistPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
@@ -123,36 +124,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${labels?.length
? html`<ha-chip-set>
${repeat(
labels,
(label) => label?.label_id,
(label) => {
const color = label?.color
? computeCssColor(label.color)
: undefined;
return html`
<ha-input-chip
.item=${label}
@remove=${this._removeItem}
@click=${this._openDetail}
.label=${label?.name}
selected
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-input-chip>
`;
}
)}
</ha-chip-set>`
: nothing}
<ha-label-picker
.hass=${this.hass}
.helper=${this.helper}
@@ -162,6 +133,47 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
.excludeLabels=${this.value}
@value-changed=${this._labelChanged}
>
<ha-chip-set>
${labels?.length
? repeat(
labels,
(label) => label?.label_id,
(label) => {
const color = label?.color
? computeCssColor(label.color)
: undefined;
return html`
<ha-input-chip
.item=${label}
@remove=${this._removeItem}
@click=${this._openDetail}
.disabled=${this.disabled}
.label=${label?.name}
selected
style=${color ? `--color: ${color}` : ""}
>
${label?.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing}
</ha-input-chip>
`;
}
)
: nothing}
<ha-button
id="picker"
size="small"
appearance="filled"
@click=${this._openPicker}
.disabled=${this.disabled}
>
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.components.label-picker.add")}
</ha-button>
</ha-chip-set>
</ha-label-picker>
`;
}
@@ -203,9 +215,25 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
}, 0);
}
private _openPicker(ev: Event) {
ev.stopPropagation();
this.labelPicker.open();
}
static styles = css`
ha-chip-set {
margin-bottom: 8px;
background-color: var(--mdc-text-field-fill-color);
border-bottom: 1px solid var(--ha-color-border-neutral-normal);
border-top-right-radius: var(--ha-border-radius-sm);
border-top-left-radius: var(--ha-border-radius-sm);
padding: var(--ha-space-3);
}
.placeholder {
color: var(--mdc-text-field-label-ink-color);
display: flex;
align-items: center;
height: var(--ha-space-8);
}
ha-input-chip {
--md-input-chip-selected-container-color: var(--color, var(--grey-color));

View File

@@ -1,56 +1,58 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { formatLanguageCode } from "../common/language/format_language";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { FrontendLocaleData } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import "./ha-list-item";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-select";
import type { HaSelect } from "./ha-select";
export const getLanguageOptions = (
languages: string[],
nativeName: boolean,
noSort: boolean,
locale?: FrontendLocaleData
) => {
let options: { label: string; value: string }[] = [];
): PickerComboBoxItem[] => {
let options: PickerComboBoxItem[] = [];
if (nativeName) {
const translations = translationMetadata.translations;
options = languages.map((lang) => {
let label = translations[lang]?.nativeName;
if (!label) {
let primary = translations[lang]?.nativeName;
if (!primary) {
try {
// this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user
label = new Intl.DisplayNames(lang, {
primary = new Intl.DisplayNames(lang, {
type: "language",
fallback: "code",
}).of(lang)!;
} catch (_err) {
label = lang;
primary = lang;
}
}
return {
value: lang,
label,
id: lang,
primary,
search_labels: [primary],
};
});
} else if (locale) {
options = languages.map((lang) => ({
value: lang,
label: formatLanguageCode(lang, locale),
id: lang,
primary: formatLanguageCode(lang, locale),
search_labels: [formatLanguageCode(lang, locale)],
}));
}
if (!noSort && locale) {
options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, locale.language)
caseInsensitiveStringCompare(a.primary, b.primary, locale.language)
);
}
return options;
@@ -80,115 +82,69 @@ export class HaLanguagePicker extends LitElement {
@state() _defaultLanguages: string[] = [];
@query("ha-select") private _select!: HaSelect;
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._computeDefaultLanguageOptions();
}
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
const localeChanged =
changedProperties.has("hass") &&
this.hass &&
changedProperties.get("hass") &&
changedProperties.get("hass").locale.language !==
this.hass.locale.language;
if (
changedProperties.has("languages") ||
changedProperties.has("value") ||
localeChanged
) {
this._select.layoutOptions();
if (!this.disabled && this._select.value !== this.value) {
fireEvent(this, "value-changed", { value: this._select.value });
}
if (!this.value) {
return;
}
const languageOptions = this._getLanguagesOptions(
this.languages ?? this._defaultLanguages,
this.nativeName,
this.noSort,
this.hass?.locale
);
const selectedItemIndex = languageOptions.findIndex(
(option) => option.value === this.value
);
if (selectedItemIndex === -1) {
this.value = undefined;
}
if (localeChanged) {
this._select.select(selectedItemIndex);
}
}
}
private _getLanguagesOptions = memoizeOne(getLanguageOptions);
private _computeDefaultLanguageOptions() {
this._defaultLanguages = Object.keys(translationMetadata.translations);
}
protected render() {
const languageOptions = this._getLanguagesOptions(
private _getItems = () =>
this._getLanguagesOptions(
this.languages ?? this._defaultLanguages,
this.nativeName,
this.noSort,
this.hass?.locale
);
private _valueRenderer = (value) => {
const language = this._getItems().find(
(lang) => lang.id === value
)?.primary;
return html`<span slot="headline">${language ?? value}</span> `;
};
protected render() {
const value =
this.value ??
(this.required && !this.disabled
? languageOptions[0]?.value
: this.value);
(this.required && !this.disabled ? this._getItems()[0].id : this.value);
return html`
<ha-select
.label=${this.label ??
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this.hass?.localize(
"ui.components.language-picker.no_match"
)}
.placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") ||
"Language")}
.value=${value || ""}
.required=${this.required}
.value=${value}
.valueRenderer=${this._valueRenderer}
.disabled=${this.disabled}
@selected=${this._changed}
@closed=${stopPropagation}
fixedMenuPosition
naturalMenuWidth
.inlineArrow=${this.inlineArrow}
>
${languageOptions.length === 0
? html`<ha-list-item value=""
>${this.hass?.localize(
"ui.components.language-picker.no_languages"
) || "No languages"}</ha-list-item
>`
: languageOptions.map(
(option) => html`
<ha-list-item .value=${option.value}
>${option.label}</ha-list-item
>
`
)}
</ha-select>
.getItems=${this._getItems}
@value-changed=${this._changed}
hide-clear-icon
></ha-generic-picker>
`;
}
static styles = css`
ha-select {
ha-generic-picker {
width: 100%;
min-width: 200px;
display: block;
}
`;
private _changed(ev): void {
const target = ev.target as HaSelect;
if (this.disabled || target.value === "" || target.value === this.value) {
return;
}
this.value = target.value;
private _changed(ev: ValueChangedEvent<string>): void {
ev.stopPropagation();
this.value = ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
}

View File

@@ -50,7 +50,7 @@ export class HaMarkdown extends LitElement {
}
ha-alert {
display: block;
margin: 4px 0;
margin: var(--ha-space-1) 0;
}
a {
color: var(--primary-color);
@@ -75,7 +75,7 @@ export class HaMarkdown extends LitElement {
padding: 0;
}
pre {
padding: 16px;
padding: var(--ha-space-4);
overflow: auto;
line-height: var(--ha-line-height-condensed);
font-family: var(--ha-font-family-code);
@@ -95,7 +95,7 @@ export class HaMarkdown extends LitElement {
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: 16px 0;
margin: var(--ha-space-4) 0;
}
` as CSSResultGroup;
}

View File

@@ -1,19 +1,28 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaFuse } from "../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
export interface PickerComboBoxItem {
id: string;
@@ -33,10 +42,13 @@ export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item type="button" compact>
<ha-combo-box-item
.type=${item.id === NO_MATCHING_ITEMS_FOUND_ID ? "text" : "button"}
compact
>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
@@ -57,7 +69,7 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
@customElement("ha-picker-combo-box")
export class HaPickerComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -73,7 +85,7 @@ export class HaPickerComboBox extends LitElement {
@property() public value?: string;
@property() public helper?: string;
@state() private _listScrolled = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@@ -82,10 +94,7 @@ export class HaPickerComboBox extends LitElement {
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
public rowRenderer?: RenderItemFunction<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@@ -93,33 +102,77 @@ export class HaPickerComboBox extends LitElement {
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@state() private _opened = false;
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
@query("ha-combo-box", true) public comboBox!: HaComboBox;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: PickerComboBoxItemWithLabel[] = [];
private _allItems: PickerComboBoxItemWithLabel[] = [];
private _selectedItemIndex = -1;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
};
private _removeKeyboardShortcuts?: () => void;
protected firstUpdated() {
this._registerKeyboardShortcuts();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
this._allItems = this._getItems();
this._items = this._allItems;
}
}
private _initialItems = false;
disconnectedCallback() {
super.disconnectedCallback();
this._removeKeyboardShortcuts?.();
}
private _items: PickerComboBoxItemWithLabel[] = [];
protected render() {
return html`<ha-textfield
.label=${this.label ??
this.hass?.localize("ui.common.search") ??
"Search"}
@input=${this._filterChanged}
></ha-textfield>
<lit-virtualizer
@scroll=${this._onScrollList}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
@focus=${this._focusList}
>
</lit-virtualizer> `;
}
private _defaultNotFoundItem = memoizeOne(
(
label: this["notFoundLabel"],
localize: LocalizeFunc
localize?: LocalizeFunc
): PickerComboBoxItemWithLabel => ({
id: NO_MATCHING_ITEMS_FOUND_ID,
primary: label || localize("ui.components.combo-box.no_match"),
primary:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
icon_path: mdiMagnify,
a11y_label: label || localize("ui.components.combo-box.no_match"),
a11y_label:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
})
);
@@ -144,13 +197,13 @@ export class HaPickerComboBox extends LitElement {
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
this.hass.locale.language
this.hass?.locale.language ?? navigator.language
)
);
if (!sortedItems.length) {
sortedItems.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize)
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
);
}
@@ -159,99 +212,73 @@ export class HaPickerComboBox extends LitElement {
return sortedItems;
};
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
private _renderItem = (item: PickerComboBoxItem, index: number) => {
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
return html`<div
id=${`list-item-${index}`}
class="combo-box-row ${this._value === item.id ? "current-value" : ""}"
.value=${item.id}
.index=${index}
@click=${this._valueSelected}
>
${item.id === NO_MATCHING_ITEMS_FOUND_ID
? DEFAULT_ROW_RENDERER(item, index)
: renderer(item, index)}
</div>`;
};
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("_opened") && this._opened) {
this._items = this._getItems();
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="a11y_label"
clear-initial-value
.hass=${this.hass}
.value=${this._value}
.label=${this.label}
.helper=${this.helper}
.allowCustomValue=${this.allowCustomValue}
.filteredItems=${this._items}
.renderer=${this.rowRenderer || DEFAULT_ROW_RENDERER}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
`;
@eventOptions({ passive: true })
private _onScrollList(ev) {
const top = ev.target.scrollTop ?? 0;
this._listScrolled = top > 0;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
private _valueSelected = (ev: Event) => {
ev.stopPropagation();
if (ev.detail.value !== this._opened) {
this._opened = ev.detail.value;
fireEvent(this, "opened-changed", { value: this._opened });
}
}
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();
const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
fireEvent(this, "value-changed", { value: newValue });
};
private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim();
const target = ev.target as HaComboBox;
const searchString = ev.detail.value.trim() as string;
if (!searchString) {
this._items = this._allItems;
return;
}
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, { shouldSort: false }, index);
const index = this._fuseIndex(this._allItems);
const fuse = new HaFuse(
this._allItems,
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = this._items as PickerComboBoxItem[];
let filteredItems = this._allItems as PickerComboBoxItem[];
if (results) {
const items = results.map((result) => result.item);
if (items.length === 0) {
items.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize)
this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
);
}
const additionalItems = this._getAdditionalItems(searchString);
@@ -260,17 +287,279 @@ export class HaPickerComboBox extends LitElement {
}
if (this.searchFn) {
filteredItems = this.searchFn(searchString, filteredItems, this._items);
filteredItems = this.searchFn(
searchString,
filteredItems,
this._allItems
);
}
target.filteredItems = filteredItems;
this._items = filteredItems as PickerComboBoxItemWithLabel[];
this._selectedItemIndex = -1;
if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0);
}
};
private _registerKeyboardShortcuts() {
this._removeKeyboardShortcuts = tinykeys(this, {
ArrowUp: this._selectPreviousItem,
ArrowDown: this._selectNextItem,
Home: this._selectFirstItem,
End: this._selectLastItem,
Enter: this._pickSelectedItem,
});
}
private _setValue(value: string | undefined) {
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
private _focusList() {
if (this._selectedItemIndex === -1) {
this._selectNextItem();
}
}
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this._virtualizerElement) {
return;
}
this._searchFieldElement?.focus();
const items = this._virtualizerElement.items as PickerComboBoxItem[];
const maxItems = items.length - 1;
if (maxItems === -1) {
this._resetSelectedItem();
return;
}
const nextIndex =
maxItems === this._selectedItemIndex
? this._selectedItemIndex
: this._selectedItemIndex + 1;
if (!items[nextIndex]) {
return;
}
if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
// Skip titles, padding and empty search
if (nextIndex === maxItems) {
return;
}
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
};
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this._virtualizerElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this._virtualizerElement.items as PickerComboBoxItem[];
if (!items[nextIndex]) {
return;
}
if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
// Skip titles, padding and empty search
if (nextIndex === 0) {
return;
}
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
}
};
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
return;
}
const nextIndex = 0;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
};
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
return;
}
const nextIndex = this._virtualizerElement.items.length - 1;
if (
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
NO_MATCHING_ITEMS_FOUND_ID
) {
return;
}
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
}
this._scrollToSelectedItem();
};
private _scrollToSelectedItem = () => {
this._virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
requestAnimationFrame(() => {
this._virtualizerElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
};
private _pickSelectedItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (
this._virtualizerElement?.items.length === 1 &&
firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
) {
fireEvent(this, "value-changed", {
value: firstItem.id,
});
}
if (this._selectedItemIndex === -1) {
return;
}
// if filter button is focused
ev.preventDefault();
const item = this._virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
fireEvent(this, "value-changed", { value: item.id });
}
};
private _resetSelectedItem() {
this._virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._selectedItemIndex = -1;
}
static styles = [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
padding-top: var(--ha-space-3);
flex: 1;
}
ha-textfield {
padding: 0 var(--ha-space-3);
margin-bottom: var(--ha-space-3);
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
ha-combo-box-item {
width: 100%;
}
ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
@media (prefers-color-scheme: dark) {
ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
lit-virtualizer {
flex: 1;
}
lit-virtualizer:focus-visible {
outline: none;
}
lit-virtualizer.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
.bottom-padding {
height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
width: 100%;
}
.empty {
text-align: center;
}
.combo-box-row {
display: flex;
width: 100%;
align-items: center;
box-sizing: border-box;
min-height: 36px;
}
.combo-box-row.current-value {
background-color: var(--ha-color-fill-primary-quiet-resting);
}
.combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
@media (prefers-color-scheme: dark) {
.combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
`,
];
}
declare global {

View File

@@ -137,7 +137,7 @@ export class HaSelect extends SelectBase {
height: var(--ha-select-height, 56px);
}
.mdc-select--filled .mdc-floating-label {
inset-inline-start: 12px;
inset-inline-start: var(--ha-space-4);
inset-inline-end: initial;
direction: var(--direction);
}
@@ -147,7 +147,7 @@ export class HaSelect extends SelectBase {
direction: var(--direction);
}
.mdc-select .mdc-select__anchor {
padding-inline-start: 12px;
padding-inline-start: var(--ha-space-4);
padding-inline-end: 0px;
direction: var(--direction);
}
@@ -158,7 +158,10 @@ export class HaSelect extends SelectBase {
padding-inline-end: var(--select-selected-text-padding-end, 0px);
}
:host([clearable]) .mdc-select__selected-text-container {
padding-inline-end: var(--select-selected-text-padding-end, 12px);
padding-inline-end: var(
--select-selected-text-padding-end,
var(--ha-space-4)
);
}
ha-icon-button {
position: absolute;

View File

@@ -1,122 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { BackgroundSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-picture-upload";
import "../ha-alert";
import type { HaPictureUpload } from "../ha-picture-upload";
import { URL_PREFIX } from "../../data/image_upload";
@customElement("ha-selector-background")
export class HaBackgroundSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: any;
@property({ attribute: false }) public selector!: BackgroundSelector;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private yamlBackground = false;
protected updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("value")) {
this.yamlBackground = !!this.value && !this.value.startsWith(URL_PREFIX);
}
}
protected render() {
return html`
<div>
${this.yamlBackground
? html`
<div class="value">
<img
src=${this.value}
alt=${this.hass.localize(
"ui.components.picture-upload.current_image_alt"
)}
/>
</div>
<ha-alert alert-type="info">
${this.hass.localize(
`ui.components.selectors.background.yaml_info`
)}
<ha-button slot="action" @click=${this._clearValue}>
${this.hass.localize(
`ui.components.picture-upload.clear_picture`
)}
</ha-button>
</ha-alert>
`
: html`
<ha-picture-upload
.hass=${this.hass}
.value=${this.value?.startsWith(URL_PREFIX) ? this.value : null}
.original=${!!this.selector.background?.original}
.cropOptions=${this.selector.background?.crop}
select-media
@change=${this._pictureChanged}
></ha-picture-upload>
`}
</div>
`;
}
private _pictureChanged(ev) {
const value = (ev.target as HaPictureUpload).value;
fireEvent(this, "value-changed", { value: value ?? undefined });
}
private _clearValue() {
fireEvent(this, "value-changed", { value: undefined });
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-picture-upload {
background-color: var(--primary-background-color);
border-radius: var(--file-upload-image-border-radius);
}
div {
display: flex;
flex-direction: column;
}
ha-button {
white-space: nowrap;
--mdc-theme-primary: var(--primary-color);
}
.value {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
img {
max-width: 100%;
max-height: 200px;
margin-bottom: 4px;
border-radius: var(--file-upload-image-border-radius);
transition: opacity 0.3s;
opacity: var(--picture-opacity, 1);
}
img:hover {
opacity: 1;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-background": HaBackgroundSelector;
}
}

View File

@@ -36,6 +36,8 @@ export class HaDeviceSelector extends LitElement {
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@@ -102,6 +104,7 @@ export class HaDeviceSelector extends LitElement {
.entityFilter=${this.selector.device?.entity
? this._filterEntities
: undefined}
.placeholder=${this.placeholder}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-entity

View File

@@ -29,6 +29,8 @@ export class HaEntitySelector extends LitElement {
@property() public helper?: string;
@property() public placeholder?: any;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@@ -69,6 +71,7 @@ export class HaEntitySelector extends LitElement {
.excludeEntities=${this.selector.entity?.exclude_entities}
.entityFilter=${this._filterEntities}
.createDomains=${this._createDomains}
.placeholder=${this.placeholder}
.disabled=${this.disabled}
.required=${this.required}
allow-custom-entity
@@ -86,6 +89,7 @@ export class HaEntitySelector extends LitElement {
.reorder=${this.selector.entity.reorder ?? false}
.entityFilter=${this._filterEntities}
.createDomains=${this._createDomains}
.placeholder=${this.placeholder}
.disabled=${this.disabled}
.required=${this.required}
></ha-entities-picker>

View File

@@ -1,152 +0,0 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { ImageSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-icon-button";
import "../ha-textarea";
import "../ha-textfield";
import "../ha-picture-upload";
import "../ha-radio";
import "../ha-formfield";
import type { HaPictureUpload } from "../ha-picture-upload";
import { URL_PREFIX } from "../../data/image_upload";
@customElement("ha-selector-image")
export class HaImageSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: any;
@property() public name?: string;
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property({ attribute: false }) public selector!: ImageSelector;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private showUpload = false;
protected firstUpdated(changedProps): void {
super.firstUpdated(changedProps);
if (!this.value || this.value.startsWith(URL_PREFIX)) {
this.showUpload = true;
}
}
protected render() {
return html`
<div>
<label>
${this.hass.localize(
"ui.components.selectors.image.select_image_with_label",
{
label:
this.label ||
this.hass.localize("ui.components.selectors.image.image"),
}
)}
<ha-formfield
.label=${this.hass.localize("ui.components.selectors.image.upload")}
>
<ha-radio
name="mode"
value="upload"
.checked=${this.showUpload}
@change=${this._radioGroupPicked}
></ha-radio>
</ha-formfield>
<ha-formfield
.label=${this.hass.localize("ui.components.selectors.image.url")}
>
<ha-radio
name="mode"
value="url"
.checked=${!this.showUpload}
@change=${this._radioGroupPicked}
></ha-radio>
</ha-formfield>
</label>
${!this.showUpload
? html`
<ha-textfield
.name=${this.name}
.value=${this.value || ""}
.placeholder=${this.placeholder || ""}
.helper=${this.helper}
helperPersistent
.disabled=${this.disabled}
@input=${this._handleChange}
.label=${this.label || ""}
.required=${this.required}
></ha-textfield>
`
: html`
<ha-picture-upload
.hass=${this.hass}
.value=${this.value?.startsWith(URL_PREFIX) ? this.value : null}
.original=${this.selector.image?.original}
.cropOptions=${this.selector.image?.crop}
select-media
@change=${this._pictureChanged}
></ha-picture-upload>
`}
</div>
`;
}
private _radioGroupPicked(ev): void {
this.showUpload = ev.target.value === "upload";
}
private _pictureChanged(ev) {
const value = (ev.target as HaPictureUpload).value;
fireEvent(this, "value-changed", { value: value ?? undefined });
}
private _handleChange(ev) {
let value = ev.target.value;
if (this.value === value) {
return;
}
if (value === "" && !this.required) {
value = undefined;
}
fireEvent(this, "value-changed", { value });
}
static styles = css`
:host {
display: block;
position: relative;
}
div {
display: flex;
flex-direction: column;
}
label {
display: flex;
flex-direction: column;
}
ha-textarea,
ha-textfield {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-image": HaImageSelector;
}
}

View File

@@ -107,14 +107,15 @@ export class HaMediaSelector extends LitElement {
supportsFeature(stateObj, MediaPlayerEntityFeature.BROWSE_MEDIA));
if (this.selector.media?.image_upload && !this.value) {
return html`<ha-picture-upload
.hass=${this.hass}
.value=${null}
.contentIdHelper=${this.selector.media?.content_id_helper}
select-media
full-media
@media-picked=${this._pictureUploadMediaPicked}
></ha-picture-upload>`;
return html`${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-picture-upload
.hass=${this.hass}
.value=${null}
.contentIdHelper=${this.selector.media?.content_id_helper}
select-media
full-media
@media-picked=${this._pictureUploadMediaPicked}
></ha-picture-upload>`;
}
return html`
@@ -141,6 +142,7 @@ export class HaMediaSelector extends LitElement {
`}
${!supportsBrowse
? html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-alert>
${this.hass.localize(
"ui.components.selectors.media.browse_not_supported"
@@ -154,7 +156,8 @@ export class HaMediaSelector extends LitElement {
.computeHelper=${this._computeHelperCallback}
></ha-form>
`
: html`<ha-card
: html`${this.label ? html`<label>${this.label}</label>` : nothing}
<ha-card
outlined
tabindex="0"
role="button"

View File

@@ -1,4 +1,9 @@
import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js";
import {
mdiClose,
mdiDelete,
mdiDragHorizontalVariant,
mdiPencil,
} from "@mdi/js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -47,9 +52,10 @@ export class HaObjectSelector extends LitElement {
const translationKey = this.selector.object?.translation_key;
if (this.localizeValue && translationKey) {
const label = this.localizeValue(
`${translationKey}.fields.${schema.name}`
);
const label =
this.localizeValue(`${translationKey}.fields.${schema.name}.name`) ||
// Fallback for backward compatibility
this.localizeValue(`${translationKey}.fields.${schema.name}`);
if (label) {
return label;
}
@@ -57,6 +63,20 @@ export class HaObjectSelector extends LitElement {
return this.selector.object?.fields?.[schema.name]?.label || schema.name;
};
private _computeHelper = (schema: HaFormSchema): string => {
const translationKey = this.selector.object?.translation_key;
if (this.localizeValue && translationKey) {
const helper = this.localizeValue(
`${translationKey}.fields.${schema.name}.description`
);
if (helper) {
return helper;
}
}
return this.selector.object?.fields?.[schema.name]?.description || "";
};
private _renderItem(item: any, index: number) {
const labelField =
this.selector.object!.label_field ||
@@ -92,7 +112,7 @@ export class HaObjectSelector extends LitElement {
? html`
<ha-svg-icon
class="handle"
.path=${mdiDrag}
.path=${mdiDragHorizontalVariant}
slot="start"
></ha-svg-icon>
`
@@ -209,6 +229,7 @@ export class HaObjectSelector extends LitElement {
schema: this._schema(this.selector),
data: {},
computeLabel: this._computeLabel,
computeHelper: this._computeHelper,
submitText: this.hass.localize("ui.common.add"),
});

View File

@@ -1,4 +1,4 @@
import { mdiDrag } from "@mdi/js";
import { mdiDragHorizontalVariant } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
@@ -197,7 +197,7 @@ export class HaSelectSelector extends LitElement {
? html`
<ha-svg-icon
slot="icon"
.path=${mdiDrag}
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
`
: nothing}

View File

@@ -36,7 +36,7 @@ export class HaSelectorUiStateContent extends SubscribeMixin(LitElement) {
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.allowName=${this.selector.ui_state_content?.allow_name}
.allowName=${this.selector.ui_state_content?.allow_name || false}
></ha-entity-state-content-picker>
`;
}

View File

@@ -34,8 +34,6 @@ const LOAD_ELEMENTS = {
file: () => import("./ha-selector-file"),
floor: () => import("./ha-selector-floor"),
label: () => import("./ha-selector-label"),
image: () => import("./ha-selector-image"),
background: () => import("./ha-selector-background"),
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),
number: () => import("./ha-selector-number"),

View File

@@ -53,7 +53,7 @@ class HaServicePicker extends LitElement {
item,
{ index }
) => html`
<ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
<ha-service-icon
slot="start"
.hass=${this.hass}
@@ -76,34 +76,42 @@ class HaServicePicker extends LitElement {
</ha-combo-box-item>
`;
private _valueRenderer: PickerValueRenderer = (value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
private _valueRenderer = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): PickerValueRenderer =>
(value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
if (!this.hass.services[domain]?.[service]) {
return html`
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
if (!services[domain]?.[service]) {
return html`
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
const serviceName =
this.hass.localize(`component.${domain}.services.${service}.name`) ||
this.hass.services[domain][service].name ||
service;
const serviceName =
localize(`component.${domain}.services.${service}.name`) ||
services[domain][service].name ||
service;
return html`
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code">${serviceId}</span>`
: nothing}
`;
};
return html`
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code"
>${serviceId}</span
>`
: nothing}
`;
}
);
protected render(): TemplateResult {
const placeholder =
@@ -123,7 +131,10 @@ class HaServicePicker extends LitElement {
.value=${this.value}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
.valueRenderer=${this._valueRenderer(
this.hass.localize,
this.hass.services
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -162,7 +173,9 @@ class HaServicePicker extends LitElement {
const description =
this.hass.localize(
`component.${domain}.services.${service}.description`
) || services[domain][service].description;
) ||
services[domain][service].description ||
"";
items.push({
id: serviceId,

View File

@@ -29,6 +29,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
@@ -536,11 +537,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(this.hass);
return html`
<ha-md-list-item
href="/profile"
type="link"
class="user ${selectedPanel === "profile" ? " selected" : ""}"
class=${classMap({
user: true,
selected: selectedPanel === "profile",
rtl: isRTL,
})}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
>
@@ -666,7 +673,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`;
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, 0px))`;
tooltip.style.left = `calc(${item.offsetLeft + item.clientWidth + 8}px + var(--safe-area-inset-left, var(--ha-space-0)))`;
}
private _hideTooltip() {
@@ -705,13 +712,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
background-color: var(--sidebar-background-color);
width: 100%;
box-sizing: border-box;
padding-bottom: calc(14px + var(--safe-area-inset-bottom, 0px));
padding-bottom: calc(
14px + var(--safe-area-inset-bottom, var(--ha-space-0))
);
}
.menu {
height: calc(var(--header-height) + var(--safe-area-inset-top, 0px));
height: calc(
var(--header-height) + var(--safe-area-inset-top, var(--ha-space-0))
);
box-sizing: border-box;
display: flex;
padding: 0 4px;
padding: 0 var(--ha-space-1);
border-bottom: 1px solid transparent;
white-space: nowrap;
font-weight: var(--ha-font-weight-normal);
@@ -726,13 +737,17 @@ class HaSidebar extends SubscribeMixin(LitElement) {
);
font-size: var(--ha-font-size-xl);
align-items: center;
padding-left: calc(4px + var(--safe-area-inset-left, 0px));
padding-inline-start: calc(4px + var(--safe-area-inset-left, 0px));
padding-left: calc(
var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
);
padding-inline-start: calc(
var(--ha-space-1) + var(--safe-area-inset-left, var(--ha-space-0))
);
padding-inline-end: initial;
padding-top: var(--safe-area-inset-top, 0px);
padding-top: var(--safe-area-inset-top, var(--ha-space-0));
}
:host([expanded]) .menu {
width: calc(256px + var(--safe-area-inset-left, 0px));
width: calc(256px + var(--safe-area-inset-left, var(--ha-space-0)));
}
:host([narrow][expanded]) .menu {
width: 100%;
@@ -748,8 +763,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: none;
}
:host([narrow]) .title {
margin: 0;
padding: 0 16px;
margin: var(--ha-space-0);
padding: var(--ha-space-0) var(--ha-space-4);
}
:host([expanded]) .title {
display: initial;
@@ -761,13 +776,16 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-fade-in,
ha-md-list {
height: calc(
100% - var(--header-height) - var(--safe-area-inset-top, 0px) -
100% - var(--header-height) - var(
--safe-area-inset-top,
var(--ha-space-0)
) -
132px
);
}
ha-fade-in {
padding: 4px 0;
padding: var(--ha-space-1) var(--ha-space-0);
box-sizing: border-box;
display: flex;
justify-content: center;
@@ -777,29 +795,29 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-md-list {
overflow-x: hidden;
background: none;
margin-left: var(--safe-area-inset-left, 0px);
margin-left: var(--safe-area-inset-left, var(--ha-space-0));
}
ha-md-list-item {
flex-shrink: 0;
box-sizing: border-box;
margin: 4px;
margin: var(--ha-space-1);
border-radius: var(--ha-border-radius-sm);
--md-list-item-one-line-container-height: 40px;
--md-list-item-one-line-container-height: var(--ha-space-10);
--md-list-item-top-space: 0;
--md-list-item-bottom-space: 0;
width: 48px;
width: var(--ha-space-12);
position: relative;
--md-list-item-label-text-color: var(--sidebar-text-color);
--md-list-item-leading-space: 12px;
--md-list-item-trailing-space: 12px;
--md-list-item-leading-icon-size: 24px;
--md-list-item-leading-space: var(--ha-space-3);
--md-list-item-trailing-space: var(--ha-space-3);
--md-list-item-leading-icon-size: var(--ha-space-6);
}
:host([expanded]) ha-md-list-item {
width: 248px;
}
:host([narrow][expanded]) ha-md-list-item {
width: calc(240px - var(--safe-area-inset-left, 0px));
width: calc(240px - var(--safe-area-inset-left, var(--ha-space-0)));
}
ha-md-list-item.selected {
@@ -823,7 +841,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-icon[slot="start"],
ha-svg-icon[slot="start"] {
width: 24px;
width: var(--ha-space-6);
flex-shrink: 0;
color: var(--sidebar-icon-color);
}
@@ -856,7 +874,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
display: flex;
justify-content: center;
align-items: center;
min-width: 8px;
min-width: var(--ha-space-2);
border-radius: var(--ha-border-radius-xl);
font-weight: var(--ha-font-weight-normal);
line-height: normal;
@@ -867,22 +885,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
ha-svg-icon + .badge {
position: absolute;
top: 4px;
top: var(--ha-space-1);
left: 26px;
border-radius: var(--ha-border-radius-md);
font-size: 0.65em;
line-height: var(--ha-line-height-expanded);
padding: 0 4px;
padding: var(--ha-space-0) var(--ha-space-1);
}
ha-md-list-item.user {
--md-list-item-leading-icon-size: 40px;
--md-list-item-leading-space: 4px;
--md-list-item-leading-icon-size: var(--ha-space-10);
--md-list-item-leading-space: var(--ha-space-1);
}
ha-md-list-item.user.rtl {
--md-list-item-leading-space: var(--ha-space-3);
}
ha-user-badge {
flex-shrink: 0;
margin-right: -8px;
margin-right: calc(var(--ha-space-2) * -1);
}
.spacer {
@@ -894,7 +916,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
color: var(--sidebar-text-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
padding: 16px;
padding: var(--ha-space-4);
white-space: nowrap;
}
@@ -906,7 +928,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
white-space: nowrap;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: 4px;
padding: var(--ha-space-1);
font-weight: var(--ha-font-weight-medium);
}

View File

@@ -59,12 +59,33 @@ export class HaSlider extends Slider {
background-color: var(--ha-slider-thumb-color, var(--primary-color));
}
#thumb:after {
content: "";
border-radius: 50%;
position: absolute;
width: calc(var(--thumb-width) * 2 + 8px);
height: calc(var(--thumb-height) * 2 + 8px);
left: calc(-50% - 4px);
top: calc(-50% - 4px);
cursor: pointer;
}
#slider:focus-visible:not(.disabled) #thumb,
#slider:focus-visible:not(.disabled) #thumb-min,
#slider:focus-visible:not(.disabled) #thumb-max {
outline: var(--wa-focus-ring);
}
#track:after {
content: "";
position: absolute;
top: calc(-50% - 4px);
left: 0;
width: 100%;
height: calc(var(--track-size) * 2 + 8px);
cursor: pointer;
}
#indicator {
background-color: var(
--ha-slider-indicator-color,

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,18 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import "./ha-dialog-header";
import "./ha-icon-button";
import type { HomeAssistant } from "../types";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dialog-header";
import "./ha-icon-button";
export type DialogWidth = "small" | "medium" | "large" | "full";
@@ -90,6 +96,11 @@ export class HaWaDialog extends LitElement {
@state()
private _open = false;
@query(".body") public bodyContainer!: HTMLDivElement;
@state()
private _bodyScrolled = false;
protected updated(
changedProperties: Map<string | number | symbol, unknown>
): void {
@@ -107,10 +118,14 @@ export class HaWaDialog extends LitElement {
.lightDismiss=${!this.preventScrimClose}
without-header
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
>
<slot name="header">
<ha-dialog-header .subtitlePosition=${this.headerSubtitlePosition}>
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="close"
@@ -129,7 +144,7 @@ export class HaWaDialog extends LitElement {
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>
<div class="body ha-scrollbar">
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>
</div>
<slot name="footer" slot="footer"></slot>
@@ -146,6 +161,10 @@ export class HaWaDialog extends LitElement {
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
};
private _handleAfterShow = () => {
fireEvent(this, "after-show");
};
private _handleAfterHide = () => {
this._open = false;
fireEvent(this, "closed");
@@ -156,6 +175,11 @@ export class HaWaDialog extends LitElement {
this._open = false;
}
@eventOptions({ passive: true })
private _handleBodyScroll(ev: Event) {
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
}
static styles = [
haStyleScrollbar,
css`
@@ -172,7 +196,7 @@ export class HaWaDialog extends LitElement {
)
)
);
--width: var(--ha-dialog-width-md, min(580px, var(--full-width)));
--width: min(var(--ha-dialog-width-md, 580px), var(--full-width));
--spacing: var(--dialog-content-padding, var(--ha-space-6));
--show-duration: var(--ha-dialog-show-duration, 200ms);
--hide-duration: var(--ha-dialog-hide-duration, 200ms);
@@ -193,11 +217,11 @@ export class HaWaDialog extends LitElement {
}
:host([width="small"]) wa-dialog {
--width: var(--ha-dialog-width-sm, min(320px, var(--full-width)));
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
}
:host([width="large"]) wa-dialog {
--width: var(--ha-dialog-width-lg, min(720px, var(--full-width)));
--width: min(var(--ha-dialog-width-lg, 720px), var(--full-width));
}
:host([width="full"]) wa-dialog {
@@ -211,6 +235,7 @@ export class HaWaDialog extends LitElement {
--ha-dialog-max-height,
calc(100% - var(--ha-space-20))
);
min-height: var(--ha-dialog-min-height);
position: var(--dialog-surface-position, relative);
margin-top: var(--dialog-surface-margin-top, auto);
display: flex;
@@ -247,10 +272,7 @@ export class HaWaDialog extends LitElement {
.header-title {
margin: 0;
margin-bottom: 0;
color: var(
--ha-dialog-header-title-color,
var(--ha-color-on-surface-default, var(--primary-text-color))
);
color: var(--ha-dialog-header-title-color, var(--primary-text-color));
font-size: var(
--ha-dialog-header-title-font-size,
var(--ha-font-size-2xl)
@@ -287,6 +309,7 @@ export class HaWaDialog extends LitElement {
}
:host([flexcontent]) .body {
max-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
@@ -315,6 +338,7 @@ declare global {
interface HASSDomEvents {
opened: undefined;
"after-show": undefined;
closed: undefined;
}
}

View File

@@ -321,6 +321,10 @@ class HaWebRtcPlayer extends LitElement {
if (!this._remoteStream) {
return;
}
// If the track is audio and the player is muted, we do not add it to the stream.
if (event.track.kind === "audio" && this.muted) {
return;
}
this._remoteStream.addTrack(event.track);
if (!this.hasUpdated) {
await this.updateComplete;

View File

@@ -15,6 +15,7 @@ import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
import { isUnavailableState } from "../../data/entity";
import type {
@@ -693,10 +694,12 @@ export class HaMediaPlayerBrowse extends LitElement {
`
: ""}
</div>
<ha-tooltip .for="grid-${child.title}" distance="-4">
<ha-tooltip .for="grid-${slugify(child.title)}" distance="-4">
${child.title}
</ha-tooltip>
<div .id="grid-${child.title}" class="title">${child.title}</div>
<div .id="grid-${slugify(child.title)}" class="title">
${child.title}
</div>
</ha-card>
</div>
`;

View File

@@ -0,0 +1,76 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../../ha-wa-dialog";
import "../ha-target-picker-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@customElement("ha-dialog-target-details")
class DialogTargetDetails extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: TargetDetailsDialogParams;
@state() private _opened = false;
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
this._opened = true;
}
public closeDialog() {
this._opened = false;
return true;
}
private _dialogClosed() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._params = undefined;
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._opened}
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
)}
header-subtitle=${`${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}`}
@closed=${this._dialogClosed}
>
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
expand
></ha-target-picker-item-row>
</ha-wa-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-dialog-target-details": DialogTargetDetails;
}
}

View File

@@ -0,0 +1,28 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity";
import type { TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
export type NewBackupType = "automatic" | "manual";
export interface TargetDetailsDialogParams {
title: string;
type: TargetType;
itemId: string;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
includeDeviceClasses?: string[];
}
export const loadTargetDetailsDialog = () => import("./dialog-target-details");
export const showTargetDetailsDialog = (
element: HTMLElement,
params: TargetDetailsDialogParams
) =>
fireEvent(element, "show-dialog", {
dialogTag: "ha-dialog-target-details",
dialogImport: loadTargetDetailsDialog,
dialogParams: params,
});

View File

@@ -0,0 +1,113 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import "../ha-expansion-panel";
import "../ha-md-list";
import "./ha-target-picker-item-row";
@customElement("ha-target-picker-item-group")
export class HaTargetPickerItemGroup extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: TargetTypeFloorless;
@property({ attribute: false }) public items!: Partial<
Record<TargetType, string[]>
>;
@property({ type: Boolean, reflect: true }) public collapsed = false;
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Show only targets with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show only targets with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
protected render() {
let count = 0;
Object.values(this.items).forEach((items) => {
if (items) {
count += items.length;
}
});
return html`<ha-expansion-panel
.expanded=${!this.collapsed}
left-chevron
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="heading">
${this.hass.localize(
`ui.components.target-picker.selected.${this.type}`,
{
count,
}
)}
</div>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-expansion-panel>`;
}
private _expandedChanged(ev: CustomEvent) {
this.collapsed = !ev.detail.expanded;
}
static styles = css`
:host {
display: block;
--expansion-panel-content-padding: var(--ha-space-0);
}
ha-expansion-panel::part(summary) {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
display: flex;
justify-content: space-between;
min-height: unset;
}
ha-md-list {
padding: var(--ha-space-0);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-item-group": HaTargetPickerItemGroup;
}
}

View File

@@ -0,0 +1,694 @@
import { consume } from "@lit/context";
import {
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
} from "@mdi/js";
import { css, 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 { computeAreaName } from "../../common/entity/compute_area_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
entityRegMeetsFilter,
extractFromTarget,
type ExtractFromTargetResult,
type ExtractFromTargetResultReferenced,
type TargetType,
} from "../../data/target";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon-button";
import "../ha-md-list";
import type { HaMdList } from "../ha-md-list";
import "../ha-md-list-item";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-svg-icon";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
@customElement("ha-target-picker-item-row")
export class HaTargetPickerItemRow extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ reflect: true }) public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@property({ type: Boolean }) public expand = false;
@property({ type: Boolean, attribute: "sub-entry", reflect: true })
public subEntry = false;
@property({ type: Boolean, attribute: "hide-context" })
public hideContext = false;
@property({ attribute: false })
public parentEntries?: ExtractFromTargetResultReferenced;
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
/**
* Show only targets with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show only targets with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
@state() private _iconImg?: string;
@state() private _domainName?: string;
@state() private _entries?: ExtractFromTargetResult;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
@query("ha-md-list-item") public item?: HaMdListItem;
@query("ha-md-list") public list?: HaMdList;
@query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow;
protected willUpdate(changedProps: PropertyValues) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
}
}
protected render() {
const { name, context, iconPath, fallbackIconPath, stateObject } =
this._itemData(this.type, this.itemId);
const showEntities = this.type !== "entity";
const entries = this.parentEntries || this._entries;
// Don't show sub entries that have no entities
if (
this.subEntry &&
this.type !== "entity" &&
(!entries || entries.referenced_entities.length === 0)
) {
return nothing;
}
return html`
<ha-md-list-item type="text">
<div class="icon" slot="start">
${this.subEntry
? html`
<div class="horizontal-line-wrapper">
<div class="horizontal-line"></div>
</div>
`
: nothing}
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
>
</ha-state-icon>
`
: nothing}
</div>
<div slot="headline">${name}</div>
${context && !this.hideContext
? html`<span slot="supporting-text">${context}</span>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: nothing}
</ha-md-list-item>
${this.expand && entries && entries.referenced_entities
? this._renderEntries()
: nothing}
`;
}
private _renderEntries() {
const entries = this.parentEntries || this._entries;
let nextType: TargetType =
this.type === "floor"
? "area"
: this.type === "area"
? "device"
: "entity";
if (this.type === "label") {
if (entries?.referenced_areas.length) {
nextType = "area";
} else if (entries?.referenced_devices.length) {
nextType = "device";
}
}
const rows1 =
(nextType === "area"
? entries?.referenced_areas
: nextType === "device" && this.type !== "label"
? entries?.referenced_devices
: this.type !== "label"
? entries?.referenced_entities
: []) || [];
const devicesInAreas = [] as string[];
const rows1Entries =
nextType === "entity"
? undefined
: rows1.map((rowItem) => {
const nextEntries = {
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities: [] as string[],
};
if (nextType === "area") {
nextEntries.referenced_devices =
entries?.referenced_devices.filter(
(device_id) =>
this.hass.devices?.[device_id]?.area_id === rowItem &&
entries?.referenced_entities.some(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
)
) || ([] as string[]);
devicesInAreas.push(...nextEntries.referenced_devices);
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
return (
entity.area_id === rowItem ||
!entity.device_id ||
nextEntries.referenced_devices.includes(entity.device_id)
);
}) || ([] as string[]);
return nextEntries;
}
nextEntries.referenced_entities =
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === rowItem
) || ([] as string[]);
return nextEntries;
});
const entityRows =
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id].area_id === this.itemId
)
: [];
const deviceRows =
this.type === "label" && entries
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id].labels.includes(this.itemId)
)
: [];
const deviceRowsEntries =
deviceRows.length === 0
? undefined
: deviceRows.map((device_id) => ({
referenced_areas: [] as string[],
referenced_devices: [] as string[],
referenced_entities:
entries?.referenced_entities.filter(
(entity_id) =>
this.hass.entities?.[entity_id]?.device_id === device_id
) || ([] as string[]),
}));
return html`
<div class="entries-tree">
<div class="line-wrapper">
<div class="line"></div>
</div>
<ha-md-list class="entries">
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
</ha-md-list>
</div>
`;
}
private async _updateItemData() {
if (this.type === "entity") {
this._entries = undefined;
return;
}
try {
const entries = await extractFromTarget(this.hass, {
[`${this.type}_id`]: [this.itemId],
});
const hiddenAreaIds: string[] = [];
if (this.type === "floor" || this.type === "label") {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
area,
this.hass.devices,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
return true;
}
hiddenAreaIds.push(area_id);
return false;
}
);
}
const hiddenDeviceIds: string[] = [];
if (
this.type === "floor" ||
this.type === "area" ||
this.type === "label"
) {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
device,
this.hass.entities,
this.deviceFilter,
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
)
) {
return true;
}
hiddenDeviceIds.push(device_id);
return false;
}
);
}
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
if (
(this.type === "area" && entity.area_id === this.itemId) ||
(this.type === "floor" &&
entity.area_id &&
entries.referenced_areas.includes(entity.area_id)) ||
(this.type === "label" && entity.labels.includes(this.itemId)) ||
entries.referenced_devices.includes(entity.device_id || "")
) {
return entityRegMeetsFilter(
entity,
this.type === "label",
this.includeDomains,
this.includeDeviceClasses,
this.hass.states,
this.entityFilter
);
}
return false;
}
);
this._entries = entries;
} catch (e) {
// eslint-disable-next-line no-console
console.error("Failed to extract target", e);
}
}
private _itemData = memoizeOne((type: TargetType, item: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[item];
return {
name: floor?.name || item,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[item];
return {
name: area?.name || item,
context: area.floor_id && this.hass.floors?.[area.floor_id]?.name,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[item];
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device ? computeDeviceNameDisplay(device, this.hass) : item,
context: device?.area_id && this.hass.areas?.[device.area_id]?.name,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(item));
const stateObject = this.hass.states[item];
const entityName = computeEntityName(
stateObject,
this.hass.entities,
this.hass.devices
);
const { area, device } = getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(computeRTL(this.hass) ? " ◂ " : " ▸ ");
return {
name: entityName || deviceName || item,
context,
stateObject,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === item);
return {
name: label?.name || item,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private _removeItem(ev) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl({
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _openDetails() {
showTargetDetailsDialog(this, {
title: this._itemData(this.type, this.itemId).name,
type: this.type,
itemId: this.itemId,
deviceFilter: this.deviceFilter,
entityFilter: this.entityFilter,
includeDomains: this.includeDomains,
includeDeviceClasses: this.includeDeviceClasses,
});
}
static styles = [
buttonLinkStyle,
css`
:host {
--md-list-item-top-space: var(--ha-space-0);
--md-list-item-bottom-space: var(--ha-space-0);
--md-list-item-leading-space: var(--ha-space-2);
--md-list-item-trailing-space: var(--ha-space-2);
--md-list-item-two-line-container-height: 56px;
}
:host([expand]:not([sub-entry])) ha-md-list-item {
border: 2px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-fill-neutral-quiet-resting);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
state-badge {
color: var(--ha-color-on-neutral-quiet);
}
.icon {
display: flex;
}
img {
width: 24px;
height: 24px;
}
ha-icon-button {
--mdc-icon-button-size: 32px;
}
.summary {
display: flex;
flex-direction: column;
align-items: flex-end;
line-height: var(--ha-line-height-condensed);
}
:host([sub-entry]) .summary {
margin-right: var(--ha-space-12);
}
.summary .main {
font-weight: var(--ha-font-weight-medium);
}
.summary .secondary {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.entries-tree {
display: flex;
position: relative;
}
.entries-tree .line-wrapper {
padding: var(--ha-space-5);
}
.entries-tree .line-wrapper .line {
border-left: 2px dashed var(--divider-color);
height: calc(100% - 28px);
position: absolute;
top: 0;
}
:host([sub-entry]) .entries-tree .line-wrapper .line {
height: calc(100% - 12px);
top: -18px;
}
.entries {
padding: 0;
--md-item-overflow: visible;
}
.horizontal-line-wrapper {
position: relative;
}
.horizontal-line-wrapper .horizontal-line {
position: absolute;
top: 11px;
margin-inline-start: -28px;
width: 29px;
border-top: 2px dashed var(--divider-color);
}
button.link {
text-decoration: none;
color: var(--primary-color);
}
button.link:hover,
button.link:focus {
text-decoration: underline;
}
.domain {
width: fit-content;
border-radius: var(--ha-border-radius-md);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1);
font-family: var(--ha-font-family-code);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-item-row": HaTargetPickerItemRow;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,355 @@
import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
import {
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiTextureBox,
mdiUnfoldMoreVertical,
} from "@mdi/js";
import { css, html, LitElement, nothing, unsafeCSS } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { computeCssColor } from "../../common/color/compute-color";
import { hex2rgb } from "../../common/color/convert-color";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { getConfigEntry } from "../../data/config_entries";
import { labelsContext } from "../../data/context";
import { domainToName } from "../../data/integration";
import type { LabelRegistryEntry } from "../../data/label_registry";
import type { TargetType } from "../../data/target";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
import "../ha-md-list";
import "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-tooltip";
@customElement("ha-target-picker-value-chip")
export class HaTargetPickerValueChip extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public type!: TargetType;
@property({ attribute: "item-id" }) public itemId!: string;
@state() private _domainName?: string;
@state() private _iconImg?: string;
@state()
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
protected render() {
const { name, iconPath, fallbackIconPath, stateObject, color } =
this._itemData(this.type, this.itemId);
return html`
<div
class="mdc-chip ${classMap({
[this.type]: true,
})}"
style=${color
? `--color: rgb(${color}); --background-color: rgba(${color}, .5)`
: ""}
>
${iconPath
? html`<ha-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.icon=${iconPath}
></ha-icon>`
: this._iconImg
? html`<img
class="mdc-chip__icon mdc-chip__icon--leading"
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.path=${fallbackIconPath}
></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
class="mdc-chip__icon mdc-chip__icon--leading"
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
<span role="gridcell">
<span role="button" tabindex="0" class="mdc-chip__primary-action">
<span id="title-${this.itemId}" class="mdc-chip__text"
>${name}</span
>
</span>
</span>
${this.type === "entity"
? nothing
: html`<span role="gridcell">
<ha-tooltip .for="expand-${slugify(this.itemId)}"
>${this.hass.localize(
`ui.components.target-picker.expand_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize(
"ui.components.target-picker.expand"
)}
.path=${mdiUnfoldMoreVertical}
hide-title
.id="expand-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._handleExpand}
></ha-icon-button>
</span>`}
<span role="gridcell">
<ha-tooltip .for="remove-${slugify(this.itemId)}">
${this.hass.localize(
`ui.components.target-picker.remove_${this.type}_id`
)}
</ha-tooltip>
<ha-icon-button
class="mdc-chip__icon mdc-chip__icon--trailing"
.label=${this.hass.localize("ui.components.target-picker.remove")}
.path=${mdiClose}
hide-title
.id="remove-${slugify(this.itemId)}"
.type=${this.type}
@click=${this._removeItem}
></ha-icon-button>
</span>
</div>
`;
}
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
if (type === "floor") {
const floor = this.hass.floors?.[itemId];
return {
name: floor?.name || itemId,
iconPath: floor?.icon,
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
};
}
if (type === "area") {
const area = this.hass.areas?.[itemId];
return {
name: area?.name || itemId,
iconPath: area?.icon,
fallbackIconPath: mdiTextureBox,
};
}
if (type === "device") {
const device = this.hass.devices?.[itemId];
if (device.primary_config_entry) {
this._getDeviceDomain(device.primary_config_entry);
}
return {
name: device ? computeDeviceNameDisplay(device, this.hass) : itemId,
fallbackIconPath: mdiDevices,
};
}
if (type === "entity") {
this._setDomainName(computeDomain(itemId));
const stateObject = this.hass.states[itemId];
const entityName = computeEntityName(
stateObject,
this.hass.entities,
this.hass.devices
);
const { device } = getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const deviceName = device ? computeDeviceName(device) : undefined;
return {
name: entityName || deviceName || itemId,
stateObject,
};
}
// type label
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
let color = label?.color ? computeCssColor(label.color) : undefined;
if (color?.startsWith("var(")) {
const computedStyles = getComputedStyle(this);
color = computedStyles.getPropertyValue(
color.substring(4, color.length - 1)
);
}
if (color?.startsWith("#")) {
color = hex2rgb(color).join(",");
}
return {
name: label?.name || itemId,
iconPath: label?.icon,
fallbackIconPath: mdiLabel,
color,
};
});
private _setDomainName(domain: string) {
this._domainName = domainToName(this.hass.localize, domain);
}
private async _getDeviceDomain(configEntryId: string) {
try {
const data = await getConfigEntry(this.hass, configEntryId);
const domain = data.config_entry.domain;
this._iconImg = brandsUrl({
domain: domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
});
this._setDomainName(domain);
} catch {
// failed to load config entry -> ignore
}
}
private _removeItem(ev) {
ev.stopPropagation();
fireEvent(this, "remove-target-item", {
type: this.type,
id: this.itemId,
});
}
private _handleExpand(ev) {
ev.stopPropagation();
fireEvent(this, "expand-target-item", {
type: this.type,
id: this.itemId,
});
}
static styles = css`
${unsafeCSS(chipStyles)}
.mdc-chip {
color: var(--primary-text-color);
}
.mdc-chip.add {
color: rgba(0, 0, 0, 0.87);
}
.add-container {
position: relative;
display: inline-flex;
}
.mdc-chip:not(.add) {
cursor: default;
}
.mdc-chip ha-icon-button {
--mdc-icon-button-size: 24px;
display: flex;
align-items: center;
outline: none;
}
.mdc-chip ha-icon-button ha-svg-icon {
border-radius: 50%;
background: var(--secondary-text-color);
}
.mdc-chip__icon.mdc-chip__icon--trailing {
width: var(--ha-space-4);
height: var(--ha-space-4);
--mdc-icon-size: 14px;
color: var(--secondary-text-color);
margin-inline-start: var(--ha-space-1) !important;
margin-inline-end: calc(-1 * var(--ha-space-1)) !important;
direction: var(--direction);
}
.mdc-chip__icon--leading {
display: flex;
align-items: center;
justify-content: center;
--mdc-icon-size: 20px;
border-radius: var(--ha-border-radius-circle);
padding: 6px;
margin-left: -13px !important;
margin-inline-start: -13px !important;
margin-inline-end: var(--ha-space-1) !important;
direction: var(--direction);
}
.expand-btn {
margin-right: var(--ha-space-0);
margin-inline-end: var(--ha-space-0);
margin-inline-start: initial;
}
.mdc-chip.area:not(.add),
.mdc-chip.floor:not(.add) {
border: 1px solid #fed6a4;
background: var(--card-background-color);
}
.mdc-chip.area:not(.add) .mdc-chip__icon--leading,
.mdc-chip.area.add,
.mdc-chip.floor:not(.add) .mdc-chip__icon--leading,
.mdc-chip.floor.add {
background: #fed6a4;
}
.mdc-chip.device:not(.add) {
border: 1px solid #a8e1fb;
background: var(--card-background-color);
}
.mdc-chip.device:not(.add) .mdc-chip__icon--leading,
.mdc-chip.device.add {
background: #a8e1fb;
}
.mdc-chip.entity:not(.add) {
border: 1px solid #d2e7b9;
background: var(--card-background-color);
}
.mdc-chip.entity:not(.add) .mdc-chip__icon--leading,
.mdc-chip.entity.add {
background: #d2e7b9;
}
.mdc-chip.label:not(.add) {
border: 1px solid var(--color, #e0e0e0);
background: var(--card-background-color);
}
.mdc-chip.label:not(.add) .mdc-chip__icon--leading,
.mdc-chip.label.add {
background: var(--background-color, #e0e0e0);
}
.mdc-chip:hover {
z-index: 5;
}
:host([disabled]) .mdc-chip {
opacity: var(--light-disabled-opacity);
pointer-events: none;
}
.tooltip-icon-img {
width: 24px;
height: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-target-picker-value-chip": HaTargetPickerValueChip;
}
}

View File

@@ -6,8 +6,6 @@ import {
mdiCallSplit,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiFormatListNumbered,
mdiGestureDoubleTap,
mdiHandBackRight,
@@ -16,10 +14,10 @@ import {
mdiRoomService,
mdiShuffleDisabled,
mdiTimerOutline,
mdiTools,
mdiTrafficLight,
} from "@mdi/js";
import type { AutomationElementGroup } from "./automation";
import type { AutomationElementGroupCollection } from "./automation";
import type { Action } from "./script";
export const ACTION_ICONS = {
condition: mdiAbTesting,
@@ -48,37 +46,73 @@ export const YAML_ONLY_ACTION_TYPES = new Set<keyof typeof ACTION_ICONS>([
"variables",
]);
export const ACTION_GROUPS: AutomationElementGroup = {
device_id: {},
helpers: {
icon: mdiTools,
members: {},
},
building_blocks: {
icon: mdiExcavator,
members: {
condition: {},
delay: {},
wait_template: {},
wait_for_trigger: {},
repeat_count: {},
repeat_while: {},
repeat_until: {},
repeat_for_each: {},
choose: {},
if: {},
stop: {},
sequence: {},
parallel: {},
variables: {},
export const ACTION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device_id: {},
serviceGroups: {},
},
},
other: {
icon: mdiDotsHorizontal,
members: {
{
titleKey: "ui.panel.config.automation.editor.actions.groups.helpers.label",
groups: {
helpers: {},
},
},
{
titleKey: "ui.panel.config.automation.editor.actions.groups.other.label",
groups: {
event: {},
service: {},
set_conversation_response: {},
other: {},
},
},
] as const;
export const ACTION_BUILDING_BLOCKS_GROUP = {
condition: {},
delay: {},
wait_template: {},
wait_for_trigger: {},
repeat_count: {},
repeat_while: {},
repeat_until: {},
repeat_for_each: {},
choose: {},
if: {},
stop: {},
sequence: {},
parallel: {},
variables: {},
};
// These will be replaced with the correct action
export const VIRTUAL_ACTIONS: Partial<
Record<keyof typeof ACTION_BUILDING_BLOCKS_GROUP, Action>
> = {
repeat_count: {
repeat: {
count: 2,
sequence: [],
},
},
repeat_while: {
repeat: {
while: [],
sequence: [],
},
},
repeat_until: {
repeat: {
until: [],
sequence: [],
},
},
repeat_for_each: {
repeat: {
for_each: {},
sequence: [],
},
},
} as const;

260
src/data/area_floor.ts Normal file
View File

@@ -0,0 +1,260 @@
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import {
floorCompare,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "./floor_registry";
export interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
floor?: FloorRegistryEntry;
area?: AreaRegistryEntry;
}
export interface AreaFloorValue {
id: string;
type: "floor" | "area";
}
export const getAreasAndFloors = (
states: HomeAssistant["states"],
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
formatId: (value: AreaFloorValue) => string,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[],
excludeFloors?: string[]
): 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;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) => deviceFilter!(device));
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) => areaIds!.includes(area.area_id));
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
if (excludeFloors) {
outputAreas = outputAreas.filter(
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
);
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassignedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
);
const compare = floorCompare(haFloors);
// @ts-ignore
const floorAreaEntries: [
FloorRegistryEntry | undefined,
AreaRegistryEntry[],
][] = Object.entries(floorAreaLookup)
.map(([floorId, floorAreas]) => {
const floor = floors.find((fl) => fl.floor_id === floorId)!;
return [floor, floorAreas] as const;
})
.sort(([floorA], [floorB]) => compare(floorA.floor_id, floorB.floor_id));
const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
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: formatId({ id: floor.floor_id, type: "floor" }),
type: "floor",
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
});
}
items.push(
...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ 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],
};
})
);
});
items.push(
...unassignedAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: formatId({ 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],
};
})
);
return items;
};

View File

@@ -4,6 +4,7 @@ import type {
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { navigate } from "../common/navigate";
import type { LocalizeKeys } from "../common/translations/localize";
import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint";
@@ -293,6 +294,11 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition {
not: Condition[];
}
export interface AutomationElementGroupCollection {
titleKey?: LocalizeKeys;
groups: AutomationElementGroup;
}
export type AutomationElementGroup = Record<
string,
{ icon?: string; members?: AutomationElementGroup }

View File

@@ -3,8 +3,6 @@ import {
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiExcavator,
mdiGateOr,
mdiIdentifier,
mdiMapClock,
@@ -15,7 +13,7 @@ import {
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import type { AutomationElementGroup } from "./automation";
import type { AutomationElementGroupCollection } from "./automation";
export const CONDITION_ICONS = {
device: mdiDevices,
@@ -31,25 +29,31 @@ export const CONDITION_ICONS = {
zone: mdiMapMarkerRadius,
};
export const CONDITION_GROUPS: AutomationElementGroup = {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { sun: {}, time: {}, zone: {} },
},
},
},
building_blocks: {
icon: mdiExcavator,
members: { and: {}, or: {}, not: {} },
},
other: {
icon: mdiDotsHorizontal,
members: {
{
titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
groups: {
template: {},
trigger: {},
},
},
} as const;
] as const;
export const CONDITION_BUILDING_BLOCKS_GROUP = {
and: {},
or: {},
not: {},
};
export const CONDITION_BUILDING_BLOCKS = ["and", "or", "not"];

View File

@@ -79,6 +79,7 @@ export interface DataEntryFlowStepAbort {
reason: string;
description_placeholders?: Record<string, string>;
translation_domain?: string;
next_flow?: [FlowType, string]; // [flow_type, flow_id]
}
export interface DataEntryFlowStepProgress {

View File

@@ -1,12 +1,20 @@
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateName } from "../common/entity/compute_state_name";
import { getDeviceContext } from "../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { ConfigEntry } from "./config_entries";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
import type { EntitySources } from "./entity_sources";
import { domainToName } from "./integration";
import type { RegistryEntry } from "./registry";
export {
@@ -163,3 +171,147 @@ export const getDeviceIntegrationLookup = (
}
return deviceIntegrations;
};
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
}
export const getDevices = (
hass: HomeAssistant,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string
): DevicePickerItem[] => {
const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
}
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
}
if (excludeDevices) {
inputDevices = inputDevices.filter(
(device) => !excludeDevices!.includes(device.id)
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
}
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
}
if (deviceFilter) {
inputDevices = inputDevices.filter(
(device) =>
// We always want to include the device of the current value
device.id === value || deviceFilter!(device)
);
}
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
const deviceName = computeDeviceNameDisplay(
device,
hass,
deviceEntityLookup[device.id]
);
const { area } = getDeviceContext(device, 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(hass.localize, domain) : undefined;
return {
id: device.id,
label: "",
primary:
deviceName ||
hass.localize("ui.components.device-picker.unnamed_device"),
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
sorting_label: deviceName || "zzz",
};
});
return outputDevices;
};

View File

@@ -1,3 +1,4 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { arrayLiteralIncludes } from "../common/array/literal-includes";
export const UNAVAILABLE = "unavailable";
@@ -10,3 +11,5 @@ export const OFF_STATES = [UNAVAILABLE, UNKNOWN, OFF] as const;
export const isUnavailableState = arrayLiteralIncludes(UNAVAILABLE_STATES);
export const isOffState = arrayLiteralIncludes(OFF_STATES);
export type HaEntityPickerEntityFilterFunc = (entityId: HassEntity) => boolean;

View File

@@ -1,12 +1,17 @@
import type { Connection } from "home-assistant-js-websocket";
import type { Connection, HassEntity } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import memoizeOne from "memoize-one";
import { computeDomain } from "../common/entity/compute_domain";
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
import { computeStateName } from "../common/entity/compute_state_name";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { computeRTL } from "../common/util/compute_rtl";
import { debounce } from "../common/util/debounce";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import { domainToName } from "./integration";
import type { LightColor } from "./light";
import type { RegistryEntry } from "./registry";
@@ -324,3 +329,122 @@ export const getAutomaticEntityIds = (
type: "config/entity_registry/get_automatic_entity_ids",
entity_ids,
});
export interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
}
export const getEntities = (
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string
): 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))
);
}
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(hass);
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 === 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 === 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 === value || (item.stateObj && entityFilter!(item.stateObj))
);
}
return items;
};

View File

@@ -68,13 +68,18 @@ export const getFloorAreaLookup = (
};
export const floorCompare =
(entries?: FloorRegistryEntry[], order?: string[]) =>
(entries?: HomeAssistant["floors"], order?: string[]) =>
(a: string, b: string) => {
const indexA = order ? order.indexOf(a) : -1;
const indexB = order ? order.indexOf(b) : -1;
if (indexA === -1 && indexB === -1) {
const nameA = entries?.[a]?.name ?? a;
const nameB = entries?.[b]?.name ?? b;
const floorA = entries?.[a];
const floorB = entries?.[b];
if (floorA && floorB && floorA.level !== floorB.level) {
return (floorB.level ?? -9999) - (floorA.level ?? -9999);
}
const nameA = floorA?.name ?? a;
const nameB = floorB?.name ?? b;
return stringCompare(nameA, nameB);
}
if (indexA === -1) {

View File

@@ -112,7 +112,6 @@ export interface HassioAddonDetails extends HassioAddonInfo {
translations: Record<string, AddonTranslations>;
watchdog: null | boolean;
webui: null | string;
webui_ha_aware?: boolean;
}
export interface HassioAddonsInfo {

View File

@@ -435,9 +435,9 @@ export const convertStatisticsToHistory = (
Object.entries(orderedStatistics).forEach(([key, value]) => {
const entityHistoryStates: EntityHistoryState[] = value.map((e) => ({
s: e.mean != null ? e.mean.toString() : e.state!.toString(),
lc: e.start / 1000,
lc: e.end / 1000,
a: {},
lu: e.start / 1000,
lu: e.end / 1000,
}));
statsHistoryStates[key] = entityHistoryStates;
});

View File

@@ -1,9 +1,20 @@
import { mdiLabel } from "@mdi/js";
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { computeDomain } from "../common/entity/compute_domain";
import { stringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../components/ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
import type { RegistryEntry } from "./registry";
export interface LabelRegistryEntry extends RegistryEntry {
@@ -88,3 +99,179 @@ export const deleteLabelRegistryEntry = (
type: "config/label_registry/delete",
label_id: labelId,
});
export const getLabels = (
hass: HomeAssistant,
labels?: LabelRegistryEntry[],
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeLabels?: string[]
): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [];
}
const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities);
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.labels.length > 0);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) => deviceFilter!(device));
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputLabels = labels;
const usedLabels = new Set<string>();
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
inputDevices.forEach((device) => {
device.labels.forEach((label) => usedLabels.add(label));
});
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
inputEntities.forEach((entity) => {
entity.labels.forEach((label) => usedLabels.add(label));
});
}
if (areaIds) {
areaIds.forEach((areaId) => {
const area = hass.areas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
if (excludeLabels) {
outputLabels = outputLabels.filter(
(label) => !excludeLabels!.includes(label.label_id)
);
}
if (inputDevices || inputEntities) {
outputLabels = outputLabels.filter((label) =>
usedLabels.has(label.label_id)
);
}
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
primary: label.name,
secondary: label.description ?? "",
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,
search_labels: [label.name, label.label_id, label.description].filter(
(v): v is string => Boolean(v)
),
}));
return items;
};

View File

@@ -1,3 +1,4 @@
import type { MediaSelectorValue } from "../../selector";
import type { LovelaceBadgeConfig } from "./badge";
import type { LovelaceCardConfig } from "./card";
import type { LovelaceSectionRawConfig } from "./section";
@@ -8,7 +9,7 @@ export interface ShowViewConfig {
}
export interface LovelaceViewBackgroundConfig {
image?: string;
image?: string | MediaSelectorValue;
opacity?: number;
size?: "auto" | "cover" | "contain";
alignment?:

View File

@@ -5,7 +5,6 @@ import type {
import { ensureArray } from "../common/array/ensure-array";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog";
import { isHelperDomain } from "../panels/config/helpers/const";
import type { UiAction } from "../panels/lovelace/components/hui-action-editor";
import type { HomeAssistant } from "../types";
@@ -47,8 +46,6 @@ export type Selector =
| FileSelector
| IconSelector
| LabelSelector
| ImageSelector
| BackgroundSelector
| LanguageSelector
| LocationSelector
| MediaSelector
@@ -273,14 +270,6 @@ export interface IconSelector {
} | null;
}
export interface ImageSelector {
image: { original?: boolean; crop?: CropOptions } | null;
}
export interface BackgroundSelector {
background: { original?: boolean; crop?: CropOptions } | null;
}
export interface LabelSelector {
label: {
multiple?: boolean;
@@ -352,6 +341,7 @@ export interface NumberSelector {
interface ObjectSelectorField {
selector: Selector;
label?: string;
description?: string;
required?: boolean;
}

164
src/data/target.ts Normal file
View File

@@ -0,0 +1,164 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import type { HaDevicePickerDeviceFilterFunc } from "../components/device/ha-device-picker";
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import type { DeviceRegistryEntry } from "./device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import type { EntityRegistryDisplayEntry } from "./entity_registry";
export type TargetType = "entity" | "device" | "area" | "label" | "floor";
export type TargetTypeFloorless = Exclude<TargetType, "floor">;
export interface ExtractFromTargetResult {
missing_areas: string[];
missing_devices: string[];
missing_floors: string[];
missing_labels: string[];
referenced_areas: string[];
referenced_devices: string[];
referenced_entities: string[];
}
export interface ExtractFromTargetResultReferenced {
referenced_areas: string[];
referenced_devices: string[];
referenced_entities: string[];
}
export const extractFromTarget = async (
hass: HomeAssistant,
target: HassServiceTarget
) =>
hass.callWS<ExtractFromTargetResult>({
type: "extract_from_target",
target,
});
export const areaMeetsFilter = (
area: AreaRegistryEntry,
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
): boolean => {
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
if (
areaDevices.some((device) =>
deviceMeetsFilter(
device,
entities,
deviceFilter,
includeDomains,
includeDeviceClasses,
states,
entityFilter
)
)
) {
return true;
}
const areaEntities = Object.values(entities).filter(
(entity) => entity.area_id === area.area_id
);
if (
areaEntities.some((entity) =>
entityRegMeetsFilter(
entity,
false,
includeDomains,
includeDeviceClasses,
states,
entityFilter
)
)
) {
return true;
}
return false;
};
export const deviceMeetsFilter = (
device: DeviceRegistryEntry,
entities: HomeAssistant["entities"],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
): boolean => {
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
if (
!devEntities.some((entity) =>
entityRegMeetsFilter(
entity,
false,
includeDomains,
includeDeviceClasses,
states,
entityFilter
)
)
) {
return false;
}
if (deviceFilter) {
return deviceFilter(device);
}
return true;
};
export const entityRegMeetsFilter = (
entity: EntityRegistryDisplayEntry,
includeSecondary = false,
includeDomains?: string[],
includeDeviceClasses?: string[],
states?: HomeAssistant["states"],
entityFilter?: HaEntityPickerEntityFilterFunc
): boolean => {
if (entity.hidden || (entity.entity_category && !includeSecondary)) {
return false;
}
if (
includeDomains &&
!includeDomains.includes(computeDomain(entity.entity_id))
) {
return false;
}
if (includeDeviceClasses) {
const stateObj = states?.[entity.entity_id];
if (!stateObj) {
return false;
}
if (
!stateObj.attributes.device_class ||
!includeDeviceClasses!.includes(stateObj.attributes.device_class)
) {
return false;
}
}
if (entityFilter) {
const stateObj = states?.[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
}
return true;
};

View File

@@ -4,7 +4,6 @@ import {
mdiClockOutline,
mdiCodeBraces,
mdiDevices,
mdiDotsHorizontal,
mdiFormatListBulleted,
mdiGestureDoubleTap,
mdiMapClock,
@@ -23,7 +22,7 @@ import {
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type {
AutomationElementGroup,
AutomationElementGroupCollection,
Trigger,
TriggerList,
} from "./automation";
@@ -49,16 +48,26 @@ export const TRIGGER_ICONS = {
list: mdiFormatListBulleted,
};
export const TRIGGER_GROUPS: AutomationElementGroup = {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: { calendar: {}, sun: {}, time: {}, time_pattern: {}, zone: {} },
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
{
groups: {
device: {},
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
time_location: {
icon: mdiMapClock,
members: {
calendar: {},
sun: {},
time: {},
time_pattern: {},
zone: {},
},
},
},
},
other: {
icon: mdiDotsHorizontal,
members: {
{
titleKey: "ui.panel.config.automation.editor.triggers.groups.other.label",
groups: {
event: {},
geo_location: {},
homeassistant: {},
@@ -70,7 +79,7 @@ export const TRIGGER_GROUPS: AutomationElementGroup = {
persistent_notification: {},
},
},
} as const;
] as const;
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
"triggers" in trigger;

View File

@@ -1,18 +1,13 @@
import type { Connection } from "home-assistant-js-websocket";
import { createCollection } from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import { stringCompare } from "../common/string/compare";
import { debounce } from "../common/util/debounce";
import type { AreaRegistryEntry } from "./area_registry";
const fetchAreaRegistry = (conn: Connection) =>
conn
.sendMessagePromise<AreaRegistryEntry[]>({
type: "config/area_registry/list",
})
.then((areas) =>
areas.sort((ent1, ent2) => stringCompare(ent1.name, ent2.name))
);
conn.sendMessagePromise<AreaRegistryEntry[]>({
type: "config/area_registry/list",
});
const subscribeAreaRegistryUpdates = (
conn: Connection,

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