Compare commits

..

79 Commits

Author SHA1 Message Date
Aidan Timson
ca2344eb10 Show url in label 2025-12-09 16:55:23 +00:00
Aidan Timson
6c9f51071c Reuse device page logic 2025-12-09 16:21:32 +00:00
Aidan Timson
0c1f57e2ce Follow device page link handling 2025-12-09 16:19:32 +00:00
Aidan Timson
e573f2012e Add configuration url link to config entry row 2025-12-09 16:17:55 +00:00
Aidan Timson
05ca8253f0 Update HaGenericPicker unknown value check to handle null and empty strings (#28462) 2025-12-09 15:53:28 +01:00
Aidan Timson
071161e82d Add area to helpers table (#28460) 2025-12-09 14:05:54 +00:00
Wendelin
9cd38c7128 Multi term search sort by search score (#28353) 2025-12-09 14:48:57 +01:00
Wendelin
6322c19a45 Generic picker warn unknown selected item (#28372)
* Add unknown item text localization to various pickers

* Review
2025-12-09 14:24:30 +02:00
karwosts
74b51b77fe Fix markdown card image sizing (#28449) 2025-12-09 13:15:12 +01:00
Wendelin
b80481b53e Generic picker: scroll to selected value on open (#28457) 2025-12-09 12:27:56 +01:00
Aidan Timson
2ce1eaf8c6 Revert "Add basic view transitions between tab UIs (#28374)" (#28451) 2025-12-09 13:18:51 +02:00
Aidan Timson
4030ce3f88 Migrate dialog-upload-backup to ha-wa-dialog (#28444)
* Migrate dialog-upload-backup.ts from ha-md-dialog to ha-wa-dialog

* Remove custom css, space tokens

* Restore
2025-12-09 09:32:28 +02:00
Aidan Timson
41cabde393 Migrate dialog-download-decrypted-backup to ha-wa-dialog (#28442)
* Migrate dialog-download-decrypted-backup.ts from ha-md-dialog to ha-wa-dialog

* Fixes from other migrations
2025-12-09 09:28:44 +02:00
Aidan Timson
47d1fdf673 Migrate change/show backup encryption key to ha-wa-dialog (#28428)
* Migrate dialog-change-backup-encryption-key.ts from ha-md-dialog to ha-wa-dialog

* Remove open render nothing, remove custom css size

* Migrate show backup key

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-09 07:24:43 +00:00
Aidan Timson
e59e83fffe Migrate dialog-areas-floors-order to ha-wa-dialog (#28424)
* Migrate dialog-areas-floors-order.ts from ha-md-dialog to ha-wa-dialog

* Fix saving

* Remove render nothing if dialog false

* Remove custom width
2025-12-09 09:12:29 +02:00
Aidan Timson
b896b78876 Migrate labs dialogs to ha-wa-dialog (#28429)
* Migrate dialog-labs-preview-feature-enable.ts from ha-md-dialog to ha-wa-dialog

* Migrate dialog-labs-progress.ts from ha-md-dialog to ha-wa-dialog

* Restore

* Remove use of slots

* Fix

* Remove header
2025-12-09 08:55:28 +02:00
Copilot
6e180c9fb4 Migrate ha-button-menu to ha-dropdown in 3 files (#28337)
* Initial plan

* Migrate ha-button-menu to ha-dropdown in 3 files

- Migrate ha-config-logs.ts: Replace ha-button-menu with ha-dropdown for log provider selection
- Migrate ha-qr-scanner.ts: Replace ha-button-menu with ha-dropdown for camera selection
- Migrate ha-data-table-labels.ts: Replace ha-button-menu with ha-dropdown for overflow labels

Following the migration pattern from PR #28293

Co-authored-by: wendevlin <12148533+wendevlin@users.noreply.github.com>

* Fix ha-data-table-labels migration to use proper @wa-select pattern

- Add HaDropdownItem type import
- Use @wa-show instead of @click for menu opening
- Use @wa-select for item selection with proper event handler
- Update comment to reference ha-dropdown instead of ha-button-menu

Co-authored-by: wendevlin <12148533+wendevlin@users.noreply.github.com>

* Fixes

* Remove unused code

* Remove unused styles

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wendevlin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
2025-12-08 21:30:35 +01:00
Aidan Timson
b50daecdec Create an adaptive dialog and bottom sheet component, migrate restart (#28344)
* Create a switchable dialog and bottom sheet component

* Migrate dialog restart

* Fix close action

* Docstring

* Rename type

* Rename func

* Document

* Remove flex, use flex

* Cleanup docstring

* Update bodyScrolled

* Cleanup

* Remove

* Rename to ha-adaptive-dialog

* Cleanup

* Format

* Fix forwarding of slots

* Fix style

* listenMediaQuery, simplify and remove double scroll handling

* Add slotted footer to bottom sheet for compat

* Add block-mode-change to stop mode switches for forms etc.

* Document block-mode-change

* Update gallery

* Fix gallery doc for wa-dialog (caught by agent review)

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

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

* Cleanup

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-08 17:32:31 +02:00
Aidan Timson
f5bd0816a8 Fix more info entity settings bottom fade (#28437)
* Use space tokens

* Fix settings bottom fade issue

* Space token

* Restore
2025-12-08 16:05:49 +02:00
Aidan Timson
b6d1e65044 Migrate dialog-edit-sidebar to ha-wa-dialog, fix dialog padding (#28426)
* Migrate dialog-edit-sidebar.ts from ha-md-dialog to ha-wa-dialog

* Format

* Fix wa dialog padding impl

* Remove custom size

* Use space tokens

* Remove render nothing

* Fix default top padding
2025-12-08 14:47:10 +02:00
Aidan Timson
e2312e52e3 Remove z-index from scrollable fades (#28434) 2025-12-08 14:33:30 +02:00
renovate[bot]
46a6da33d9 Update dependency terser-webpack-plugin to v5.3.15 (#28436)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-08 14:32:39 +02:00
Aidan Timson
e7b0b9a090 Add basic view transitions between tab UIs (#28374)
* Add basic view transitions between tab UIs

* Remove debug

* Add to debug navigation action
2025-12-08 14:22:05 +02:00
Petar Petrov
63ffc3b7f9 Restrict row resizing for stack and entities cards (#28422)
* Restrict row resizing for stack and entities cards

* Fix disabled state for grid layout slider
2025-12-08 12:53:37 +02:00
Nils Schönwald
ebfceae38c Update snowflake to 6 sides (#28406) 2025-12-08 09:16:55 +00:00
Aidan Timson
cccfc1716b Add scrollable fade mixin to bottom sheet, picker combo box (#28347)
* Add scrollable fade mixin to ha-bottom-sheet

* Wrap picker combobox (used inside of sheet with virtualizer)
2025-12-08 08:58:19 +02:00
dcapslock
6abf0dfcb9 Energy strategies to refresh energy collection which allows to be used in custom dashboards (#28400)
* Energy strategies to refresh energy collection which allows to be used in custom dashboards

* Update src/panels/energy/strategies/energy-overview-view-strategy.ts

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

* Only refresh if no prefs

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-08 08:17:19 +02:00
dependabot[bot]
9d2404763f Bump home-assistant/wheels from 2025.11.0 to 2025.12.0 (#28421)
Bumps [home-assistant/wheels](https://github.com/home-assistant/wheels) from 2025.11.0 to 2025.12.0.
- [Release notes](https://github.com/home-assistant/wheels/releases)
- [Commits](https://github.com/home-assistant/wheels/compare/2025.11.0...2025.12.0)

---
updated-dependencies:
- dependency-name: home-assistant/wheels
  dependency-version: 2025.12.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-12-08 06:11:02 +00:00
dependabot[bot]
6c84c9b44e Bump github/codeql-action from 4.31.5 to 4.31.7 (#28417)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.31.5 to 4.31.7.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](fdbfb4d275...cf1bb45a27)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.31.7
  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>
2025-12-08 06:10:48 +00:00
dependabot[bot]
edb2172bdd Bump actions/checkout from 6.0.0 to 6.0.1 (#28419)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.0 to 6.0.1.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](1af3b93b68...8e8c483db8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  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>
2025-12-08 06:10:44 +00:00
dependabot[bot]
067e7645ac Bump actions/stale from 10.1.0 to 10.1.1 (#28420)
Bumps [actions/stale](https://github.com/actions/stale) from 10.1.0 to 10.1.1.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](5f858e3efb...997185467f)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.1.1
  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>
2025-12-08 06:10:35 +00:00
dependabot[bot]
c3e4af3db8 Bump actions/setup-node from 6.0.0 to 6.1.0 (#28418)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.0.0 to 6.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](2028fbc5c2...395ad32622)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.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>
2025-12-08 06:10:25 +00:00
karwosts
f44be2d3b9 More unsafe description_placeholders fixes (#28416) 2025-12-08 08:05:35 +02:00
karwosts
cff7ed9b05 Fix for undefined description_placeholders (#28395)
Another fix for undefined description_placeholders
2025-12-07 09:21:08 +02:00
Norbert Rittel
c4e5f1dba6 Fix wording for use with both energy and power sensors (#28392) 2025-12-06 13:41:56 +00:00
Bram Kragten
d1011d691f Handle search params changed after first updated for dashboards and … (#28375)
handle search params changed after first updated for dashboards and integrations
2025-12-06 13:45:44 +02:00
renovate[bot]
7dda8c36bc Update dependency prettier to v3.7.4 (#28388)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-06 09:32:57 +01:00
renovate[bot]
45b2376616 Update vitest monorepo to v4.0.15 (#28379)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-05 17:51:51 +01:00
Paul Bottein
2f70a82d02 Use non-admin endpoint to subscribe to one lab feature (#28352) 2025-12-05 17:34:22 +01:00
Aidan Timson
00868b2450 Add scrollable fade to more info dialog (#28314)
* Add scrollable fade to more info dialog

* Introduce scroll threshold (default 4px, more info 24px)

* Docstrings

* Remove getter for numeric values in mixin

* Update src/dialogs/more-info/ha-more-info-dialog.ts

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>

* Fix lint

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2025-12-05 15:43:47 +01:00
Timothy
e573a726aa Add add to button in more info topbar for non admin users (#28365) 2025-12-05 15:05:50 +01:00
Petar Petrov
ef82bc2abb Fix calendar card not showing different colors for multiple calendars (#28338) 2025-12-05 13:39:51 +01:00
renovate[bot]
92e3864f63 Update dependency typescript-eslint to v8.48.1 (#28371)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-05 13:17:53 +01:00
renovate[bot]
a4af975bb3 Update dependency @rspack/core to v1.6.6 (#28370)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-05 13:17:47 +01:00
Aidan Timson
025ffd7b56 Revert "Fix underflowing text in ha-settings-row (#28339)" (#28369) 2025-12-05 11:50:37 +00:00
Jan-Philipp Benecke
3ea4a28931 Fix underflowing text in ha-settings-row (#28339) 2025-12-05 09:08:42 +00:00
Benjamin
3b092b834e Filter out hidden entities in map configuration (#28320) 2025-12-05 08:44:07 +01:00
Aidan Timson
ed8ccbe12c Add scrollable fade mixin to ha-wa-dialog (#28346) 2025-12-05 08:43:43 +01:00
karwosts
420f88f73a Fix incorrect water & gas price hints (#28357)
* Fix incorrect water price hint

* Gas
2025-12-05 08:44:23 +02:00
karwosts
086aa5fa28 Delete stop response variable on empty (#28362) 2025-12-05 08:38:39 +02:00
renovate[bot]
cca4cc512b Update dependency @rsdoctor/rspack-plugin to v1.3.12 (#28350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 14:54:27 +00:00
Petar Petrov
8eb65f327a Append current state to power-sources-graph (#28330) 2025-12-04 10:18:48 +01:00
Petar Petrov
f3495feacb Fix markdown sections and styling (#28333) 2025-12-04 10:15:11 +01:00
Petar Petrov
2161bcfa3f Fix gauge severity using entity state instead of attribute value (#28331) 2025-12-04 10:13:41 +01:00
Paul Bottein
1400398422 Move reorder areas and floors to floor overflow (#28335) 2025-12-04 10:58:27 +02:00
Preet Patel
506d466c03 Fix energy dashboard redirect for device-consumption-only configs (#28322)
When users configure energy with only device consumption (no
grid/solar/battery/gas/water sources), the dashboard would redirect
to /config/energy instead of displaying. This occurred because
_generateLovelaceConfig() returned an empty views array.

The fix adds hasDeviceConsumption check and includes ENERGY_VIEW
when device consumption is configured, since energy-view-strategy
already supports device consumption cards.
2025-12-04 06:38:43 +00:00
Bram Kragten
46735c72ed Add more info to the energy demo (#28316)
* Add more info to the energy demo

* Also add battery power
2025-12-03 20:46:59 +01:00
Copilot
c43d41053b Migrate ha-button-menu to ha-dropdown in 4 files (#28300)
Co-authored-by: wendevlin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: uptimeZERO_ <pavilionsahota@gmail.com>
2025-12-03 16:43:07 +00:00
Petar Petrov
844d53a0ba Always show energy-sources-table in overview (#28315) 2025-12-03 17:18:07 +01:00
Bram Kragten
1c8b78eae9 Bumped version to 20251203.0 2025-12-03 15:31:03 +01:00
Bram Kragten
a918e878fa Fix add matter device my link (#28313) 2025-12-03 15:30:26 +01:00
Petar Petrov
ebc354bf55 Fix label filter losing selections when searching (#28312) 2025-12-03 15:29:48 +01:00
Wendelin
98a1f5ca3a Use ha-dropdown for automations/scripts (#28293)
Co-authored-by: uptimeZERO_ <pavilionsahota@gmail.com>
2025-12-03 13:33:48 +00:00
Bram Kragten
48015ab312 Fix sticky headers in TCA dialog when target is selected (#28310) 2025-12-03 14:24:29 +01:00
Aidan Timson
09515b1937 Add subscribeLabFeature function (#28309)
* Add subscribe to lab feature function

* Add docstrings to exported functions
2025-12-03 14:16:09 +01:00
Aidan Timson
5a40627676 Add small rotation to snowflakes (#28308) 2025-12-03 14:12:08 +01:00
Wendelin
cd34447603 Hide disabled devices in automation target tree (#28307) 2025-12-03 14:02:25 +01:00
Paul Bottein
803fabbf64 Use svg for snowflakes (#28306) 2025-12-03 11:46:07 +00:00
Paul Bottein
78c4dc48d0 Rename unassigned areas to other areas (#28305) 2025-12-03 12:42:14 +01:00
Paul Bottein
147600ea43 Use core area sorting everywhere (#28304) 2025-12-03 12:28:55 +01:00
Wendelin
2f91f0dd15 Revert "Migrate updates dropdown to ha-dropdown" (#28303)
Revert "Migrate updates dropdown to ha-dropdown (#28039)"

This reverts commit ba9bab38c9.
2025-12-03 12:27:39 +01:00
ildar170975
3fa330acfb computeLovelaceEntityName(): allow "number" names to be processed (#28231)
* allow "number" names to be processed

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-12-03 10:40:29 +00:00
Paul Bottein
e0a6b671ce Always set ha-wa-dialog position to fixed (#28301) 2025-12-03 10:11:54 +00:00
Wendelin
d6edd150a8 Fix filtering of floors in getAreasAndFloorsItems function (#28302) 2025-12-03 10:45:18 +01:00
Petar Petrov
2c00889921 Add Y-axis label formatter to energy charts (#28298) 2025-12-03 10:33:00 +01:00
Petar Petrov
0447d87f18 Hide empty System message in assist debug view (#28296) 2025-12-03 10:29:07 +01:00
Petar Petrov
d7e18b0520 Fix binary sensor history timeline not rendering properly (#28297) 2025-12-03 10:18:32 +01:00
Luca Félix
e7254b1587 feat: round_temperature on weather forecast card (#28103)
* feat: round_temperature on weather forecast card

* fix: use round util function

* refactor: applied comments from review
2025-12-03 10:02:25 +02:00
renovate[bot]
8681a7d450 Update dependency prettier to v3.7.3 (#28295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 07:19:24 +01:00
139 changed files with 5151 additions and 3232 deletions

View File

@@ -21,12 +21,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: dev
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -56,12 +56,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: master
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -24,9 +24,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -58,9 +58,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -76,9 +76,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -100,9 +100,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.
@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
uses: github/codeql-action/init@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
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@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
uses: github/codeql-action/autobuild@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7
# 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@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
uses: github/codeql-action/analyze@cf1bb45a277cb3c205638b2cd5c984db1c46a412 # v4.31.7

View File

@@ -22,12 +22,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: dev
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -57,12 +57,12 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: master
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -16,10 +16,10 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -21,10 +21,10 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
@@ -28,7 +28,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
@@ -34,7 +34,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.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.11.0
uses: home-assistant/wheels@2025.12.0
with:
abi: cp313
tag: musllinux_1_2
@@ -91,9 +91,9 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -120,9 +120,9 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90

View File

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

View File

@@ -156,7 +156,9 @@ const createTestTranslation = () =>
*/
const createMasterTranslation = () =>
gulp
.src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])])
.src([EN_SRC, ...(mergeBackend ? [`${inBackendDir}/en.json`] : [])], {
allowEmpty: true,
})
.pipe(new CustomJSON(lokaliseTransform))
.pipe(new MergeJSON("en"))
.pipe(gulp.dest(workDir));

View File

@@ -44,18 +44,24 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
number_energy_price: null,
},
],
power: [
{ stat_rate: "sensor.power_grid" },
{ stat_rate: "sensor.power_grid_return" },
],
cost_adjustment_day: 0,
},
{
type: "solar",
stat_energy_from: "sensor.solar_production",
stat_rate: "sensor.power_solar",
config_entry_solar_forecast: ["solar_forecast"],
},
/* {
{
type: "battery",
stat_energy_from: "sensor.battery_output",
stat_energy_to: "sensor.battery_input",
}, */
stat_rate: "sensor.power_battery",
},
{
type: "gas",
stat_energy_from: "sensor.energy_gas",
@@ -63,28 +69,48 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
entity_energy_price: null,
number_energy_price: null,
},
{
type: "water",
stat_energy_from: "sensor.energy_water",
stat_cost: "sensor.energy_water_cost",
entity_energy_price: null,
number_energy_price: null,
},
],
device_consumption: [
{
stat_consumption: "sensor.energy_car",
stat_rate: "sensor.power_car",
},
{
stat_consumption: "sensor.energy_ac",
stat_rate: "sensor.power_ac",
},
{
stat_consumption: "sensor.energy_washing_machine",
stat_rate: "sensor.power_washing_machine",
},
{
stat_consumption: "sensor.energy_dryer",
stat_rate: "sensor.power_dryer",
},
{
stat_consumption: "sensor.energy_heat_pump",
stat_rate: "sensor.power_heat_pump",
},
{
stat_consumption: "sensor.energy_boiler",
stat_rate: "sensor.power_boiler",
},
],
device_consumption_water: [
{
stat_consumption: "sensor.water_kitchen",
},
{
stat_consumption: "sensor.water_garden",
},
],
device_consumption_water: [],
})
);
hass.mockWS(

View File

@@ -154,6 +154,38 @@ export const energyEntities = () =>
unit_of_measurement: "EUR",
},
},
"sensor.power_grid": {
entity_id: "sensor.power_grid",
state: "500",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.power_grid_return": {
entity_id: "sensor.power_grid_return",
state: "-100",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.power_solar": {
entity_id: "sensor.power_solar",
state: "200",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.power_battery": {
entity_id: "sensor.power_battery",
state: "100",
attributes: {
state_class: "measurement",
unit_of_measurement: "W",
},
},
"sensor.energy_gas_cost": {
entity_id: "sensor.energy_gas_cost",
state: "2",
@@ -171,6 +203,15 @@ export const energyEntities = () =>
unit_of_measurement: "m³",
},
},
"sensor.energy_water": {
entity_id: "sensor.energy_water",
state: "4000",
attributes: {
last_reset: "1970-01-01T00:00:00:00+00",
friendly_name: "Water",
unit_of_measurement: "L",
},
},
"sensor.energy_car": {
entity_id: "sensor.energy_car",
state: "4",
@@ -225,4 +266,58 @@ export const energyEntities = () =>
unit_of_measurement: "kWh",
},
},
"sensor.power_car": {
entity_id: "sensor.power_car",
state: "40",
attributes: {
state_class: "measurement",
friendly_name: "Electric car",
unit_of_measurement: "W",
},
},
"sensor.power_ac": {
entity_id: "sensor.power_ac",
state: "30",
attributes: {
state_class: "measurement",
friendly_name: "Air conditioning",
unit_of_measurement: "W",
},
},
"sensor.power_washing_machine": {
entity_id: "sensor.power_washing_machine",
state: "60",
attributes: {
state_class: "measurement",
friendly_name: "Washing machine",
unit_of_measurement: "W",
},
},
"sensor.power_dryer": {
entity_id: "sensor.power_dryer",
state: "55",
attributes: {
state_class: "measurement",
friendly_name: "Dryer",
unit_of_measurement: "W",
},
},
"sensor.power_heat_pump": {
entity_id: "sensor.power_heat_pump",
state: "60",
attributes: {
state_class: "measurement",
friendly_name: "Heat pump",
unit_of_measurement: "W",
},
},
"sensor.power_boiler": {
entity_id: "sensor.power_boiler",
state: "70",
attributes: {
state_class: "measurement",
friendly_name: "Boiler",
unit_of_measurement: "W",
},
},
});

View File

@@ -17,17 +17,15 @@ const generateMeanStatistics = (
end: Date,
// eslint-disable-next-line default-param-last
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number
): StatisticValue[] => {
const statistics: StatisticValue[] = [];
let currentDate = new Date(start);
currentDate.setMinutes(0, 0, 0);
let lastVal = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
const mean = lastVal + delta;
const mean = delta;
statistics.push({
start: currentDate.getTime(),
end: currentDate.getTime(),
@@ -38,7 +36,6 @@ const generateMeanStatistics = (
state: mean,
sum: null,
});
lastVal = mean;
currentDate =
period === "day"
? addDays(currentDate, 1)
@@ -336,7 +333,6 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
start,
end,
period,
state,
state * (state > 80 ? 0.05 : 0.1)
);
}

View File

@@ -0,0 +1,3 @@
---
title: Adaptive dialog (ha-adaptive-dialog)
---

View File

@@ -0,0 +1,732 @@
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mdiCog, mdiHelp } from "@mdi/js";
import "../../../../src/components/ha-button";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-dialog-footer";
import "../../../../src/components/ha-adaptive-dialog";
import "../../../../src/components/ha-form/ha-form";
import "../../../../src/components/ha-icon-button";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { HomeAssistant } from "../../../../src/types";
const SCHEMA: HaFormSchema[] = [
{ type: "string", name: "Name", default: "", autofocus: true },
{ type: "string", name: "Email", default: "" },
];
type DialogType =
| false
| "basic"
| "basic-subtitle-below"
| "basic-subtitle-above"
| "form"
| "form-block-mode"
| "actions"
| "large"
| "small";
@customElement("demo-components-ha-adaptive-dialog")
export class DemoHaAdaptiveDialog extends LitElement {
@state() private _openDialog: DialogType = false;
@state() private _hass?: HomeAssistant;
protected firstUpdated() {
const hass = provideHass(this);
this._hass = hass;
}
protected render() {
return html`
<div class="content">
<h1>Adaptive dialog <code>&lt;ha-adaptive-dialog&gt;</code></h1>
<p class="subtitle">
Responsive dialog component that automatically switches between a full
dialog and bottom sheet based on screen size.
</p>
<h2>Demos</h2>
<div class="buttons">
<ha-button @click=${this._handleOpenDialog("basic")}
>Basic adaptive dialog</ha-button
>
<ha-button @click=${this._handleOpenDialog("basic-subtitle-below")}
>Adaptive dialog with subtitle below</ha-button
>
<ha-button @click=${this._handleOpenDialog("basic-subtitle-above")}
>Adaptive dialog with subtitle above</ha-button
>
<ha-button @click=${this._handleOpenDialog("small")}
>Small width adaptive dialog</ha-button
>
<ha-button @click=${this._handleOpenDialog("large")}
>Large width adaptive dialog</ha-button
>
<ha-button @click=${this._handleOpenDialog("form")}
>Adaptive dialog with form</ha-button
>
<ha-button @click=${this._handleOpenDialog("form-block-mode")}
>Adaptive dialog with form (block mode change)</ha-button
>
<ha-button @click=${this._handleOpenDialog("actions")}
>Adaptive dialog with actions</ha-button
>
</div>
<ha-card>
<div class="card-content">
<p>
<strong>Tip:</strong> Resize your browser window to see the
responsive behavior. The dialog automatically switches to a bottom
sheet on narrow screens (&lt;870px width) or short screens
(&lt;500px height).
</p>
</div>
</ha-card>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic"}
header-title="Basic adaptive dialog"
@closed=${this._handleClosed}
>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-below"}
header-title="Adaptive dialog with subtitle"
header-subtitle="This is an adaptive dialog with a subtitle below"
@closed=${this._handleClosed}
>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "basic-subtitle-above"}
header-title="Adaptive dialog with subtitle above"
header-subtitle="This is an adaptive dialog with a subtitle above"
header-subtitle-position="above"
@closed=${this._handleClosed}
>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "small"}
width="small"
header-title="Small adaptive dialog"
@closed=${this._handleClosed}
>
<div>This dialog uses the small width preset (320px).</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "large"}
width="large"
header-title="Large adaptive dialog"
@closed=${this._handleClosed}
>
<div>This dialog uses the large width preset (1024px).</div>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form"}
header-title="Adaptive dialog with form"
header-subtitle="This is an adaptive dialog with a form"
@closed=${this._handleClosed}
>
<ha-form autofocus .schema=${SCHEMA}></ha-form>
<ha-dialog-footer slot="footer">
<ha-button
@click=${this._handleClosed}
slot="secondaryAction"
variant="plain"
>Cancel</ha-button
>
<ha-button
@click=${this._handleClosed}
slot="primaryAction"
variant="accent"
>Submit</ha-button
>
</ha-dialog-footer>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "form-block-mode"}
header-title="Adaptive dialog with form (block mode change)"
header-subtitle="This form will not reset when the viewport size changes"
block-mode-change
@closed=${this._handleClosed}
>
<ha-form autofocus .schema=${SCHEMA}></ha-form>
<ha-dialog-footer slot="footer">
<ha-button
@click=${this._handleClosed}
slot="secondaryAction"
variant="plain"
>Cancel</ha-button
>
<ha-button
@click=${this._handleClosed}
slot="primaryAction"
variant="accent"
>Submit</ha-button
>
</ha-dialog-footer>
</ha-adaptive-dialog>
<ha-adaptive-dialog
.hass=${this._hass}
.open=${this._openDialog === "actions"}
header-title="Adaptive dialog with actions"
header-subtitle="This is an adaptive dialog with header actions"
@closed=${this._handleClosed}
>
<div slot="headerActionItems">
<ha-icon-button label="Settings" path=${mdiCog}></ha-icon-button>
<ha-icon-button label="Help" path=${mdiHelp}></ha-icon-button>
</div>
<div>Adaptive dialog content</div>
</ha-adaptive-dialog>
<h2>Design</h2>
<h3>Responsive behavior</h3>
<p>
The <code>ha-adaptive-dialog</code> component automatically switches
between two modes based on screen size:
</p>
<ul>
<li>
<strong>Dialog mode:</strong> Used on larger screens (width &gt;
870px and height &gt; 500px). Renders as a centered dialog using
<code>ha-wa-dialog</code>.
</li>
<li>
<strong>Bottom sheet mode:</strong> Used on mobile devices and
smaller screens (width ≤ 870px or height ≤ 500px). Renders as a
drawer from the bottom using <code>ha-bottom-sheet</code>.
</li>
</ul>
<p>
The mode is determined automatically and updates when the window is
resized. To prevent mode changes after the initial mount (useful for
preventing form resets), use the <code>block-mode-change</code>
attribute.
</p>
<h3>Width</h3>
<p>
In dialog mode, there are multiple width presets available. These are
ignored in bottom sheet mode.
</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>small</code></td>
<td><code>min(320px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>medium</code></td>
<td><code>min(580px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>large</code></td>
<td><code>min(1024px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>full</code></td>
<td><code>var(--full-width)</code></td>
</tr>
</tbody>
</table>
<p>Adaptive dialogs have a default width of <code>medium</code>.</p>
<h3>Header</h3>
<p>
The header contains a navigation icon, title, subtitle, and action
items.
</p>
<table>
<thead>
<tr>
<th>Slot</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>headerNavigationIcon</code></td>
<td>
Leading header action (e.g., close/back button). In bottom sheet
mode, defaults to a close button if not provided.
</td>
</tr>
<tr>
<td><code>headerTitle</code></td>
<td>The header title content.</td>
</tr>
<tr>
<td><code>headerSubtitle</code></td>
<td>The header subtitle content.</td>
</tr>
<tr>
<td><code>headerActionItems</code></td>
<td>Trailing header actions (e.g., icon buttons, menus).</td>
</tr>
</tbody>
</table>
<h4>Header title</h4>
<p>
The header title can be set using the <code>header-title</code>
attribute or by providing custom content in the
<code>headerTitle</code> slot.
</p>
<h4>Header subtitle</h4>
<p>
The header subtitle can be set using the
<code>header-subtitle</code> attribute or by providing custom content
in the <code>headerSubtitle</code> slot. The subtitle position
relative to the title can be controlled with the
<code>header-subtitle-position</code> attribute.
</p>
<h4>Header navigation icon</h4>
<p>
In bottom sheet mode, a close button is automatically provided if no
custom navigation icon is specified. In dialog mode, the dialog can be
closed via the standard dialog close button.
</p>
<h4>Header action items</h4>
<p>
The header action items usually contain icon buttons and/or menu
buttons.
</p>
<h3>Body</h3>
<p>The body is the content of the adaptive dialog.</p>
<h3>Footer</h3>
<p>The footer is the footer of the adaptive dialog.</p>
<p>
It is recommended to use the <code>ha-dialog-footer</code> component
for the footer and to style the buttons inside the footer as follows:
</p>
<table>
<thead>
<tr>
<th>Slot</th>
<th>Description</th>
<th>Variant to use</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>secondaryAction</code></td>
<td>The secondary action button(s).</td>
<td><code>plain</code></td>
</tr>
<tr>
<td><code>primaryAction</code></td>
<td>The primary action button(s).</td>
<td><code>accent</code></td>
</tr>
</tbody>
</table>
<h2>Implementation</h2>
<h3>When to use</h3>
<p>
Use <code>ha-adaptive-dialog</code> when you need a dialog that should
adapt to different screen sizes automatically. This is particularly
useful for:
</p>
<ul>
<li>Forms and data entry that need to work well on mobile devices</li>
<li>
Content that benefits from full-screen presentation on small devices
</li>
<li>
Interfaces that need consistent behavior across desktop and mobile
</li>
</ul>
<p>
If you don't need responsive behavior, use
<code>ha-wa-dialog</code> directly for desktop-only dialogs or
<code>ha-bottom-sheet</code> for mobile-only sheets.
</p>
<p>
Use the <code>block-mode-change</code> attribute when you want to
prevent the dialog from switching modes after it's opened. This is
especially useful for forms, as it prevents form data from being lost
when users resize their browser window.
</p>
<h3>Example usage</h3>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
width="medium"
header-title="Dialog title"
header-subtitle="Dialog subtitle"
&gt;
&lt;div slot="headerActionItems"&gt;
&lt;ha-icon-button label="Settings" path="mdiCog"&gt;&lt;/ha-icon-button&gt;
&lt;ha-icon-button label="Help" path="mdiHelp"&gt;&lt;/ha-icon-button&gt;
&lt;/div&gt;
&lt;div&gt;Dialog content&lt;/div&gt;
&lt;ha-dialog-footer slot="footer"&gt;
&lt;ha-button slot="secondaryAction" variant="plain"
&gt;Cancel&lt;/ha-button
&gt;
&lt;ha-button slot="primaryAction" variant="accent"&gt;Submit&lt;/ha-button&gt;
&lt;/ha-dialog-footer&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
<p>Example with <code>block-mode-change</code> for forms:</p>
<pre><code>&lt;ha-adaptive-dialog
.hass=\${this.hass}
open
header-title="Edit configuration"
block-mode-change
&gt;
&lt;ha-form .schema=\${schema} .data=\${data}&gt;&lt;/ha-form&gt;
&lt;ha-dialog-footer slot="footer"&gt;
&lt;ha-button slot="secondaryAction" variant="plain"
&gt;Cancel&lt;/ha-button
&gt;
&lt;ha-button slot="primaryAction" variant="accent"&gt;Save&lt;/ha-button&gt;
&lt;/ha-dialog-footer&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
<h3>API</h3>
<p>
This component combines <code>ha-wa-dialog</code> and
<code>ha-bottom-sheet</code> with automatic mode switching based on
screen size.
</p>
<h4>Attributes</h4>
<table>
<thead>
<tr>
<th>Attribute</th>
<th>Description</th>
<th>Default</th>
<th>Options</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>open</code></td>
<td>Controls the adaptive dialog open state.</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>
</tr>
<tr>
<td><code>width</code></td>
<td>
Preferred dialog width preset (dialog mode only, ignored in
bottom sheet mode).
</td>
<td><code>medium</code></td>
<td>
<code>small</code>, <code>medium</code>, <code>large</code>,
<code>full</code>
</td>
</tr>
<tr>
<td><code>header-title</code></td>
<td>Header title text when no custom title slot is provided.</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>header-subtitle</code></td>
<td>
Header subtitle text when no custom subtitle slot is provided.
</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>header-subtitle-position</code></td>
<td>Position of the subtitle relative to the title.</td>
<td><code>below</code></td>
<td><code>above</code>, <code>below</code></td>
</tr>
<tr>
<td><code>aria-labelledby</code></td>
<td>
The ID of the element that labels the dialog (for
accessibility).
</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>aria-describedby</code></td>
<td>
The ID of the element that describes the dialog (for
accessibility).
</td>
<td></td>
<td></td>
</tr>
<tr>
<td><code>block-mode-change</code></td>
<td>
When set, the mode is determined at mount time based on the
current screen size, but subsequent mode changes are blocked.
Useful for preventing forms from resetting when the viewport
size changes.
</td>
<td><code>false</code></td>
<td><code>false</code>, <code>true</code></td>
</tr>
</tbody>
</table>
<h4>CSS custom properties</h4>
<table>
<thead>
<tr>
<th>CSS Property</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>--ha-dialog-surface-background</code></td>
<td>Dialog/sheet background color.</td>
</tr>
<tr>
<td><code>--ha-dialog-border-radius</code></td>
<td>Border radius of the dialog surface (dialog mode only).</td>
</tr>
<tr>
<td><code>--ha-dialog-show-duration</code></td>
<td>Show animation duration (dialog mode only).</td>
</tr>
<tr>
<td><code>--ha-dialog-hide-duration</code></td>
<td>Hide animation duration (dialog mode only).</td>
</tr>
</tbody>
</table>
<h4>Events</h4>
<table>
<thead>
<tr>
<th>Event</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>opened</code></td>
<td>
Fired when the adaptive dialog is shown (dialog mode only).
</td>
</tr>
<tr>
<td><code>closed</code></td>
<td>
Fired after the adaptive dialog is hidden (dialog mode only).
</td>
</tr>
<tr>
<td><code>after-show</code></td>
<td>Fired after show animation completes (dialog mode only).</td>
</tr>
</tbody>
</table>
<h3>Focus management</h3>
<p>
To automatically focus an element when the adaptive dialog opens, add
the
<code>autofocus</code> attribute to it. Components with
<code>delegatesFocus: true</code> (like <code>ha-form</code>) will
forward focus to their first focusable child.
</p>
<p>Example:</p>
<pre><code>&lt;ha-adaptive-dialog .hass=\${this.hass} open&gt;
&lt;ha-form autofocus .schema=\${schema}&gt;&lt;/ha-form&gt;
&lt;/ha-adaptive-dialog&gt;</code></pre>
</div>
`;
}
private _handleOpenDialog = (dialog: DialogType) => () => {
this._openDialog = dialog;
};
private _handleClosed = () => {
this._openDialog = false;
};
static styles = [
css`
:host {
display: block;
padding: var(--ha-space-4);
}
.content {
max-width: 1000px;
margin: 0 auto;
}
h1 {
margin-top: 0;
margin-bottom: var(--ha-space-2);
}
h2 {
margin-top: var(--ha-space-6);
margin-bottom: var(--ha-space-3);
}
h3,
h4 {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-2);
}
p {
margin: var(--ha-space-2) 0;
line-height: 1.6;
}
ul {
margin: var(--ha-space-2) 0;
padding-left: var(--ha-space-5);
}
li {
margin: var(--ha-space-1) 0;
line-height: 1.6;
}
.subtitle {
color: var(--secondary-text-color);
font-size: 1.1em;
margin-bottom: var(--ha-space-4);
}
table {
width: 100%;
border-collapse: collapse;
margin: var(--ha-space-3) 0;
}
th,
td {
text-align: left;
padding: var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
th {
font-weight: 500;
}
code {
background-color: var(--secondary-background-color);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 0.9em;
}
pre {
background-color: var(--secondary-background-color);
padding: var(--ha-space-3);
border-radius: 8px;
overflow-x: auto;
margin: var(--ha-space-3) 0;
}
pre code {
background-color: transparent;
padding: 0;
}
.buttons {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: var(--ha-space-2);
margin: var(--ha-space-4) 0;
}
.card-content {
padding: var(--ha-space-3);
}
a {
color: var(--primary-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-adaptive-dialog": DemoHaAdaptiveDialog;
}
}

View File

@@ -139,7 +139,7 @@ export class DemoHaWaDialog extends LitElement {
</tr>
<tr>
<td><code>large</code></td>
<td><code>min(720px, var(--full-width))</code></td>
<td><code>min(1024px, var(--full-width))</code></td>
</tr>
<tr>
<td><code>full</code></td>
@@ -381,10 +381,6 @@ export class DemoHaWaDialog extends LitElement {
<td><code>--dialog-z-index</code></td>
<td>Z-index for the dialog.</td>
</tr>
<tr>
<td><code>--dialog-surface-position</code></td>
<td>CSS position of the dialog surface.</td>
</tr>
<tr>
<td><code>--dialog-surface-margin-top</code></td>
<td>Top margin for the dialog surface.</td>

View File

@@ -52,7 +52,7 @@
"@fullcalendar/list": "6.1.19",
"@fullcalendar/luxon3": "6.1.19",
"@fullcalendar/timegrid": "6.1.19",
"@home-assistant/webawesome": "3.0.0-ha.0",
"@home-assistant/webawesome": "3.0.0-ha.1",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.0.9",
"@lit-labs/observers": "2.0.6",
@@ -157,8 +157,8 @@
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.3.11",
"@rspack/core": "1.6.5",
"@rsdoctor/rspack-plugin": "1.3.12",
"@rspack/core": "1.6.6",
"@rspack/dev-server": "1.1.4",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
@@ -178,7 +178,7 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.14",
"@vitest/coverage-v8": "4.0.15",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -209,17 +209,17 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.7.2",
"prettier": "3.7.4",
"rspack-manifest-plugin": "5.2.0",
"serve": "14.2.5",
"sinon": "21.0.0",
"tar": "7.5.2",
"terser-webpack-plugin": "5.3.14",
"terser-webpack-plugin": "5.3.15",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.48.0",
"typescript-eslint": "8.48.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "4.0.14",
"vitest": "4.0.15",
"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

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

View File

@@ -0,0 +1,29 @@
export interface ProcessedConfigurationUrl {
url: string;
isLocal: boolean;
}
/**
* Get a processed configuration URL, converting homeassistant:// URLs to local paths
* and determining if it should be opened locally or in a new tab.
*
* @param configurationUrl - The configuration URL to process
* @returns Processed URL and whether it's a local link, or null if URL is empty
*/
export const getConfigurationUrl = (
configurationUrl: string | null | undefined
): ProcessedConfigurationUrl | null => {
if (!configurationUrl) {
return null;
}
const isHomeAssistant = configurationUrl.startsWith("homeassistant://");
const url = isHomeAssistant
? configurationUrl.replace("homeassistant://", "/")
: configurationUrl;
return {
url,
isLocal: isHomeAssistant,
};
};

View File

@@ -373,6 +373,7 @@ export class StateHistoryChartTimeline extends LitElement {
itemName: 3,
},
renderItem: this._renderItem,
progressive: 0,
});
});

View File

@@ -2,15 +2,17 @@ import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import type { LabelRegistryEntry } from "../../data/label_registry";
import { computeCssColor } from "../../common/color/compute-color";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-label";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
import type { LabelRegistryEntry } from "../../data/label_registry";
import "../chips/ha-chip-set";
import "../ha-button-menu";
import "../ha-dropdown";
import "../ha-dropdown-item";
import type { HaDropdownItem } from "../ha-dropdown-item";
import "../ha-icon";
import "../ha-list-item";
import "../ha-label";
@customElement("ha-data-table-labels")
class HaDataTableLabels extends LitElement {
@@ -26,12 +28,11 @@ class HaDataTableLabels extends LitElement {
(label) => this._renderLabel(label, true)
)}
${labels.length > 2
? html`<ha-button-menu
absolute
? html`<ha-dropdown
role="button"
tabindex="0"
@click=${this._handleIconOverflowMenuOpened}
@closed=${this._handleIconOverflowMenuClosed}
@click=${stopPropagation}
@wa-select=${this._handleDropdownSelect}
>
<ha-label slot="trigger" class="plus" dense>
+${labels.length - 2}
@@ -40,12 +41,12 @@ class HaDataTableLabels extends LitElement {
labels.slice(2),
(label) => label.label_id,
(label) => html`
<ha-list-item @click=${this._labelClicked} .item=${label}>
<ha-dropdown-item .value=${label.label_id} .item=${label}>
${this._renderLabel(label, false)}
</ha-list-item>
</ha-dropdown-item>
`
)}
</ha-button-menu>`
</ha-dropdown>`
: nothing}
</ha-chip-set>
`;
@@ -81,21 +82,12 @@ class HaDataTableLabels extends LitElement {
fireEvent(this, "label-clicked", { label });
}
protected _handleIconOverflowMenuOpened(e) {
e.stopPropagation();
// If this component is used inside a data table, the z-index of the row
// needs to be increased. Otherwise the ha-button-menu would be displayed
// underneath the next row in the table.
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "1";
}
}
protected _handleIconOverflowMenuClosed() {
const row = this.closest(".mdc-data-table__row") as HTMLDivElement | null;
if (row) {
row.style.zIndex = "";
private _handleDropdownSelect(
ev: CustomEvent<{ item: HaDropdownItem & { item?: LabelRegistryEntry } }>
) {
const label = ev.detail?.item?.item;
if (label) {
fireEvent(this, "label-clicked", { label });
}
}
@@ -114,9 +106,6 @@ class HaDataTableLabels extends LitElement {
--ha-label-background-color: var(--color, var(--grey-color));
--ha-label-background-opacity: 0.5;
}
ha-button-menu {
border-radius: 10px;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);

View File

@@ -16,8 +16,10 @@ import memoizeOne from "memoize-one";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { LocalizeFunc } from "../../common/translations/localize";
import { debounce } from "../../common/util/debounce";
import { groupBy } from "../../common/util/group-by";
import { nextRender } from "../../common/util/render-status";
import { haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
@@ -26,8 +28,6 @@ import type { HaCheckbox } from "../ha-checkbox";
import "../ha-svg-icon";
import "../search-input";
import { filterData, sortData } from "./sort-filter";
import type { LocalizeFunc } from "../../common/translations/localize";
import { nextRender } from "../../common/util/render-status";
export interface RowClickedEvent {
id: string;

View File

@@ -1,9 +1,9 @@
import { expose } from "comlink";
import Fuse from "fuse.js";
import Fuse, { type FuseOptionKey } from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { HaFuse } from "../../resources/fuse";
import { multiTermSearch } from "../../resources/fuseMultiTerm";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -11,9 +11,10 @@ import type {
SortingDirection,
} from "./ha-data-table";
const fuseIndex = memoizeOne(
(data: DataTableRowData[], columns: SortableColumnContainer) => {
const getSearchKeys = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => {
const searchKeys = new Set<string>();
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
@@ -23,10 +24,15 @@ const fuseIndex = memoizeOne(
);
}
});
return Fuse.createIndex([...searchKeys], data);
return Array.from(searchKeys);
}
);
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
@@ -38,21 +44,13 @@ const filterData = (
return data;
}
const index = fuseIndex(data, columns);
const keys = getSearchKeys(columns);
const fuse = new HaFuse(
data,
{ shouldSort: false, minMatchCharLength: 1 },
index
);
const index = fuseIndex(data, keys);
const searchResults = fuse.multiTermsSearch(filter);
if (searchResults) {
return searchResults.map((result) => result.item);
}
return data;
return multiTermSearch<DataTableRowData>(data, filter, keys, index, {
threshold: 0.2, // reduce fuzzy matches in data tables
});
};
const sortData = (

View File

@@ -9,6 +9,7 @@ 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 {
deviceComboBoxKeys,
getDevices,
type DevicePickerItem,
type DeviceRegistryEntry,
@@ -216,6 +217,10 @@ export class HaDevicePicker extends LitElement {
.getItems=${this._getItems}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.device-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -9,6 +9,7 @@ import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
import {
entityComboBoxKeys,
getEntities,
type EntityComboBoxItem,
} from "../../data/entity_registry";
@@ -288,10 +289,14 @@ export class HaEntityPicker extends LitElement {
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
.searchKeys=${entityComboBoxKeys}
.addButtonLabel=${this.addButton
? this.hass.localize("ui.components.entity.entity-picker.add")
: undefined}
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;

View File

@@ -38,9 +38,21 @@ type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string;
stateObj?: HassEntity;
domainName?: string;
type?: StatisticItemType;
}
const SEARCH_KEYS = [
{ name: "label", weight: 10 },
{ name: "search_labels.entityName", weight: 10 },
{ name: "search_labels.friendlyName", weight: 9 },
{ name: "search_labels.deviceName", weight: 8 },
{ name: "search_labels.areaName", weight: 6 },
{ name: "search_labels.domainName", weight: 4 },
{ name: "statisticId", weight: 3 },
{ name: "id", weight: 2 },
];
@customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -233,7 +245,6 @@ export class HaStatisticPicker extends LitElement {
),
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
@@ -246,7 +257,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName,
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, id],
search_labels: { label, domainName },
icon_path: mdiChartLine,
});
}
@@ -280,13 +291,12 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
friendlyName,
id,
].filter(Boolean) as string[],
},
});
});
@@ -361,13 +371,13 @@ export class HaStatisticPicker extends LitElement {
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
].filter(Boolean) as string[],
},
};
}
@@ -394,7 +404,7 @@ export class HaStatisticPicker extends LitElement {
secondary: domainName,
type: "external",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, statisticId],
search_labels: { label, domainName, statisticId },
icon_path: mdiChartLine,
};
}
@@ -409,7 +419,7 @@ export class HaStatisticPicker extends LitElement {
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, statisticId],
search_labels: { label, statisticId },
icon_path: mdiShape,
};
}
@@ -475,6 +485,10 @@ export class HaStatisticPicker extends LitElement {
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.helper=${this.helper}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.statistic-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -0,0 +1,188 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { listenMediaQuery } from "../common/dom/media_query";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
import "./ha-wa-dialog";
import type { DialogWidth } from "./ha-wa-dialog";
type DialogSheetMode = "dialog" | "bottom-sheet";
/**
* Home Assistant adaptive dialog component
*
* @element ha-adaptive-dialog
* @extends {LitElement}
*
* @summary
* A responsive dialog component that automatically switches between a full dialog (ha-wa-dialog)
* and a bottom sheet (ha-bottom-sheet) based on screen size. Uses dialog mode on larger screens
* (>870px width and >500px height) and bottom sheet mode on smaller screens or mobile devices.
*
* @slot headerNavigationIcon - Leading header action (e.g. close/back button).
* @slot headerTitle - Custom title content (used when header-title is not set).
* @slot headerSubtitle - Custom subtitle content (used when header-subtitle is not set).
* @slot headerActionItems - Trailing header actions (e.g. buttons, menus).
* @slot - Dialog/sheet content body.
* @slot footer - Dialog/sheet footer content.
*
* @cssprop --ha-dialog-surface-background - Dialog/sheet background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface (dialog mode only).
* @cssprop --ha-dialog-show-duration - Show animation duration (dialog mode only).
* @cssprop --ha-dialog-hide-duration - Hide animation duration (dialog mode only).
*
* @attr {boolean} open - Controls the dialog/sheet open state.
* @attr {("small"|"medium"|"large"|"full")} width - Preferred dialog width preset (dialog mode only). Defaults to "medium".
* @attr {string} header-title - Header title text. If not set, the headerTitle slot is used.
* @attr {string} header-subtitle - Header subtitle text. If not set, the headerSubtitle slot is used.
* @attr {("above"|"below")} header-subtitle-position - Position of the subtitle relative to the title. Defaults to "below".
* @attr {boolean} block-mode-change - When set, the mode is determined at mount time based on the current screen size, but subsequent mode changes are blocked. Useful for preventing forms from resetting when the viewport size changes.
*
* @event opened - Fired when the dialog/sheet is shown (dialog mode only).
* @event closed - Fired after the dialog/sheet is hidden.
* @event after-show - Fired after show animation completes (dialog mode only).
*
* @remarks
* **Responsive Behavior:**
* The component automatically switches between dialog and bottom sheet modes based on viewport size.
* Dialog mode is used for screens wider than 870px and taller than 500px.
* Bottom sheet mode is used for mobile devices and smaller screens.
*
* When `block-mode-change` is set, the mode is determined once at mount time based on the initial
* screen size. Subsequent viewport size changes will not trigger mode switches, which is useful
* for preventing form resets or other state loss when users resize their browser window.
*
* **Focus Management:**
* To automatically focus an element when opened, add the `autofocus` attribute to it.
* Components with `delegatesFocus: true` (like `ha-form`) will forward focus to their first focusable child.
* Example: `<ha-form autofocus .schema=${schema}></ha-form>`
*/
@customElement("ha-adaptive-dialog")
export class HaAdaptiveDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@property({ attribute: "aria-describedby" })
public ariaDescribedBy?: string;
@property({ type: Boolean, reflect: true })
public open = false;
@property({ type: String, reflect: true, attribute: "width" })
public width: DialogWidth = "medium";
@property({ attribute: "header-title" })
public headerTitle?: string;
@property({ attribute: "header-subtitle" })
public headerSubtitle?: string;
@property({ type: String, attribute: "header-subtitle-position" })
public headerSubtitlePosition: "above" | "below" = "below";
@property({ type: Boolean, attribute: "block-mode-change" })
public blockModeChange = false;
@state() private _mode: DialogSheetMode = "dialog";
private _unsubMediaQuery?: () => void;
private _modeSet = false;
connectedCallback() {
super.connectedCallback();
this._unsubMediaQuery = listenMediaQuery(
"(max-width: 870px), (max-height: 500px)",
(matches) => {
if (!this._modeSet || !this.blockModeChange) {
this._mode = matches ? "bottom-sheet" : "dialog";
this._modeSet = true;
}
}
);
}
disconnectedCallback() {
super.disconnectedCallback();
this._unsubMediaQuery?.();
this._unsubMediaQuery = undefined;
this._modeSet = false;
}
render() {
if (this._mode === "bottom-sheet") {
return html`
<ha-bottom-sheet .open=${this.open} flexcontent>
<ha-dialog-header
slot="header"
.subtitlePosition=${this.headerSubtitlePosition}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-drawer="close"
.label=${this.hass?.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
></ha-icon-button>
</slot>
${this.headerTitle !== undefined
? html`<span slot="title" class="title" id="ha-wa-dialog-title">
${this.headerTitle}
</span>`
: html`<slot name="headerTitle" slot="title"></slot>`}
${this.headerSubtitle !== undefined
? html`<span slot="subtitle">${this.headerSubtitle}</span>`
: html`<slot name="headerSubtitle" slot="subtitle"></slot>`}
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-bottom-sheet>
`;
}
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this.open}
.width=${this.width}
.ariaLabelledBy=${this.ariaLabelledBy}
.ariaDescribedBy=${this.ariaDescribedBy}
.headerTitle=${this.headerTitle}
.headerSubtitle=${this.headerSubtitle}
.headerSubtitlePosition=${this.headerSubtitlePosition}
flexcontent
>
<slot name="headerNavigationIcon" slot="headerNavigationIcon"></slot>
<slot name="headerTitle" slot="headerTitle"></slot>
<slot name="headerSubtitle" slot="headerSubtitle"></slot>
<slot name="headerActionItems" slot="headerActionItems"></slot>
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-wa-dialog>
`;
}
static get styles() {
return [
css`
ha-bottom-sheet {
--ha-bottom-sheet-surface-background: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-adaptive-dialog": HaAdaptiveDialog;
}
}

View File

@@ -1,270 +0,0 @@
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { computeRTL } from "../common/util/compute_rtl";
import {
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";
import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
import "./ha-tree-indicator";
const SEPARATOR = "________";
@customElement("ha-area-floor-picker")
export class HaAreaFloorPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: AreaFloorValue;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
/**
* Show only areas with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no areas with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only areas with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
/**
* List of floors to be excluded.
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floors" })
public excludeFloors?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
await this.updateComplete;
await this._picker?.open();
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const item = this._parseValue(value);
const area = item.type === "area" && this.hass.areas[value];
if (area) {
const areaName = computeAreaName(area);
return html`
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<slot name="headline">${areaName}</slot>
`;
}
const floor = item.type === "floor" && this.hass.floors[value];
if (floor) {
const floorName = computeFloorName(floor);
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
};
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item,
{ index },
combobox
) => {
const nextItem = combobox.filteredItems?.[index + 1];
const isLastArea =
!nextItem ||
nextItem.type === "floor" ||
(nextItem.type === "area" && !nextItem.area?.floor_id);
const rtl = computeRTL(this.hass);
const hasFloor = item.type === "area" && item.area?.floor_id;
return html`
<ha-combo-box-item
type="button"
style=${item.type === "area" && hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${isLastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor" && item.floor
? html`<ha-floor-icon
slot="start"
.floor=${item.floor}
></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`}
${item.primary}
</ha-combo-box-item>
`;
};
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
private _getItems = () =>
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,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
private _formatValue = memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
);
private _parseValue = memoizeOne((value: string): AreaFloorValue => {
const [type, id] = value.split(SEPARATOR);
return { id, type: type as "floor" | "area" };
});
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const value = this.value ? this._formatValue(this.value) : undefined;
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.placeholder=${placeholder}
.value=${value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
return;
}
const selected = this._parseValue(value);
this._setValue(selected);
}
private _setValue(value?: AreaFloorValue) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-floor-picker": HaAreaFloorPicker;
}
}

View File

@@ -30,6 +30,12 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "areaName", weight: 10 },
{ name: "aliases", weight: 8 },
{ name: "floorName", weight: 6 },
{ name: "id", weight: 3 },
];
@customElement("ha-area-picker")
export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -291,12 +297,12 @@ export class HaAreaPicker extends LitElement {
icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox,
sorting_label: areaName,
search_labels: [
areaName,
floorName,
area.area_id,
...area.aliases,
].filter((v): v is string => Boolean(v)),
search_labels: {
areaName: areaName || null,
floorName: floorName || null,
id: area.area_id,
aliases: area.aliases.join(" "),
},
};
});
@@ -379,6 +385,10 @@ export class HaAreaPicker extends LitElement {
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.area-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -4,7 +4,6 @@ import { LitElement, html } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { areaCompare } from "../data/area_registry";
import type { HomeAssistant } from "../types";
import "./ha-expansion-panel";
import "./ha-items-display-editor";
@@ -37,11 +36,7 @@ export class HaAreasDisplayEditor extends LitElement {
public showNavigationButton = false;
protected render(): TemplateResult {
const compare = areaCompare(this.hass.areas);
const areas = Object.values(this.hass.areas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const areas = Object.values(this.hass.areas);
const items: DisplayItem[] = areas.map((area) => {
const { floor } = getAreaContext(area, this.hass.floors);

View File

@@ -7,7 +7,6 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { areaCompare } from "../data/area_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloors } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
import type { HomeAssistant } from "../types";
@@ -131,11 +130,8 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
// update items if floors change
_hassFloors: HomeAssistant["floors"]
): Record<string, DisplayItem[]> => {
const compare = areaCompare(hassAreas);
const areas = Object.values(hassAreas);
const areas = Object.values(hassAreas).sort((areaA, areaB) =>
compare(areaA.area_id, areaB.area_id)
);
const groupedItems: Record<string, DisplayItem[]> = areas.reduce(
(acc, area) => {
const { floor } = getAreaContext(area, this.hass.floors);

View File

@@ -134,6 +134,7 @@ export class HaAssistChat extends LitElement {
})}"
breaks
cache
assist
.content=${message.text}
>
</ha-markdown>

View File

@@ -2,12 +2,13 @@ import "@home-assistant/webawesome/dist/components/drawer/drawer";
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 { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@customElement("ha-bottom-sheet")
export class HaBottomSheet extends LitElement {
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean }) public open = false;
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
@@ -17,6 +18,12 @@ export class HaBottomSheet extends LitElement {
@query("#drawer") private _drawer!: HTMLElement;
@query("#body") private _bodyElement!: HTMLDivElement;
protected get scrollableElement(): HTMLElement | null {
return this._bodyElement;
}
private _gestureRecognizer = new SwipeGestureRecognizer();
private _isDragging = false;
@@ -49,9 +56,13 @@ export class HaBottomSheet extends LitElement {
@touchstart=${this._handleTouchStart}
>
<slot name="header"></slot>
<div id="body" class="body ha-scrollbar">
<slot></slot>
<div class="content-wrapper">
<div id="body" class="body ha-scrollbar">
<slot></slot>
</div>
${this.renderScrollableFades()}
</div>
<slot name="footer"></slot>
</wa-drawer>
`;
}
@@ -167,60 +178,87 @@ export class HaBottomSheet extends LitElement {
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)
);
}
`,
];
static get styles() {
return [
...super.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;
}
.content-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
: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)
);
}
slot[name="footer"] {
display: block;
padding: var(--ha-space-0);
}
::slotted([slot="footer"]) {
display: flex;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
gap: var(--ha-space-3);
justify-content: flex-end;
align-items: center;
width: 100%;
box-sizing: border-box;
}
:host([flexcontent]) slot[name="footer"] {
flex-shrink: 0;
}
`,
];
}
}
declare global {

View File

@@ -167,30 +167,33 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
}
private async _labelSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
if (!ev.detail.index.size) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const value: string[] = [];
const filteredLabels = this._filteredLabels(
this._labels,
this._filter,
this.value
);
const filteredLabelIds = new Set(filteredLabels.map((l) => l.label_id));
// Keep previously selected labels that are not in the current filtered view
const preservedLabels = (this.value || []).filter(
(id) => !filteredLabelIds.has(id)
);
// Build the new selection from the filtered labels based on selected indices
const newlySelectedLabels: string[] = [];
for (const index of ev.detail.index) {
const labelId = filteredLabels[index].label_id;
value.push(labelId);
const labelId = filteredLabels[index]?.label_id;
if (labelId) {
newlySelectedLabels.push(labelId);
}
}
this.value = value;
const value = [...preservedLabels, ...newlySelectedLabels];
this.value = value.length ? value : [];
fireEvent(this, "data-table-filter-changed", {
value,
value: value.length ? value : undefined,
items: undefined,
});
}

View File

@@ -35,6 +35,12 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "floorName", weight: 10 },
{ name: "aliases", weight: 8 },
{ name: "floor_id", weight: 3 },
];
interface FloorComboBoxItem extends PickerComboBoxItem {
floor?: FloorRegistryEntry;
}
@@ -286,9 +292,11 @@ export class HaFloorPicker extends LitElement {
primary: floorName,
floor: floor,
sorting_label: floor.level?.toString() || "zzzzz",
search_labels: [floorName, floor.floor_id, ...floor.aliases].filter(
(v): v is string => Boolean(v)
),
search_labels: {
floorName,
floor_id: floor.floor_id,
aliases: floor.aliases.join(" "),
},
};
});
@@ -393,6 +401,10 @@ export class HaFloorPicker extends LitElement {
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.rowRenderer=${this._rowRenderer}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.floor-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -4,8 +4,10 @@ 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 memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-button";
@@ -46,8 +48,9 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
/** To prevent lags, getItems needs to be memoized */
@property({ attribute: false })
public getItems?: (
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@@ -64,6 +67,9 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: false })
public searchKeys?: FuseWeightedKey[];
@property({ attribute: false })
public notFoundLabel?: string | ((search: string) => string);
@@ -107,6 +113,8 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "selected-section" }) public selectedSection?: string;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -156,6 +164,8 @@ export class HaGenericPicker extends LitElement {
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue(this.value, this.getItems())}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
@@ -229,10 +239,23 @@ export class HaGenericPicker extends LitElement {
.sections=${this.sections}
.sectionTitleFunction=${this.sectionTitleFunction}
.selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
></ha-picker-combo-box>
`;
}
private _unknownValue = memoizeOne(
(value?: string, items?: (PickerComboBoxItem | string)[]) => {
if (value === undefined || value === null || value === "" || !items) {
return false;
}
return !items.some(
(item) => typeof item !== "string" && item.id === value
);
}
);
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}

View File

@@ -32,6 +32,12 @@ export class HaGridSizeEditor extends LitElement {
@property({ attribute: false }) public step = 1;
@property({ type: Boolean, attribute: "rows-disabled" })
public rowsDisabled?: boolean;
@property({ type: Boolean, attribute: "columns-disabled" })
public columnsDisabled?: boolean;
@state() public _localValue?: CardGridSize = { rows: 1, columns: 1 };
protected willUpdate(changedProperties) {
@@ -42,9 +48,11 @@ export class HaGridSizeEditor extends LitElement {
protected render() {
const disabledColumns =
this.columnMin !== undefined && this.columnMin === this.columnMax;
this.columnsDisabled ||
(this.columnMin !== undefined && this.columnMin === this.columnMax);
const disabledRows =
this.rowMin !== undefined && this.rowMin === this.rowMax;
this.rowsDisabled ||
(this.rowMin !== undefined && this.rowMin === this.rowMax);
const autoHeight = this._localValue?.rows === "auto";
const fullWidth = this._localValue?.columns === "full";
@@ -72,7 +80,7 @@ export class HaGridSizeEditor extends LitElement {
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledColumns}
tooltip-mode="always"
tooltip-mode=${disabledColumns ? "never" : "always"}
></ha-grid-layout-slider>
<ha-grid-layout-slider
@@ -88,7 +96,7 @@ export class HaGridSizeEditor extends LitElement {
@value-changed=${this._valueChanged}
@slider-moved=${this._sliderMoved}
.disabled=${disabledRows}
tooltip-mode="always"
tooltip-mode=${disabledRows ? "never" : "always"}
></ha-grid-layout-slider>
${!this.isDefault
? html`

View File

@@ -15,6 +15,7 @@ import type { LabelRegistryEntry } from "../data/label_registry";
import {
createLabelRegistryEntry,
getLabels,
labelComboBoxKeys,
subscribeLabelRegistry,
} from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
@@ -237,6 +238,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged}
>
<slot .slot=${this._slotNodes?.length ? "field" : undefined}></slot>

View File

@@ -40,14 +40,12 @@ export const getLanguageOptions = (
return {
id: lang,
primary,
search_labels: [primary],
};
});
} else if (locale) {
options = languages.map((lang) => ({
id: lang,
primary: formatLanguageCode(lang, locale),
search_labels: [formatLanguageCode(lang, locale)],
}));
}

View File

@@ -99,10 +99,7 @@ class HaMarkdownElement extends ReactiveElement {
}
);
render(
elements.map((e) => h(unsafeHTML(e))),
this.renderRoot
);
render(h(unsafeHTML(elements.join(""))), this.renderRoot);
this._resize();

View File

@@ -25,11 +25,11 @@ export class HaMarkdown extends LitElement {
@property({ type: Boolean }) public cache = false;
@query("ha-markdown-element") private _markdownElement!: ReactiveElement;
@query("ha-markdown-element") private _markdownElement?: ReactiveElement;
protected async getUpdateComplete() {
const result = await super.getUpdateComplete();
await this._markdownElement.updateComplete;
await this._markdownElement?.updateComplete;
return result;
}
@@ -70,13 +70,15 @@ export class HaMarkdown extends LitElement {
a {
color: var(--markdown-link-color, var(--primary-color));
}
:host([assist]) img {
height: auto;
width: auto;
transition: height 0.2s ease-in-out;
}
img {
background-color: var(--markdown-image-background-color);
border-radius: var(--markdown-image-border-radius);
max-width: 100%;
height: auto;
width: auto;
transition: height 0.2s ease-in-out;
}
p:first-child > img:first-child {
vertical-align: top;

View File

@@ -14,7 +14,11 @@ import memoizeOne from "memoize-one";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { HaFuse } from "../resources/fuse";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
@@ -25,11 +29,26 @@ import "./ha-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
export const DEFAULT_SEARCH_KEYS: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "secondary",
weight: 7,
},
{
name: "id",
weight: 3,
},
];
export interface PickerComboBoxItem {
id: string;
primary: string;
secondary?: string;
search_labels?: string[];
search_labels?: Record<string, string | null>;
sorting_label?: string;
icon_path?: string;
icon?: string;
@@ -59,7 +78,7 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
) => T[];
@customElement("ha-picker-combo-box")
export class HaPickerComboBox extends LitElement {
export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@@ -76,10 +95,13 @@ export class HaPickerComboBox extends LitElement {
@property() public value?: string;
@property({ attribute: false })
public searchKeys?: FuseWeightedKey[];
@state() private _listScrolled = false;
@property({ attribute: false })
public getItems?: (
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
@@ -126,8 +148,14 @@ export class HaPickerComboBox extends LitElement {
@state() private _items: (PickerComboBoxItem | string)[] = [];
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
}
@state() private _sectionTitle?: string;
@state() private _valuePinned = true;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _selectedItemIndex = -1;
@@ -180,19 +208,31 @@ export class HaPickerComboBox extends LitElement {
</div>
`
: nothing}
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>`;
<div class="virtualizer-wrapper">
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${this._items}
.renderItem=${this._renderItem}
style="min-height: 36px;"
class=${this._listScrolled ? "scrolled" : ""}
.layout=${this.value && this._valuePinned
? {
pin: {
index: this._getInitialSelectedIndex(),
block: "center",
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
${this.renderScrollableFades()}
</div>`;
}
private _renderSectionButtons() {
@@ -236,15 +276,16 @@ export class HaPickerComboBox extends LitElement {
}
}
@eventOptions({ passive: true })
private _handleUnpinned() {
this._valuePinned = false;
}
private _getAdditionalItems = (searchString?: string) =>
this.getAdditionalItems?.(searchString) || [];
private _getItems = () => {
let items = [
...(this.getItems
? this.getItems(this._search, this.selectedSection)
: []),
];
let items = [...this.getItems(this._search, this.selectedSection)];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) =>
@@ -271,6 +312,9 @@ export class HaPickerComboBox extends LitElement {
};
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
if (!item) {
return nothing;
}
if (item === "padding") {
return html`<div class="bottom-padding"></div>`;
}
@@ -331,8 +375,9 @@ export class HaPickerComboBox extends LitElement {
fireEvent(this, "value-changed", { value: newValue });
};
private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
private _fuseIndex = memoizeOne(
(states: PickerComboBoxItem[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys || DEFAULT_SEARCH_KEYS, states)
);
private _filterChanged = (ev: Event) => {
@@ -348,34 +393,26 @@ export class HaPickerComboBox extends LitElement {
return;
}
const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
const fuse = new HaFuse(
const index = this._fuseIndex(
this._allItems as PickerComboBoxItem[],
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
this.searchKeys
);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = [...this._allItems];
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems as PickerComboBoxItem[],
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
) as (PickerComboBoxItem | string)[];
if (results) {
const items: (PickerComboBoxItem | string)[] = results.map(
(result) => result.item
);
if (!items.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
filteredItems = items;
if (!filteredItems.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
filteredItems.push(...additionalItems);
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
@@ -582,158 +619,187 @@ export class HaPickerComboBox extends LitElement {
}
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
typeof item === "string" ? item : item?.id;
static styles = [
haStyleScrollbar,
css`
:host {
display: flex;
flex-direction: column;
padding-top: var(--ha-space-3);
flex: 1;
}
private _getInitialSelectedIndex() {
if (!this._virtualizerElement || !this.value) {
return 0;
}
ha-textfield {
padding: 0 var(--ha-space-3);
margin-bottom: var(--ha-space-3);
}
const index = this._virtualizerElement.items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
);
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
if (index === -1) {
return 0;
}
ha-combo-box-item {
width: 100%;
}
return index;
}
ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
static get styles() {
return [
...super.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%;
}
@media (prefers-color-scheme: dark) {
ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
}
lit-virtualizer {
flex: 1;
}
@media (prefers-color-scheme: dark) {
ha-combo-box-item.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
lit-virtualizer:focus-visible {
outline: none;
}
.virtualizer-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
lit-virtualizer.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
lit-virtualizer {
flex: 1;
}
.bottom-padding {
height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
width: 100%;
}
lit-virtualizer:focus-visible {
outline: none;
}
.empty {
text-align: center;
}
lit-virtualizer.scrolled {
border-top: 1px solid var(--ha-color-border-neutral-quiet);
}
.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);
}
.bottom-padding {
height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
width: 100%;
}
.combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
.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);
}
@media (prefers-color-scheme: dark) {
.combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
}
.sections {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
@media (prefers-color-scheme: dark) {
.combo-box-row.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
:host([mode="dialog"]) .sections {
padding: var(--ha-space-3) var(--ha-space-4);
}
.sections {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
padding: var(--ha-space-3) var(--ha-space-3);
overflow: auto;
}
.sections ha-filter-chip {
flex-shrink: 0;
--md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
:host([mode="dialog"]) .sections {
padding: var(--ha-space-3) var(--ha-space-4);
}
.sections .separator {
height: var(--ha-space-8);
width: 0;
border: 1px solid var(--ha-color-border-neutral-quiet);
}
.sections ha-filter-chip {
flex-shrink: 0;
--md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
.section-title,
.title {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
.sections .separator {
height: var(--ha-space-8);
width: 0;
border: 1px solid var(--ha-color-border-neutral-quiet);
}
.title {
width: 100%;
}
.section-title,
.title {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
:host([mode="dialog"]) .title {
padding: var(--ha-space-1) var(--ha-space-4);
}
.title {
width: 100%;
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
:host([mode="dialog"]) .title {
padding: var(--ha-space-1) var(--ha-space-4);
}
.section-title-wrapper {
height: 0;
position: relative;
}
:host([mode="dialog"]) ha-textfield {
padding: 0 var(--ha-space-4);
}
.section-title {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-8));
}
.section-title-wrapper {
height: 0;
position: relative;
}
.section-title.show {
opacity: 1;
z-index: 1;
}
.section-title {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-8));
}
.empty-search {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
padding: var(--ha-space-3);
}
`,
];
.section-title.show {
opacity: 1;
z-index: 1;
}
.empty-search {
display: flex;
width: 100%;
flex-direction: column;
align-items: center;
padding: var(--ha-space-3);
}
`,
];
}
}
declare global {

View File

@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import { mdiClose, mdiMenuDown } from "@mdi/js";
import {
css,
@@ -7,8 +8,10 @@ import {
type CSSResultGroup,
type TemplateResult,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { localizeContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon-button";
@@ -33,6 +36,10 @@ export class HaPickerField extends LitElement {
@property() public placeholder?: string;
@property({ type: Boolean, reflect: true }) public unknown = false;
@property({ attribute: "unknown-item-text" }) public unknownItemText?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@@ -41,6 +48,10 @@ export class HaPickerField extends LitElement {
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
@state()
@consume({ context: localizeContext, subscribe: true })
private localize!: HomeAssistant["localize"];
public async focus() {
await this.updateComplete;
await this.item?.focus();
@@ -61,6 +72,12 @@ export class HaPickerField extends LitElement {
${this.placeholder}
</span>
`}
${this.unknown
? html`<div slot="supporting-text" class="unknown">
${this.unknownItemText ||
this.localize("ui.components.combo-box.unknown_item")}
</div>`
: nothing}
${showClearIcon
? html`
<ha-icon-button
@@ -142,6 +159,10 @@ export class HaPickerField extends LitElement {
background-color: var(--mdc-theme-primary);
}
:host([unknown]) ha-combo-box-item {
background-color: var(--ha-color-fill-warning-quiet-resting);
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
@@ -156,6 +177,10 @@ export class HaPickerField extends LitElement {
color: var(--secondary-text-color);
padding: 0 8px;
}
.unknown {
color: var(--ha-color-on-warning-normal);
}
`,
];
}

View File

@@ -9,13 +9,13 @@ import { customElement, property, query, state } from "lit/decorators";
import { prepareZXingModule } from "barcode-detector";
import type QrScanner from "qr-scanner";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { addExternalBarCodeListener } from "../external_app/external_app_entrypoint";
import type { HomeAssistant } from "../types";
import "./ha-alert";
import "./ha-button";
import "./ha-button-menu";
import "./ha-list-item";
import "./ha-dropdown";
import "./ha-dropdown-item";
import type { HaDropdownItem } from "./ha-dropdown-item";
import "./ha-spinner";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -52,6 +52,8 @@ class HaQrScanner extends LitElement {
@state() private _warning?: string;
@state() private _selectedCamera?: string;
private _qrScanner?: QrScanner;
private _qrNotFoundCount = 0;
@@ -121,7 +123,7 @@ class HaQrScanner extends LitElement {
!this._error &&
this._cameras &&
this._cameras.length > 1
? html`<ha-button-menu fixed @closed=${stopPropagation}>
? html`<ha-dropdown @wa-select=${this._handleDropdownSelect}>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize(
@@ -131,15 +133,17 @@ class HaQrScanner extends LitElement {
></ha-icon-button>
${this._cameras!.map(
(camera) => html`
<ha-list-item
<ha-dropdown-item
.value=${camera.id}
@click=${this._cameraChanged}
class=${this._selectedCamera === camera.id
? "selected"
: ""}
>
${camera.label}
</ha-list-item>
</ha-dropdown-item>
`
)}
</ha-button-menu>`
</ha-dropdown>`
: nothing}
</div>`
: html`<ha-alert alert-type="warning">
@@ -205,6 +209,9 @@ class HaQrScanner extends LitElement {
private async _listCameras(qrScanner: typeof QrScanner): Promise<void> {
this._cameras = await qrScanner.listCameras(true);
if (this._cameras.length > 0) {
this._selectedCamera = this._cameras[0].id;
}
}
private _qrCodeError = (err: any) => {
@@ -252,8 +259,12 @@ class HaQrScanner extends LitElement {
this._qrCodeScanned(this._manualInput!.value);
}
private _cameraChanged(ev: CustomEvent): void {
this._qrScanner?.setCamera((ev.target as any).value);
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const cameraId = ev.detail?.item?.value;
if (cameraId) {
this._selectedCamera = cameraId;
this._qrScanner?.setCamera(cameraId);
}
}
private _openExternalScanner() {
@@ -359,7 +370,7 @@ class HaQrScanner extends LitElement {
#canvas-container {
position: relative;
}
ha-button-menu {
ha-icon-button {
position: absolute;
bottom: 8px;
right: 8px;
@@ -369,6 +380,9 @@ class HaQrScanner extends LitElement {
color: white;
border-radius: var(--ha-border-radius-circle);
}
ha-dropdown-item.selected {
font-weight: var(--ha-font-weight-bold);
}
.row {
display: flex;
align-items: center;

View File

@@ -21,6 +21,13 @@ interface ServiceComboBoxItem extends PickerComboBoxItem {
service_id?: string;
}
const SEARCH_KEYS = [
{ name: "name", weight: 10 },
{ name: "description", weight: 8 },
{ name: "domainName", weight: 6 },
{ name: "serviceId", weight: 3 },
];
@customElement("ha-service-picker")
class HaServicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -141,6 +148,10 @@ class HaServicePicker extends LitElement {
this.hass.localize,
this.hass.services
)}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.service-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
@@ -194,9 +205,7 @@ class HaServicePicker extends LitElement {
secondary: description,
domain_name: domainName,
service_id: serviceId,
search_labels: [serviceId, domainName, name, description].filter(
Boolean
),
search_labels: { serviceId, domainName, name, description },
sorting_label: serviceId,
});
}

View File

@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { subscribeLabFeatures } from "../data/labs";
import { subscribeLabFeature } from "../data/labs";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
interface Snowflake {
@@ -10,7 +10,7 @@ interface Snowflake {
size: number;
duration: number;
delay: number;
blur: number;
rotation: number;
}
@customElement("ha-snowflakes")
@@ -27,13 +27,14 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
public hassSubscribe() {
return [
subscribeLabFeatures(this.hass!.connection, (features) => {
this._enabled =
features.find(
(f) =>
f.domain === "frontend" && f.preview_feature === "winter_mode"
)?.enabled ?? false;
}),
subscribeLabFeature(
this.hass!.connection,
"frontend",
"winter_mode",
(feature) => {
this._enabled = feature.enabled;
}
),
];
}
@@ -51,7 +52,7 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
size: Math.random() * 12 + 8, // Random size between 8-20px
duration: Math.random() * 8 + 8, // Random duration between 8-16s
delay: Math.random() * 8, // Random delay between 0-8s
blur: Math.random() * 1, // Random blur between 0-1px
rotation: Math.random() * 720 - 360, // Random starting rotation -360 to 360deg
});
}
this._snowflakes = snowflakes;
@@ -75,20 +76,27 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
<div class="snowflakes ${isDark ? "dark" : "light"}" aria-hidden="true">
${this._snowflakes.map(
(flake) => html`
<div
<svg
class="snowflake ${this.narrow && flake.id >= 30
? "hide-narrow"
: ""}"
style="
left: ${flake.left}%;
font-size: ${flake.size}px;
width: ${flake.size}px;
height: ${flake.size}px;
animation-duration: ${flake.duration}s;
animation-delay: ${flake.delay}s;
filter: blur(${flake.blur}px);
--rotation: ${flake.rotation}deg;
"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
</div>
<path
d="M7.991 0a.644.644 0 0 1 .283 1.221v2.553l.986-.988a.645.645 0 0 1 .612-.839.644.644 0 1 1-.222 1.247l-1.376 1.38V7.52l1.65-.954.466-1.879a.645.645 0 0 1 .1-1.042.643.643 0 1 1 .445 1.189l-.363 1.356 3.145-1.82a.643.643 0 1 1 .282.49l-2.205 1.277 1.347.361a.643.643 0 1 1-.158.543l-1.88-.505L8.573 8l1.632.945 1.858-.535a.64.64 0 0 1 .95-.434.643.643 0 1 1-.805.98l-1.354.364L14 11.14a.641.641 0 0 1 .914.855.643.643 0 0 1-1.197-.366l-2.205-1.276.36 1.35a.642.642 0 0 1 .419.95.643.643 0 1 1-.967-.816l-.503-1.884L8.273 8.48v1.909l1.39 1.344a.644.644 0 1 1 .208 1.252.644.644 0 0 1-.606-.852l-.991-.994v3.64A.644.644 0 0 1 7.99 16a.644.644 0 0 1-.282-1.221v-2.553l-.986.988a.645.645 0 0 1-.612.839.644.644 0 1 1 .222-1.247l1.376-1.38V8.5l-1.632.945-.467 1.879q.079.068.134.163a.643.643 0 1 1-.68-.31l.364-1.357-3.145 1.82A.643.643 0 1 1 2 11.15l2.205-1.276-1.347-.361a.643.643 0 1 1 .158-.543l1.88.505L7.444 8l-1.65-.954-1.857.534a.64.64 0 0 1-.95.434.643.643 0 1 1 .805-.98l1.354-.364L2 4.85a.641.641 0 0 1-.914-.855.643.643 0 0 1 1.197.366l2.205 1.276-.36-1.35a.642.642 0 0 1-.419-.95.643.643 0 1 1 .967.816l.503 1.884L7.71 7.5V5.611L6.32 4.267a.644.644 0 1 1-.208-1.252.644.644 0 0 1 .607.852l.991.994V1.22A.644.644 0 0 1 7.991 0"
fill="currentColor"
/>
</svg>
`
)}
</div>
@@ -128,16 +136,10 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
.light .snowflake {
color: #00bcd4;
text-shadow:
0 0 5px #00bcd4,
0 0 10px #00e5ff;
}
.dark .snowflake {
color: #fff;
text-shadow:
0 0 5px rgba(255, 255, 255, 0.8),
0 0 10px rgba(255, 255, 255, 0.5);
}
.snowflake.hide-narrow {
@@ -146,19 +148,23 @@ export class HaSnowflakes extends SubscribeMixin(LitElement) {
@keyframes fall {
0% {
transform: translateY(-10vh) translateX(0);
transform: translateY(-10vh) translateX(0) rotate(var(--rotation));
}
25% {
transform: translateY(30vh) translateX(10px);
transform: translateY(30vh) translateX(10px)
rotate(calc(var(--rotation) + 25deg));
}
50% {
transform: translateY(60vh) translateX(-10px);
transform: translateY(60vh) translateX(-10px)
rotate(calc(var(--rotation) + 50deg));
}
75% {
transform: translateY(85vh) translateX(10px);
transform: translateY(85vh) translateX(10px)
rotate(calc(var(--rotation) + 75deg));
}
100% {
transform: translateY(120vh) translateX(0);
transform: translateY(120vh) translateX(0)
rotate(calc(var(--rotation) + 100deg));
}
}

View File

@@ -15,17 +15,30 @@ import { fireEvent } from "../common/dom/fire_event";
import { isValidEntityId } from "../common/entity/valid_entity_id";
import { computeRTL } from "../common/util/compute_rtl";
import {
areaFloorComboBoxKeys,
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
} from "../data/area_floor";
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
import { labelsContext } from "../data/context";
import { getDevices, type DevicePickerItem } from "../data/device_registry";
import {
deviceComboBoxKeys,
getDevices,
type DevicePickerItem,
} from "../data/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../data/entity";
import { getEntities, type EntityComboBoxItem } from "../data/entity_registry";
import {
entityComboBoxKeys,
getEntities,
type EntityComboBoxItem,
} from "../data/entity_registry";
import { domainToName } from "../data/integration";
import { getLabels, type LabelRegistryEntry } from "../data/label_registry";
import {
getLabels,
labelComboBoxKeys,
type LabelRegistryEntry,
} from "../data/label_registry";
import {
areaMeetsFilter,
deviceMeetsFilter,
@@ -37,7 +50,11 @@ import {
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../resources/fuse";
import {
multiTermSearch,
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
@@ -113,16 +130,16 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _fuseIndexes = {
area: memoizeOne((states: FloorComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, areaFloorComboBoxKeys)
),
entity: memoizeOne((states: EntityComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, entityComboBoxKeys)
),
device: memoizeOne((states: DevicePickerItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, deviceComboBoxKeys)
),
label: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, labelComboBoxKeys)
),
};
@@ -134,8 +151,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
}
private _createFuseIndex = (states) =>
Fuse.createIndex(["search_labels"], states);
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
protected render() {
if (this.addOnTop) {
@@ -735,8 +752,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
"entity",
entityItems,
searchTerm,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
entityComboBoxKeys
) as EntityComboBoxItem[];
}
@@ -765,7 +781,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
);
}
if (!filterType && deviceItems.length) {
@@ -799,7 +820,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
areasAndFloors = this._filterGroup(
"area",
areasAndFloors,
searchTerm
searchTerm,
areaFloorComboBoxKeys,
false
) as FloorComboBoxItem[];
}
@@ -844,7 +867,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
if (searchTerm) {
labels = this._filterGroup("label", labels, searchTerm);
labels = this._filterGroup(
"label",
labels,
searchTerm,
labelComboBoxKeys
);
}
if (!filterType && labels.length) {
@@ -863,40 +891,24 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
type: TargetType,
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
searchTerm: string,
checkExact?: (
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
) => boolean
weightedKeys: FuseWeightedKey[],
sort = true
) {
const fuseIndex = this._fuseIndexes[type](items);
const fuse = new HaFuse(
items,
{
shouldSort: false,
minMatchCharLength: Math.min(searchTerm.length, 2),
},
fuseIndex
);
const results = fuse.multiTermsSearch(searchTerm);
let filteredItems = items;
if (results) {
filteredItems = results.map((result) => result.item);
if (sort) {
return multiTermSortedSearch(
items,
searchTerm,
weightedKeys,
(item) => item.id,
fuseIndex
);
}
if (!checkExact) {
return filteredItems;
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex((item) => checkExact(item));
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
return multiTermSearch(items, searchTerm, weightedKeys, fuseIndex, {
ignoreLocation: true,
});
}
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);

View File

@@ -10,6 +10,7 @@ import {
} from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dialog-header";
@@ -49,7 +50,6 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @cssprop --ha-dialog-surface-background - Dialog background color.
* @cssprop --ha-dialog-border-radius - Border radius of the dialog surface.
* @cssprop --dialog-z-index - Z-index for the dialog.
* @cssprop --dialog-surface-position - CSS position of the dialog surface.
* @cssprop --dialog-surface-margin-top - Top margin for the dialog surface.
*
* @attr {boolean} open - Controls the dialog open state.
@@ -73,7 +73,7 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @see https://github.com/home-assistant/frontend/issues/27143
*/
@customElement("ha-wa-dialog")
export class HaWaDialog extends LitElement {
export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
@@ -114,6 +114,10 @@ export class HaWaDialog extends LitElement {
@state()
private _bodyScrolled = false;
protected get scrollableElement(): HTMLElement | null {
return this.bodyContainer;
}
protected updated(
changedProperties: Map<string | number | symbol, unknown>
): void {
@@ -162,8 +166,11 @@ export class HaWaDialog extends LitElement {
<slot name="headerActionItems" slot="actionItems"></slot>
</ha-dialog-header>
</slot>
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>
<div class="content-wrapper">
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>
</div>
${this.renderScrollableFades()}
</div>
<slot name="footer" slot="footer"></slot>
</wa-dialog>
@@ -200,166 +207,180 @@ export class HaWaDialog extends LitElement {
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
}
static styles = [
haStyleScrollbar,
css`
wa-dialog {
--full-width: var(--ha-dialog-width-full, min(95vw, var(--safe-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);
--ha-dialog-surface-background: var(
--card-background-color,
var(--ha-color-surface-default)
);
--wa-color-surface-raised: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
--wa-panel-border-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-3xl)
);
max-width: var(--ha-dialog-max-width, var(--safe-width));
}
static get styles() {
return [
...super.styles,
haStyleScrollbar,
css`
wa-dialog {
--full-width: var(
--ha-dialog-width-full,
min(95vw, var(--safe-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);
--ha-dialog-surface-background: var(
--card-background-color,
var(--ha-color-surface-default)
);
--wa-color-surface-raised: var(
--ha-dialog-surface-background,
var(--card-background-color, var(--ha-color-surface-default))
);
--wa-panel-border-radius: var(
--ha-dialog-border-radius,
var(--ha-border-radius-3xl)
);
max-width: var(--ha-dialog-max-width, var(--safe-width));
}
:host([width="small"]) wa-dialog {
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
}
:host([width="small"]) wa-dialog {
--width: min(var(--ha-dialog-width-sm, 320px), var(--full-width));
}
:host([width="large"]) wa-dialog {
--width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width));
}
:host([width="large"]) wa-dialog {
--width: min(var(--ha-dialog-width-lg, 1024px), var(--full-width));
}
:host([width="full"]) wa-dialog {
--width: var(--full-width);
}
:host([width="full"]) wa-dialog {
--width: var(--full-width);
}
wa-dialog::part(dialog) {
min-width: var(--width, var(--full-width));
max-width: var(--width, var(--full-width));
max-height: var(
--ha-dialog-max-height,
calc(var(--safe-height) - 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);
/* Used to offset the dialog from the safe areas when space is limited */
transform: translate(
calc(
var(--safe-area-offset-left, var(--ha-space-0)) - var(
--safe-area-offset-right,
var(--ha-space-0)
)
),
calc(
var(--safe-area-offset-top, var(--ha-space-0)) - var(
--safe-area-offset-bottom,
var(--ha-space-0)
)
)
);
display: flex;
flex-direction: column;
overflow: hidden;
}
wa-dialog::part(dialog) {
min-width: var(--width, var(--full-width));
max-width: var(--width, var(--full-width));
max-height: var(
--ha-dialog-max-height,
calc(var(--safe-height) - var(--ha-space-20))
);
min-height: var(--ha-dialog-min-height);
margin-top: var(--dialog-surface-margin-top, auto);
/* Used to offset the dialog from the safe areas when space is limited */
transform: translate(
calc(
var(--safe-area-offset-left, var(--ha-space-0)) - var(
--safe-area-offset-right,
var(--ha-space-0)
)
),
calc(
var(--safe-area-offset-top, var(--ha-space-0)) - var(
--safe-area-offset-bottom,
var(--ha-space-0)
)
)
);
display: flex;
flex-direction: column;
overflow: hidden;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
:host([type="standard"]) {
--ha-dialog-border-radius: var(--ha-space-0);
@media all and (max-width: 450px), all and (max-height: 500px) {
:host([type="standard"]) {
--ha-dialog-border-radius: var(--ha-space-0);
wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
wa-dialog {
/* Make the container fill the whole screen width and not the safe width */
--full-width: var(--ha-dialog-width-full, 100vw);
--width: var(--full-width);
}
wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
wa-dialog::part(dialog) {
/* Make the dialog fill the whole screen height and not the safe height */
min-height: var(--ha-dialog-min-height, 100vh);
min-height: var(--ha-dialog-min-height, 100dvh);
max-height: var(--ha-dialog-max-height, 100vh);
max-height: var(--ha-dialog-max-height, 100dvh);
margin-top: 0;
margin-bottom: 0;
/* Use safe area as padding instead of the container size */
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
/* Reset the transform to center the dialog */
transform: none;
}
}
}
}
.header-title-container {
display: flex;
align-items: center;
}
.header-title-container {
display: flex;
align-items: center;
}
.header-title {
margin: 0;
margin-bottom: 0;
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)
);
line-height: var(
--ha-dialog-header-title-line-height,
var(--ha-line-height-condensed)
);
font-weight: var(
--ha-dialog-header-title-font-weight,
var(--ha-font-weight-normal)
);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--ha-space-3);
}
.header-title {
margin: 0;
margin-bottom: 0;
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)
);
line-height: var(
--ha-dialog-header-title-line-height,
var(--ha-line-height-condensed)
);
font-weight: var(
--ha-dialog-header-title-font-weight,
var(--ha-font-weight-normal)
);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-right: var(--ha-space-3);
}
wa-dialog::part(body) {
padding: 0;
display: flex;
flex-direction: column;
max-width: 100%;
overflow: hidden;
}
wa-dialog::part(body) {
padding: 0;
display: flex;
flex-direction: column;
max-width: 100%;
overflow: hidden;
}
.body {
position: var(--dialog-content-position, relative);
padding: 0 var(--dialog-content-padding, var(--ha-space-6))
var(--dialog-content-padding, var(--ha-space-6))
var(--dialog-content-padding, var(--ha-space-6));
overflow: auto;
flex-grow: 1;
}
:host([flexcontent]) .body {
max-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
.content-wrapper {
position: relative;
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
wa-dialog::part(footer) {
padding: var(--ha-space-0);
}
.body {
position: var(--dialog-content-position, relative);
padding: var(
--dialog-content-padding,
0 var(--ha-space-6) var(--ha-space-6) var(--ha-space-6)
);
overflow: auto;
flex-grow: 1;
}
:host([flexcontent]) .body {
max-width: 100%;
flex: 1;
display: flex;
flex-direction: column;
}
::slotted([slot="footer"]) {
display: flex;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
gap: var(--ha-space-3);
justify-content: flex-end;
align-items: center;
width: 100%;
}
`,
];
wa-dialog::part(footer) {
padding: var(--ha-space-0);
}
::slotted([slot="footer"]) {
display: flex;
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4)
var(--ha-space-4);
gap: var(--ha-space-3);
justify-content: flex-end;
align-items: center;
width: 100%;
}
`,
];
}
}
declare global {

View File

@@ -17,6 +17,12 @@ interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
const SEARCH_KEYS = [
{ name: "primary", weight: 10 },
{ name: "search_labels.username", weight: 6 },
{ name: "id", weight: 3 },
];
@customElement("ha-user-picker")
class HaUserPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -109,9 +115,7 @@ class HaUserPicker extends LitElement {
id: user.id,
primary: user.name,
domain_name: user.name,
search_labels: [user.name, user.id, user.username].filter(
Boolean
) as string[],
search_labels: { username: user.username },
sorting_label: user.name,
user,
}));
@@ -134,6 +138,10 @@ class HaUserPicker extends LitElement {
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
.searchKeys=${SEARCH_KEYS}
.unknownItemText=${this.hass.localize(
"ui.components.user-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -4,6 +4,7 @@ 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 { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area_registry";
import {
@@ -26,7 +27,8 @@ export interface FloorNestedComboBoxItem extends PickerComboBoxItem {
areas: FloorComboBoxItem[];
}
export interface UnassignedAreasFloorComboBoxItem extends PickerComboBoxItem {
export interface UnassignedAreasFloorComboBoxItem {
id: undefined;
areas: FloorComboBoxItem[];
}
@@ -98,6 +100,29 @@ export const getAreasAndFloors = (
excludeFloors
) as FloorComboBoxItem[];
export const areaFloorComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.name",
weight: 10,
},
{
name: "search_labels.aliases",
weight: 8,
},
{
name: "search_labels.floorName",
weight: 6,
},
{
name: "search_labels.relatedAreas",
weight: 4,
},
{
name: "search_labels.id",
weight: 3,
},
];
const getAreasAndFloorsItems = (
states: HomeAssistant["states"],
haFloors: HomeAssistant["floors"],
@@ -223,6 +248,7 @@ const getAreasAndFloorsItems = (
}
let outputAreas = areas;
let outputFloors = floors;
let areaIds: string[] | undefined;
@@ -254,9 +280,29 @@ const getAreasAndFloorsItems = (
outputAreas = outputAreas.filter(
(area) => !area.floor_id || !excludeFloors!.includes(area.floor_id)
);
outputFloors = outputFloors.filter(
(floor) => !excludeFloors!.includes(floor.floor_id)
);
}
const hierarchy = getAreasFloorHierarchy(floors, outputAreas);
if (
entityFilter ||
deviceFilter ||
includeDomains ||
excludeDomains ||
includeDeviceClasses
) {
// Ensure we only include floors that have areas with the filtered entities/devices
const validFloorIds = new Set(
outputAreas.map((area) => area.floor_id).filter((id) => id)
);
outputFloors = outputFloors.filter((floor) =>
validFloorIds.has(floor.floor_id)
);
}
const hierarchy = getAreasFloorHierarchy(outputFloors, outputAreas);
const items: (
| FloorComboBoxItem
@@ -283,12 +329,12 @@ const getAreasAndFloorsItems = (
primary: floorName,
floor: floor,
icon: floor.icon || undefined,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
search_labels: {
id: floor.floor_id,
name: floorName || null,
aliases: floor.aliases.join(", ") || null,
relatedAreas: areaSearchLabels.join(" ") || null,
},
};
items.push(floorItem);
@@ -301,11 +347,12 @@ const getAreasAndFloorsItems = (
primary: areaName || area.area_id,
area: area,
icon: area.icon || undefined,
search_labels: [
area.area_id,
...(areaName ? [areaName] : []),
...area.aliases,
],
search_labels: {
id: area.area_id,
name: areaName || null,
aliases: area.aliases.join(", ") || null,
floorName: floorName || null,
},
};
});
@@ -318,19 +365,24 @@ const getAreasAndFloorsItems = (
const unassignedAreaItems = hierarchy.areas.map((areaId) => {
const area = haAreas[areaId];
const areaName = computeAreaName(area) || area.area_id;
const areaName = computeAreaName(area);
return {
id: formatId({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
primary: areaName || area.area_id,
area: area,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
search_labels: {
id: area.area_id,
name: areaName || null,
aliases: area.aliases.join(", ") || null,
},
};
});
if (nested && unassignedAreaItems.length) {
items.push({
id: undefined,
areas: unassignedAreaItems,
} as UnassignedAreasFloorComboBoxItem);
} else {

View File

@@ -1,4 +1,3 @@
import { stringCompare } from "../common/string/compare";
import type { HomeAssistant } from "../types";
import type { DeviceRegistryEntry } from "./device_registry";
import type {
@@ -105,22 +104,3 @@ export const getAreaDeviceLookup = (
}
return areaDeviceLookup;
};
export const areaCompare =
(entries?: HomeAssistant["areas"], 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;
return stringCompare(nameA, nameB);
}
if (indexA === -1) {
return 1;
}
if (indexB === -1) {
return -1;
}
return indexA - indexB;
};

View File

@@ -27,6 +27,7 @@ export interface ConfigEntry {
reason: string | null;
error_reason_translation_key: string | null;
error_reason_translation_placeholders: Record<string, string> | null;
configuration_url?: string | null;
}
export interface SubEntry {

View File

@@ -6,6 +6,7 @@ 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 { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { ConfigEntry } from "./config_entries";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
@@ -181,6 +182,25 @@ export interface DevicePickerItem extends PickerComboBoxItem {
domain_name?: string;
}
export const deviceComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.deviceName",
weight: 10,
},
{
name: "search_labels.areaName",
weight: 8,
},
{
name: "search_labels.domainName",
weight: 4,
},
{
name: "search_labels.domain",
weight: 4,
},
];
export const getDevices = (
hass: HomeAssistant,
configEntryLookup: Record<string, ConfigEntry>,
@@ -311,9 +331,12 @@ export const getDevices = (
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
search_labels: {
deviceName,
areaName: areaName || null,
domain: domain || null,
domainName: domainName || null,
},
sorting_label: deviceName || "zzz",
};
});

View File

@@ -11,7 +11,7 @@ import {
isLastDayOfMonth,
addYears,
} from "date-fns";
import type { Collection } from "home-assistant-js-websocket";
import type { Collection, HassEntity } from "home-assistant-js-websocket";
import { getCollection } from "home-assistant-js-websocket";
import memoizeOne from "memoize-one";
import {
@@ -1361,3 +1361,37 @@ export const calculateSolarConsumedGauge = (
}
return undefined;
};
/**
* Get current power value from entity state, normalized to kW
* @param stateObj - The entity state object to get power value from
* @returns Power value in kW, or 0 if entity not found or invalid
*/
export const getPowerFromState = (stateObj: HassEntity): number | undefined => {
if (!stateObj) {
return undefined;
}
const value = parseFloat(stateObj.state);
if (isNaN(value)) {
return undefined;
}
// Normalize to kW based on unit of measurement (case-sensitive)
// Supported units: GW, kW, MW, mW, TW, W
const unit = stateObj.attributes.unit_of_measurement;
switch (unit) {
case "W":
return value / 1000;
case "mW":
return value / 1000000;
case "MW":
return value * 1000;
case "GW":
return value * 1000000;
case "TW":
return value * 1000000000;
default:
// Assume kW if no unit or unit is kW
return value;
}
};

View File

@@ -9,6 +9,7 @@ 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 { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import type { HaEntityPickerEntityFilterFunc } from "./entity";
import { domainToName } from "./integration";
@@ -335,6 +336,33 @@ export interface EntityComboBoxItem extends PickerComboBoxItem {
stateObj?: HassEntity;
}
export const entityComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.entityName",
weight: 10,
},
{
name: "search_labels.friendlyName",
weight: 8,
},
{
name: "search_labels.deviceName",
weight: 7,
},
{
name: "search_labels.areaName",
weight: 6,
},
{
name: "search_labels.domainName",
weight: 6,
},
{
name: "search_labels.entityId",
weight: 3,
},
];
export const getEntities = (
hass: HomeAssistant,
includeDomains?: string[],
@@ -403,14 +431,14 @@ export const getEntities = (
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
search_labels: {
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId: entityId,
},
stateObj: stateObj,
};
});

View File

@@ -7,6 +7,7 @@ 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 { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
import {
getDeviceEntityDisplayLookup,
@@ -100,6 +101,21 @@ export const deleteLabelRegistryEntry = (
label_id: labelId,
});
export const labelComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.name",
weight: 10,
},
{
name: "search_labels.description",
weight: 5,
},
{
name: "search_labels.id",
weight: 4,
},
];
export const getLabels = (
hassStates: HomeAssistant["states"],
hassAreas: HomeAssistant["areas"],
@@ -273,9 +289,11 @@ export const getLabels = (
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)
),
search_labels: {
name: label.name,
description: label.description,
id: label.label_id,
},
}));
return items;

View File

@@ -18,6 +18,11 @@ export interface LabPreviewFeaturesResponse {
features: LabPreviewFeature[];
}
/**
* Fetch all lab features
* @param hass - The Home Assistant instance
* @returns A promise to fetch the lab features
*/
export const fetchLabFeatures = async (
hass: HomeAssistant
): Promise<LabPreviewFeature[]> => {
@@ -27,6 +32,15 @@ export const fetchLabFeatures = async (
return response.features;
};
/**
* Update a specific lab feature
* @param hass - The Home Assistant instance
* @param domain - The domain of the lab feature
* @param preview_feature - The preview feature of the lab feature
* @param enabled - Whether the lab feature is enabled
* @param create_backup - Whether to create a backup of the lab feature
* @returns A promise to update the lab feature
*/
export const labsUpdatePreviewFeature = (
hass: HomeAssistant,
domain: string,
@@ -65,6 +79,12 @@ const subscribeLabUpdates = (
"labs_updated"
);
/**
* Subscribe to a collection of lab features
* @param conn - The connection to the Home Assistant instance
* @param onChange - The function to call when the lab features change
* @returns The unsubscribe function
*/
export const subscribeLabFeatures = (
conn: Connection,
onChange: (features: LabPreviewFeature[]) => void
@@ -76,3 +96,23 @@ export const subscribeLabFeatures = (
conn,
onChange
);
/**
* Subscribe to a specific lab feature
* @param conn - The connection to the Home Assistant instance
* @param domain - The domain of the lab feature
* @param previewFeature - The preview feature identifier
* @param onChange - The function to call when the lab feature changes
* @returns A promise that resolves to the unsubscribe function
*/
export const subscribeLabFeature = (
conn: Connection,
domain: string,
previewFeature: string,
onChange: (feature: LabPreviewFeature) => void
): Promise<() => void> =>
conn.subscribeMessage<LabPreviewFeature>(onChange, {
type: "labs/subscribe",
domain,
preview_feature: previewFeature,
});

View File

@@ -220,12 +220,12 @@ const tryDescribeAction = <T extends ActionType>(
if (config.action) {
const [domain, serviceName] = config.action.split(".", 2);
const descriptionPlaceholders =
hass.services[domain][serviceName].description_placeholders;
hass.services[domain]?.[serviceName]?.description_placeholders;
const service =
hass.localize(
`component.${domain}.services.${serviceName}.name`,
descriptionPlaceholders
) || hass.services[domain][serviceName]?.name;
) || hass.services[domain]?.[serviceName]?.name;
if (config.metadata) {
return hass.localize(

View File

@@ -82,6 +82,12 @@ export interface WeatherEntity extends HassEntityBase {
attributes: WeatherEntityAttributes;
}
export const WEATHER_TEMPERATURE_ATTRIBUTES = new Set<string>([
"temperature",
"apparent_temperature",
"dew_point",
]);
export const weatherSVGs = new Set<string>([
"clear-night",
"cloudy",
@@ -256,9 +262,15 @@ export const getWeatherUnit = (
export const getSecondaryWeatherAttribute = (
hass: HomeAssistant,
stateObj: WeatherEntity,
forecast: ForecastAttribute[]
forecast: ForecastAttribute[],
temperatureFractionDigits?: number
): TemplateResult | undefined => {
const extrema = getWeatherExtrema(hass, stateObj, forecast);
const extrema = getWeatherExtrema(
hass,
stateObj,
forecast,
temperatureFractionDigits
);
if (extrema) {
return extrema;
@@ -298,7 +310,8 @@ export const getSecondaryWeatherAttribute = (
const getWeatherExtrema = (
hass: HomeAssistant,
stateObj: WeatherEntity,
forecast: ForecastAttribute[]
forecast: ForecastAttribute[],
temperatureFractionDigits?: number
): TemplateResult | undefined => {
if (!forecast?.length) {
return undefined;
@@ -313,13 +326,22 @@ const getWeatherExtrema = (
break;
}
if (!tempHigh || fc.temperature > tempHigh) {
tempHigh = fc.temperature;
tempHigh =
temperatureFractionDigits === undefined
? fc.temperature
: round(fc.temperature, temperatureFractionDigits);
}
if (!tempLow || (fc.templow && fc.templow < tempLow)) {
tempLow = fc.templow;
if (fc.templow !== undefined && (!tempLow || fc.templow < tempLow)) {
tempLow =
temperatureFractionDigits === undefined
? fc.templow
: round(fc.templow, temperatureFractionDigits);
}
if (!fc.templow && (!tempLow || fc.temperature < tempLow)) {
tempLow = fc.temperature;
tempLow =
temperatureFractionDigits === undefined
? fc.temperature
: round(fc.temperature, temperatureFractionDigits);
}
}

View File

@@ -14,8 +14,9 @@ import {
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
@@ -50,7 +51,12 @@ import { lightSupportsFavoriteColors } from "../../data/light";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
} from "../../resources/styles";
import "../../state-summary/state-card-content";
import type { HomeAssistant } from "../../types";
import {
@@ -96,13 +102,15 @@ declare global {
const DEFAULT_VIEW: View = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends LitElement {
export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public large = false;
@state() private _parentEntityIds: string[] = [];
@query(".content") private _contentElement?: HTMLDivElement;
@state() private _entityId?: string | null;
@state() private _data?: Record<string, any>;
@@ -121,6 +129,12 @@ export class MoreInfoDialog extends LitElement {
@state() private _sensorNumericDeviceClasses?: string[] = [];
protected scrollFadeThreshold = 24;
protected get scrollableElement(): HTMLElement | null {
return this._contentElement || null;
}
public showDialog(params: MoreInfoDialogParams) {
this._entityId = params.entityId;
if (!this._entityId) {
@@ -302,7 +316,9 @@ export class MoreInfoDialog extends LitElement {
}
private _goToAddEntityTo(ev) {
if (!shouldHandleRequestSelectedEvent(ev)) return;
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (ev.type === "request-selected" && !shouldHandleRequestSelectedEvent(ev))
return;
this._setView("add_to");
}
@@ -550,7 +566,18 @@ export class MoreInfoDialog extends LitElement {
: nothing}
</ha-button-menu>
`
: nothing}
: !__DEMO__ && this._shouldShowAddEntityTo()
? html`
<ha-icon-button
slot="actionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.add_entity_to"
)}
.path=${mdiPlusBoxMultipleOutline}
@click=${this._goToAddEntityTo}
></ha-icon-button>
`
: nothing}
`
: isSpecificInitialView
? html`
@@ -581,78 +608,86 @@ export class MoreInfoDialog extends LitElement {
`
: nothing}
</ha-dialog-header>
${keyed(
this._entityId,
html`
<div
class="content"
tabindex="-1"
dialogInitialFocus
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${cache(
this._childView
? html`
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
`
: this._currView === "info"
<div
class=${classMap({
"content-wrapper": true,
"settings-view": this._currView === "settings",
})}
>
${keyed(
this._entityId,
html`
<div
class="content ha-scrollbar"
tabindex="-1"
dialogInitialFocus
@show-child-view=${this._showChildView}
@entity-entry-updated=${this._entryUpdated}
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
@hass-more-info=${this._handleMoreInfoEvent}
>
${cache(
this._childView
? html`
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
.editMode=${this._infoEditMode}
.data=${this._data}
></ha-more-info-info>
<div class="child-view">
${dynamicElement(this._childView.viewTag, {
hass: this.hass,
entry: this._entry,
params: this._childView.viewParams,
})}
</div>
`
: this._currView === "history"
: this._currView === "info"
? html`
<ha-more-info-history-and-logbook
<ha-more-info-info
dialogInitialFocus
.hass=${this.hass}
.entityId=${this._entityId}
></ha-more-info-history-and-logbook>
.entry=${this._entry}
.editMode=${this._infoEditMode}
.data=${this._data}
></ha-more-info-info>
`
: this._currView === "settings"
: this._currView === "history"
? html`
<ha-more-info-settings
<ha-more-info-history-and-logbook
.hass=${this.hass}
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
></ha-more-info-history-and-logbook>
`
: this._currView === "related"
: this._currView === "settings"
? html`
<ha-related-items
<ha-more-info-settings
.hass=${this.hass}
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
.entityId=${this._entityId}
.entry=${this._entry}
></ha-more-info-settings>
`
: this._currView === "add_to"
: this._currView === "related"
? html`
<ha-more-info-add-to
<ha-related-items
.hass=${this.hass}
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
.itemId=${entityId}
.itemType=${SearchableDomains.has(domain)
? (domain as ItemType)
: "entity"}
></ha-related-items>
`
: nothing
)}
</div>
`
)}
: this._currView === "add_to"
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
`
: nothing
)}
</div>
`
)}
${this.renderScrollableFades()}
</div>
</ha-dialog>
`;
}
@@ -707,18 +742,31 @@ export class MoreInfoDialog extends LitElement {
static get styles() {
return [
...super.styles,
haStyleDialog,
haStyleDialogFixedTop,
haStyleScrollbar,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.content {
.content-wrapper {
flex: 1 1 auto;
min-height: 0;
position: relative;
display: flex;
flex-direction: column;
}
.content {
outline: none;
flex: 1;
overflow: auto;
}
.content-wrapper.settings-view .fade-bottom {
bottom: var(--ha-space-18);
}
.child-view {

View File

@@ -45,7 +45,7 @@ import { domainToName } from "../../data/integration";
import { getPanelNameTranslationKey } from "../../data/panel";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config";
import { HaFuse } from "../../resources/fuse";
import { multiTermSortedSearch } from "../../resources/fuseMultiTerm";
import {
haStyleDialog,
haStyleDialogFixedTop,
@@ -58,7 +58,17 @@ import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar";
const SEARCH_KEYS = [
{ name: "primaryText", weight: 10 },
{ name: "altText", weight: 8 },
{ name: "friendlyName", weight: 8 },
{ name: "area", weight: 6 },
{ name: "translatedDomain", weight: 5 },
{ name: "entityId", weight: 4 }, // for technical search
];
interface QuickBarItem extends ScorableTextItem {
id: string;
primaryText: string;
iconPath?: string;
action(data?: any): void;
@@ -593,6 +603,7 @@ export class QuickBar extends LitElement {
const areaName = area ? computeAreaName(area) : undefined;
const deviceItem = {
id: device.id,
primaryText: deviceName,
deviceId: device.id,
area: areaName,
@@ -666,6 +677,7 @@ export class QuickBar extends LitElement {
);
const entityItem = {
id: `entity-${entityId}`,
primaryText: primary,
altText: secondary,
icon: html`
@@ -767,8 +779,9 @@ export class QuickBar extends LitElement {
),
});
return commands.map((command) => ({
return commands.map((command, index) => ({
...command,
id: `command_${index}_${command.primaryText}`,
categoryKey: "reload",
strings: [`${command.categoryText} ${command.primaryText}`],
}));
@@ -777,10 +790,11 @@ export class QuickBar extends LitElement {
private _generateServerControlCommands(): CommandItem[] {
const serverActions = ["restart", "stop"] as const;
return serverActions.map((action) => {
return serverActions.map((action, index) => {
const categoryKey: CommandItem["categoryKey"] = "server_control";
const item = {
id: `server_control_${index}_${action}`,
primaryText: this.hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action",
{
@@ -940,10 +954,11 @@ export class QuickBar extends LitElement {
private _finalizeNavigationCommands(
items: BaseNavigationCommand[]
): CommandItem[] {
return items.map((item) => {
return items.map((item, index) => {
const categoryKey: CommandItem["categoryKey"] = "navigation";
const navItem = {
id: `navigation_${index}_${item.path}`,
iconPath: mdiEarth,
categoryText: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
@@ -961,28 +976,20 @@ export class QuickBar extends LitElement {
}
private _fuseIndex = memoizeOne((items: QuickBarItem[]) =>
Fuse.createIndex(
[
"primaryText",
"altText",
"friendlyName",
"translatedDomain",
"entityId", // for technical search
],
items
)
Fuse.createIndex(SEARCH_KEYS, items)
);
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] => {
const index = this._fuseIndex(items);
const fuse = new HaFuse(items, {}, index);
const results = fuse.multiTermsSearch(filter.trim());
if (!results || !results.length) {
return items;
}
return results.map((result) => result.item);
return multiTermSortedSearch(
items,
filter,
SEARCH_KEYS,
(item) => item.id,
index
);
}
);

View File

@@ -15,7 +15,7 @@ import "../../components/ha-alert";
import "../../components/ha-expansion-panel";
import "../../components/ha-fade-in";
import "../../components/ha-icon-next";
import "../../components/ha-wa-dialog";
import "../../components/ha-adaptive-dialog";
import "../../components/ha-md-list";
import "../../components/ha-md-list-item";
import "../../components/ha-spinner";
@@ -109,7 +109,7 @@ class DialogRestart extends LitElement {
const dialogTitle = this.hass.localize("ui.dialogs.restart.heading");
return html`
<ha-wa-dialog
<ha-adaptive-dialog
.hass=${this.hass}
.open=${this._dialogOpen}
header-title=${dialogTitle}
@@ -257,7 +257,7 @@ class DialogRestart extends LitElement {
</ha-expansion-panel>
`}
</div>
</ha-wa-dialog>
</ha-adaptive-dialog>
`;
}
@@ -405,7 +405,7 @@ class DialogRestart extends LitElement {
haStyle,
haStyleDialog,
css`
ha-wa-dialog {
ha-adaptive-dialog {
--dialog-content-padding: 0;
}

View File

@@ -1,11 +1,12 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import { mdiClose, mdiDotsVertical, mdiRestart } from "@mdi/js";
import { css, html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { mdiDotsVertical, mdiRestart } from "@mdi/js";
import { css, html, LitElement, type TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-alert";
import "../../components/ha-dialog-header";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-fade-in";
import "../../components/ha-icon-button";
import "../../components/ha-items-display-editor";
@@ -14,9 +15,8 @@ import type {
DisplayValue,
} from "../../components/ha-items-display-editor";
import "../../components/ha-md-button-menu";
import "../../components/ha-md-dialog";
import type { HaMdDialog } from "../../components/ha-md-dialog";
import "../../components/ha-md-menu-item";
import "../../components/ha-wa-dialog";
import { computePanels } from "../../components/ha-sidebar";
import "../../components/ha-spinner";
import "../../components/ha-svg-icon";
@@ -40,8 +40,6 @@ class DialogEditSidebar extends LitElement {
@state() private _open = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _order?: string[];
@state() private _hidden?: string[];
@@ -55,7 +53,6 @@ class DialogEditSidebar extends LitElement {
public async showDialog(): Promise<void> {
this._open = true;
this._getData();
}
@@ -87,7 +84,7 @@ class DialogEditSidebar extends LitElement {
}
public closeDialog(): void {
this._dialog?.close();
this._open = false;
}
private _panels = memoizeOne((panels: HomeAssistant["panels"]) =>
@@ -167,57 +164,52 @@ class DialogEditSidebar extends LitElement {
}
protected render() {
if (!this._open) {
return nothing;
}
const dialogTitle = this.hass.localize("ui.sidebar.edit_sidebar");
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
header-subtitle=${!this._migrateToUserData
? this.hass.localize("ui.sidebar.edit_subtitle")
: ""}
@closed=${this._dialogClosed}
>
<ha-md-button-menu
slot="headerActionItems"
positioning="popover"
anchor-corner="end-end"
menu-corner="start-end"
>
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close") ?? "Close"}
.path=${mdiClose}
@click=${this.closeDialog}
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
${!this._migrateToUserData
? html`<span slot="subtitle"
>${this.hass.localize("ui.sidebar.edit_subtitle")}</span
>`
: nothing}
<ha-md-button-menu
slot="actionItems"
positioning="popover"
anchor-corner="end-end"
menu-corner="start-end"
<ha-md-menu-item .clickAction=${this._resetToDefaults}>
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.sidebar.reset_to_defaults")}
</ha-md-menu-item>
</ha-md-button-menu>
<div class="content">${this._renderContent()}</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item .clickAction=${this._resetToDefaults}>
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.sidebar.reset_to_defaults")}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dialog-header>
<div slot="content" class="content">${this._renderContent()}</div>
<div slot="actions">
<ha-button appearance="plain" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._order || !this._hidden}
@click=${this._save}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -272,15 +264,13 @@ class DialogEditSidebar extends LitElement {
}
static styles = css`
ha-md-dialog {
min-width: 600px;
ha-wa-dialog {
max-height: 90%;
--dialog-content-padding: 8px 24px;
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: 0;
@media all and (max-width: 580px), all and (max-height: 500px) {
ha-wa-dialog {
min-width: 100%;
min-height: 100%;
}

View File

@@ -13,19 +13,27 @@ import type { Constructor } from "../types";
const stylesArray = (styles?: CSSResultGroup | CSSResultGroup[]) =>
styles === undefined ? [] : Array.isArray(styles) ? styles : [styles];
/**
* Mixin that adds top and bottom fade overlays for scrollable content.
* @param superClass - The LitElement class to extend.
* @returns Extended class with scrollable fade functionality.
*/
export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
superClass: T
) => {
class ScrollableFadeClass extends superClass {
/** Whether content has scrolled past the threshold. Controls top fade visibility. */
@state() protected _contentScrolled = false;
/** Whether content extends beyond the viewport. Controls bottom fade visibility. */
@state() protected _contentScrollable = false;
private _scrollTarget?: HTMLElement | null;
private _onScroll = (ev: Event) => {
const target = ev.currentTarget as HTMLElement;
this._contentScrolled = (target.scrollTop ?? 0) > 0;
this._contentScrolled =
(target.scrollTop ?? 0) > this.scrollFadeThreshold;
this._updateScrollableState(target);
};
@@ -39,15 +47,26 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
},
});
private static readonly DEFAULT_SAFE_AREA_PADDING = 16;
/**
* Safe area padding in pixels for the scrollable element.
*/
protected scrollFadeSafeAreaPadding = 16;
/**
* Scroll threshold in pixels for showing the fades.
*/
protected scrollFadeThreshold = 4;
/**
* Default scrollable element value.
*/
private static readonly DEFAULT_SCROLLABLE_ELEMENT: HTMLElement | null =
null;
protected get scrollFadeSafeAreaPadding() {
return ScrollableFadeClass.DEFAULT_SAFE_AREA_PADDING;
}
/**
* Element to observe for scroll and resize events. Override with a getter to specify target.
* Kept as a getter to allow subclasses to return query results.
*/
protected get scrollableElement(): HTMLElement | null {
return ScrollableFadeClass.DEFAULT_SCROLLABLE_ELEMENT;
}
@@ -67,6 +86,11 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
super.disconnectedCallback();
}
/**
* Renders top and bottom fade overlays. Call in render method.
* @param rounded - Whether to apply rounded corners.
* @returns Template containing fade elements.
*/
protected renderScrollableFades(rounded = false): TemplateResult {
return html`
<div
@@ -110,7 +134,6 @@ export const ScrollableFadeMixin = <T extends Constructor<LitElement>>(
transparent
);
border-radius: var(--ha-border-radius-square);
z-index: 100;
opacity: 0;
}
.fade-top {

View File

@@ -1,7 +1,7 @@
import { mdiClose, mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import { mdiDragHorizontalVariant, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import {
type AreasFloorHierarchy,
@@ -11,12 +11,10 @@ import {
} from "../../../common/areas/areas-floor-hierarchy";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-button";
import "../../../components/ha-dialog-header";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-floor-icon";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../components/ha-md-dialog";
import "../../../components/ha-wa-dialog";
import "../../../components/ha-md-list";
import "../../../components/ha-md-list-item";
import "../../../components/ha-sortable";
@@ -49,8 +47,6 @@ class DialogAreasFloorsOrder extends LitElement {
@state() private _saving = false;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
public async showDialog(
_params: AreasFloorsOrderDialogParams
): Promise<void> {
@@ -66,7 +62,8 @@ class DialogAreasFloorsOrder extends LitElement {
}
public closeDialog(): void {
this._dialog?.close();
this._saving = false;
this._open = false;
}
private _dialogClosed(): void {
@@ -77,26 +74,25 @@ class DialogAreasFloorsOrder extends LitElement {
}
protected render() {
if (!this._open || !this._hierarchy) {
if (!this._hierarchy) {
return nothing;
}
const hasFloors = this._hierarchy.floors.length > 0;
const dialogTitle = this.hass.localize(
"ui.panel.config.areas.dialog.reorder_title"
hasFloors
? "ui.panel.config.areas.dialog.reorder_floors_areas_title"
: "ui.panel.config.areas.dialog.reorder_areas_title"
);
return html`
<ha-md-dialog open @closed=${this._dialogClosed}>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
<span slot="title" .title=${dialogTitle}>${dialogTitle}</span>
</ha-dialog-header>
<div slot="content" class="content">
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
@closed=${this._dialogClosed}
>
<div class="content">
<ha-sortable
handle-selector=".floor-handle"
draggable-selector=".floor"
@@ -113,15 +109,23 @@ class DialogAreasFloorsOrder extends LitElement {
</ha-sortable>
${this._renderUnassignedAreas()}
</div>
<div slot="actions">
<ha-button @click=${this.closeDialog} appearance="plain">
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
@click=${this.closeDialog}
appearance="plain"
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._save} .disabled=${this._saving}>
<ha-button
slot="primaryAction"
@click=${this._save}
.disabled=${this._saving}
>
${this.hass.localize("ui.common.save")}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -172,7 +176,7 @@ class DialogAreasFloorsOrder extends LitElement {
? html`<div class="floor-header">
<span class="floor-name">
${this.hass.localize(
"ui.panel.config.areas.dialog.unassigned_areas"
"ui.panel.config.areas.dialog.other_areas"
)}
</span>
</div>`
@@ -388,15 +392,13 @@ class DialogAreasFloorsOrder extends LitElement {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
min-width: 600px;
ha-wa-dialog {
max-height: 90%;
--dialog-content-padding: 8px 24px;
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
@media all and (max-width: 600px), all and (max-height: 500px) {
ha-md-dialog {
--md-dialog-container-shape: 0;
@media all and (max-width: 580px), all and (max-height: 500px) {
ha-wa-dialog {
min-width: 100%;
min-height: 100%;
}
@@ -418,7 +420,6 @@ class DialogAreasFloorsOrder extends LitElement {
}
.floor.unassigned {
border-style: dashed;
margin-top: 16px;
}

View File

@@ -175,21 +175,12 @@ export class HaConfigAreasDashboard extends LitElement {
.route=${this.route}
has-fab
>
<ha-button-menu slot="toolbar-icon">
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon" @click=${this._showReorderDialog}>
<ha-svg-icon .path=${mdiSort} slot="graphic"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.picker.reorder")}
</ha-list-item>
<ha-list-item graphic="icon" @click=${this._showHelp}>
<ha-svg-icon .path=${mdiHelpCircle} slot="graphic"></ha-svg-icon>
${this.hass.localize("ui.common.help")}
</ha-list-item>
</ha-button-menu>
<ha-icon-button
slot="toolbar-icon"
.label=${this.hass.localize("ui.common.help")}
.path=${mdiHelpCircle}
@click=${this._showHelp}
></ha-icon-button>
<div class="container">
<div class="floors">
${this._hierarchy.floors.map(({ areas, id }) => {
@@ -213,6 +204,16 @@ export class HaConfigAreasDashboard extends LitElement {
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiSort}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.reorder"
)}</ha-list-item
>
<li divider role="separator"></li>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiPencil}
@@ -266,9 +267,30 @@ export class HaConfigAreasDashboard extends LitElement {
<div class="header">
<h2>
${this.hass.localize(
"ui.panel.config.areas.picker.unassigned_areas"
this._hierarchy.floors.length
? "ui.panel.config.areas.picker.other_areas"
: "ui.panel.config.areas.picker.header"
)}
</h2>
<div class="actions">
<ha-button-menu
@action=${this._handleUnassignedAreasAction}
>
<ha-icon-button
slot="trigger"
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
.path=${mdiSort}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
"ui.panel.config.areas.picker.reorder"
)}</ha-list-item
>
</ha-button-menu>
</div>
</div>
<ha-sortable
handle-selector="a"
@@ -515,14 +537,23 @@ export class HaConfigAreasDashboard extends LitElement {
const floor = (ev.currentTarget as any).floor;
switch (ev.detail.index) {
case 0:
this._editFloor(floor);
this._showReorderDialog();
break;
case 1:
this._editFloor(floor);
break;
case 2:
this._deleteFloor(floor);
break;
}
}
private _handleUnassignedAreasAction(ev: CustomEvent<ActionDetail>) {
if (ev.detail.index === 0) {
this._showReorderDialog();
}
}
private _createFloor() {
this._openFloorDialog();
}

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAlertCircleCheck,
@@ -32,11 +33,11 @@ import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-service-icon";
import "../../../../components/ha-tooltip";
import {
@@ -288,15 +289,12 @@ export default class HaAutomationActionRow extends LitElement {
</ha-tooltip>`
: nothing}
<ha-md-button-menu
quick
<ha-dropdown
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
anchor-corner="end-end"
menu-corner="start-end"
@wa-select=${this._handleDropdownSelect}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
@@ -304,30 +302,24 @@ export default class HaAutomationActionRow extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item .clickAction=${this._runAction}>
<ha-svg-icon slot="start" .path=${mdiPlay}></ha-svg-icon>
<ha-dropdown-item value="run">
<ha-svg-icon slot="icon" .path=${mdiPlay}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize("ui.panel.config.automation.editor.actions.run")
)}
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._renameAction}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="rename" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)
)}
</ha-md-menu-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<ha-md-menu-item
.clickAction=${this._duplicateAction}
.disabled=${this.disabled}
>
</ha-dropdown-item>
<wa-divider></wa-divider>
<ha-dropdown-item value="duplicate" .disabled=${this.disabled}>
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
@@ -336,13 +328,10 @@ export default class HaAutomationActionRow extends LitElement {
"ui.panel.config.automation.editor.actions.duplicate"
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
.clickAction=${this._copyAction}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
<ha-dropdown-item value="copy" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
@@ -351,7 +340,6 @@ export default class HaAutomationActionRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -362,13 +350,10 @@ export default class HaAutomationActionRow extends LitElement {
<span>C</span>
</span>`
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
.clickAction=${this._cutAction}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
<ha-dropdown-item value="cut" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
@@ -377,7 +362,6 @@ export default class HaAutomationActionRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -388,51 +372,48 @@ export default class HaAutomationActionRow extends LitElement {
<span>X</span>
</span>`
)}
</ha-md-menu-item>
</ha-dropdown-item>
${!this.optionsInSidebar
? html`
<ha-md-menu-item
.clickAction=${this._moveUp}
<ha-dropdown-item
value="move_up"
.disabled=${this.disabled || !!this.first}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_up"
)}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
<ha-svg-icon slot="icon" .path=${mdiArrowUp}></ha-svg-icon
></ha-dropdown-item>
<ha-dropdown-item
value="move_down"
.disabled=${this.disabled || !!this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="icon" .path=${mdiArrowDown}></ha-svg-icon
></ha-dropdown-item>
`
: nothing}
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
<ha-dropdown-item
value="toggle_yaml_mode"
.disabled=${!this._uiModeAvailable || !!this._warnings}
>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<wa-divider></wa-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
<ha-dropdown-item value="disable" .disabled=${this.disabled}>
<ha-svg-icon
slot="start"
slot="icon"
.path=${this.action.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
@@ -443,15 +424,15 @@ export default class HaAutomationActionRow extends LitElement {
`ui.panel.config.automation.editor.actions.${this.action.enabled === false ? "enable" : "disable"}`
)
)}
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
.clickAction=${this._onDelete}
</ha-dropdown-item>
<ha-dropdown-item
value="delete"
variant="danger"
.disabled=${this.disabled}
>
<ha-svg-icon
class="warning"
slot="start"
slot="icon"
.path=${mdiDelete}
></ha-svg-icon>
@@ -463,7 +444,6 @@ export default class HaAutomationActionRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -478,8 +458,8 @@ export default class HaAutomationActionRow extends LitElement {
>
</span>`
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dropdown-item>
</ha-dropdown>
${!this.optionsInSidebar
? html`${this._warnings
@@ -890,6 +870,47 @@ export default class HaAutomationActionRow extends LitElement {
this._automationRowElement?.focus();
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "run":
this._runAction();
break;
case "rename":
this._renameAction();
break;
case "duplicate":
this._duplicateAction();
break;
case "copy":
this._copyAction();
break;
case "cut":
this._cutAction();
break;
case "move_up":
this._moveUp();
break;
case "move_down":
this._moveDown();
break;
case "toggle_yaml_mode":
this._toggleYamlMode(ev.target as HTMLElement);
break;
case "disable":
this._onDisable();
break;
case "delete":
this._onDelete();
break;
}
}
static styles = [rowStyles, overflowStyles];
}

View File

@@ -64,8 +64,15 @@ export class HaStopAction extends LitElement implements ActionElement {
private _responseChanged(ev: Event) {
ev.stopPropagation();
const newAction = { ...this.action };
const newValue = (ev.target as any).value;
if (newValue) {
newAction.response_variable = newValue;
} else {
delete newAction.response_variable;
}
fireEvent(this, "value-changed", {
value: { ...this.action, response_variable: (ev.target as any).value },
value: newAction,
});
}

View File

@@ -97,7 +97,7 @@ import {
fetchIntegrationManifests,
} from "../../../data/integration";
import type { LabelRegistryEntry } from "../../../data/label_registry";
import { subscribeLabFeatures } from "../../../data/labs";
import { subscribeLabFeature } from "../../../data/labs";
import {
TARGET_SEPARATOR,
getConditionsForTarget,
@@ -228,7 +228,7 @@ class DialogAddAutomationElement
private _unsub?: Promise<UnsubscribeFunc>;
private _unsubscribeLabFeatures?: UnsubscribeFunc;
private _unsubscribeLabFeatures?: Promise<UnsubscribeFunc>;
private _configEntryLookup: Record<string, ConfigEntry> = {};
@@ -281,15 +281,12 @@ class DialogAddAutomationElement
this._fetchManifests();
this._calculateUsedDomains();
this._unsubscribeLabFeatures = subscribeLabFeatures(
this._unsubscribeLabFeatures = subscribeLabFeature(
this.hass.connection,
(features) => {
this._newTriggersAndConditions =
features.find(
(feature) =>
feature.domain === "automation" &&
feature.preview_feature === "new_triggers_conditions"
)?.enabled ?? false;
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
this._tab = this._newTriggersAndConditions ? "targets" : "groups";
}
);
@@ -425,7 +422,7 @@ class DialogAddAutomationElement
this._unsub = undefined;
}
if (this._unsubscribeLabFeatures) {
this._unsubscribeLabFeatures();
this._unsubscribeLabFeatures.then((unsub) => unsub());
this._unsubscribeLabFeatures = undefined;
}
}
@@ -686,6 +683,7 @@ class DialogAddAutomationElement
<ha-automation-add-items
.hass=${this.hass}
.items=${this._getItems()}
.scrollable=${!this._narrow}
.error=${this._tab === "targets" && this._loadItemsError
? this.hass.localize(
"ui.panel.config.automation.editor.load_target_items_failed"
@@ -2146,7 +2144,7 @@ class DialogAddAutomationElement
min-height: 160px;
}
.content.column ha-automation-add-from-target {
overflow: hidden;
overflow: clip;
}
ha-wa-dialog ha-automation-add-items {

View File

@@ -911,6 +911,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
const services: Record<string, Level3Entries> = {};
unassignedDevices.forEach(({ id: deviceId, entry_type }) => {
const device = this.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
const deviceEntry = {
open: false,
entities:
@@ -1012,6 +1016,10 @@ export default class HaAutomationAddFromTarget extends LitElement {
const devices: Record<string, Level3Entries> = {};
referenced_devices.forEach(({ id: deviceId }) => {
const device = this.devices[deviceId];
if (!device || device.disabled_by) {
return;
}
devices[deviceId] = {
open: false,
entities:

View File

@@ -60,6 +60,8 @@ export class HaAutomationAddItems extends LitElement {
@property({ type: Boolean, attribute: "tooltip-description" })
public tooltipDescription = false;
@property({ type: Boolean, reflect: true }) scrollable = false;
@state() private _itemsScrolled = false;
@query(".items")
@@ -260,11 +262,12 @@ export class HaAutomationAddItems extends LitElement {
:host {
display: flex;
}
:host([scrollable]) .items {
overflow: auto;
}
.items {
display: flex;
flex-direction: column;
overflow: auto;
flex: 1;
}
.items.blank {

View File

@@ -2,7 +2,6 @@ import type { LitVirtualizer } from "@lit-labs/virtualizer";
import { consume } from "@lit/context";
import "@material/mwc-list/mwc-list";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import {
@@ -34,6 +33,7 @@ import "../../../../components/ha-section-title";
import "../../../../components/ha-tree-indicator";
import { ACTION_BUILDING_BLOCKS_GROUP } from "../../../../data/action";
import {
areaFloorComboBoxKeys,
getAreasAndFloors,
type AreaFloorValue,
type FloorComboBoxItem,
@@ -42,23 +42,30 @@ import { CONDITION_BUILDING_BLOCKS_GROUP } from "../../../../data/condition";
import type { ConfigEntry } from "../../../../data/config_entries";
import { labelsContext } from "../../../../data/context";
import {
deviceComboBoxKeys,
getDevices,
type DevicePickerItem,
} from "../../../../data/device_registry";
import {
entityComboBoxKeys,
getEntities,
type EntityComboBoxItem,
} from "../../../../data/entity_registry";
import type { DomainManifestLookup } from "../../../../data/integration";
import {
getLabels,
labelComboBoxKeys,
type LabelRegistryEntry,
} from "../../../../data/label_registry";
import {
getTargetComboBoxItemType,
TARGET_SEPARATOR,
} from "../../../../data/target";
import { HaFuse } from "../../../../resources/fuse";
import {
multiTermSearch,
multiTermSortedSearch,
type FuseWeightedKey,
} from "../../../../resources/fuseMultiTerm";
import { loadVirtualizer } from "../../../../resources/virtualizer";
import type { HomeAssistant } from "../../../../types";
import type {
@@ -76,6 +83,17 @@ const TARGET_SEARCH_SECTIONS = [
"label",
] as const;
export const ITEM_SEARCH_KEYS: FuseWeightedKey[] = [
{
name: "name",
weight: 10,
},
{
name: "description",
weight: 7,
},
];
type SearchSection = "item" | "block" | "entity" | "device" | "area" | "label";
@customElement("ha-automation-add-search")
@@ -434,27 +452,27 @@ export class HaAutomationAddSearch extends LitElement {
private _keyFunction = (item: PickerComboBoxItem | string) =>
typeof item === "string" ? item : item.id;
private _createFuseIndex = (states) =>
Fuse.createIndex(["search_labels"], states);
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
Fuse.createIndex(keys, states);
private _fuseIndexes = {
area: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, areaFloorComboBoxKeys)
),
entity: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, entityComboBoxKeys)
),
device: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, deviceComboBoxKeys)
),
label: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, labelComboBoxKeys)
),
item: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, ITEM_SEARCH_KEYS)
),
block: memoizeOne((states: PickerComboBoxItem[]) =>
this._createFuseIndex(states)
this._createFuseIndex(states, ITEM_SEARCH_KEYS)
),
};
@@ -478,11 +496,12 @@ export class HaAutomationAddSearch extends LitElement {
if (!selectedSection || selectedSection === "item") {
let items = this._convertItemsToComboBoxItems(automationItems, type);
if (searchTerm) {
items = this._filterGroup("item", items, searchTerm, {
ignoreLocation: true,
includeScore: true,
minMatchCharLength: Math.min(2, this.filter.length),
}) as AutomationItemComboBoxItem[];
items = this._filterGroup(
"item",
items,
searchTerm,
ITEM_SEARCH_KEYS
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && items.length) {
@@ -514,11 +533,12 @@ export class HaAutomationAddSearch extends LitElement {
);
if (searchTerm) {
blocks = this._filterGroup("block", blocks, searchTerm, {
ignoreLocation: true,
includeScore: true,
minMatchCharLength: Math.min(2, this.filter.length),
}) as AutomationItemComboBoxItem[];
blocks = this._filterGroup(
"block",
blocks,
searchTerm,
ITEM_SEARCH_KEYS
) as AutomationItemComboBoxItem[];
}
if (!selectedSection && blocks.length) {
@@ -550,9 +570,7 @@ export class HaAutomationAddSearch extends LitElement {
"entity",
entityItems,
searchTerm,
undefined,
(item: EntityComboBoxItem) =>
item.stateObj?.entity_id === searchTerm
entityComboBoxKeys
) as EntityComboBoxItem[];
}
@@ -581,7 +599,12 @@ export class HaAutomationAddSearch extends LitElement {
);
if (searchTerm) {
deviceItems = this._filterGroup("device", deviceItems, searchTerm);
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys
);
}
if (!selectedSection && deviceItems.length) {
@@ -617,7 +640,9 @@ export class HaAutomationAddSearch extends LitElement {
areasAndFloors = this._filterGroup(
"area",
areasAndFloors,
searchTerm
searchTerm,
areaFloorComboBoxKeys,
false
) as FloorComboBoxItem[];
}
@@ -664,7 +689,12 @@ export class HaAutomationAddSearch extends LitElement {
);
if (searchTerm) {
labels = this._filterGroup("label", labels, searchTerm);
labels = this._filterGroup(
"label",
labels,
searchTerm,
labelComboBoxKeys
);
}
if (!selectedSection && labels.length) {
@@ -691,50 +721,28 @@ export class HaAutomationAddSearch extends LitElement {
| AutomationItemComboBoxItem
)[],
searchTerm: string,
fuseOptions?: IFuseOptions<any>,
checkExact?: (
item:
| FloorComboBoxItem
| PickerComboBoxItem
| EntityComboBoxItem
| AutomationItemComboBoxItem
) => boolean
searchKeys: FuseWeightedKey[],
sort = true
) {
const fuseIndex = this._fuseIndexes[type](items);
const fuse = new HaFuse<
| FloorComboBoxItem
| PickerComboBoxItem
| EntityComboBoxItem
| AutomationItemComboBoxItem
>(
if (sort) {
return multiTermSortedSearch<PickerComboBoxItem>(
items,
searchTerm,
searchKeys,
(item) => item.id,
fuseIndex
);
}
return multiTermSearch<PickerComboBoxItem>(
items,
fuseOptions || {
shouldSort: false,
minMatchCharLength: Math.min(searchTerm.length, 2),
},
fuseIndex
searchTerm,
searchKeys,
fuseIndex,
{ ignoreLocation: true }
);
const results = fuse.multiTermsSearch(searchTerm);
let filteredItems = items;
if (results) {
filteredItems = results.map((result) => result.item);
}
if (!checkExact) {
return filteredItems;
}
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex((item) => checkExact(item));
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
}
private _toggleSection(ev: Event) {
@@ -787,7 +795,11 @@ export class HaAutomationAddSearch extends LitElement {
iconPath,
renderedIcon: icon,
type,
search_labels: [key, name, description],
search_labels: {
key,
name,
description,
},
}));
private _focusSearchList = () => {

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAppleKeyboardCommand,
@@ -33,11 +34,11 @@ import "../../../../components/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-condition-icon";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import type {
AutomationClipboard,
Condition,
@@ -194,15 +195,12 @@ export default class HaAutomationConditionRow extends LitElement {
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
quick
<ha-dropdown
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
anchor-corner="end-end"
menu-corner="start-end"
@wa-select=${this._handleDropdownSelect}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
@@ -211,34 +209,28 @@ export default class HaAutomationConditionRow extends LitElement {
>
</ha-icon-button>
<ha-md-menu-item .clickAction=${this._testCondition}>
<ha-svg-icon slot="start" .path=${mdiFlask}></ha-svg-icon>
<ha-dropdown-item value="test">
<ha-svg-icon slot="icon" .path=${mdiFlask}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
)
)}
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._renameCondition}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item value="rename" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.conditions.rename"
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<wa-divider></wa-divider>
<ha-md-menu-item
.clickAction=${this._duplicateCondition}
.disabled=${this.disabled}
>
<ha-dropdown-item value="duplicate" .disabled=${this.disabled}>
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
${this._renderOverflowLabel(
@@ -246,13 +238,10 @@ export default class HaAutomationConditionRow extends LitElement {
"ui.panel.config.automation.editor.actions.duplicate"
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
.clickAction=${this._copyCondition}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon
<ha-dropdown-item value="copy" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon
>${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
@@ -261,7 +250,6 @@ export default class HaAutomationConditionRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -272,13 +260,10 @@ export default class HaAutomationConditionRow extends LitElement {
<span>C</span>
</span>`
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
.clickAction=${this._cutCondition}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon
<ha-dropdown-item value="cut" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon
>${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
@@ -287,7 +272,6 @@ export default class HaAutomationConditionRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -298,48 +282,45 @@ export default class HaAutomationConditionRow extends LitElement {
<span>X</span>
</span>`
)}
</ha-md-menu-item>
</ha-dropdown-item>
${!this.optionsInSidebar
? html`
<ha-md-menu-item
.clickAction=${this._moveUp}
.disabled=${this.disabled || this.first}
<ha-dropdown-item
value="move_up"
.disabled=${this.disabled || !!this.first}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_up"
)}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
.disabled=${this.disabled || this.last}
<ha-svg-icon slot="icon" .path=${mdiArrowUp}></ha-svg-icon
></ha-dropdown-item>
<ha-dropdown-item
value="move_down"
.disabled=${this.disabled || !!this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="icon" .path=${mdiArrowDown}></ha-svg-icon
></ha-dropdown-item>
`
: nothing}
<ha-md-menu-item .clickAction=${this._toggleYamlMode}>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-dropdown-item value="toggle_yaml_mode">
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<wa-divider></wa-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
.disabled=${this.disabled}
>
<ha-dropdown-item value="disable" .disabled=${this.disabled}>
<ha-svg-icon
slot="start"
slot="icon"
.path=${this.condition.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
@@ -350,15 +331,15 @@ export default class HaAutomationConditionRow extends LitElement {
`ui.panel.config.automation.editor.actions.${this.condition.enabled === false ? "enable" : "disable"}`
)
)}
</ha-md-menu-item>
<ha-md-menu-item
class="warning"
.clickAction=${this._onDelete}
</ha-dropdown-item>
<ha-dropdown-item
variant="danger"
value="delete"
.disabled=${this.disabled}
>
<ha-svg-icon
class="warning"
slot="start"
slot="icon"
.path=${mdiDelete}
></ha-svg-icon>
${this._renderOverflowLabel(
@@ -369,7 +350,6 @@ export default class HaAutomationConditionRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -384,8 +364,8 @@ export default class HaAutomationConditionRow extends LitElement {
>
</span>`
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dropdown-item>
</ha-dropdown>
${!this.optionsInSidebar
? html`${this._warnings
? html`<ha-automation-editor-warning
@@ -837,6 +817,47 @@ export default class HaAutomationConditionRow extends LitElement {
this._automationRowElement?.focus();
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "test":
this._testCondition();
break;
case "rename":
this._renameCondition();
break;
case "duplicate":
this._duplicateCondition();
break;
case "copy":
this._copyCondition();
break;
case "cut":
this._cutCondition();
break;
case "move_up":
this._moveUp();
break;
case "move_down":
this._moveDown();
break;
case "toggle_yaml_mode":
this._toggleYamlMode(ev.target as HTMLElement);
break;
case "disable":
this._onDisable();
break;
case "delete":
this._onDelete();
break;
}
}
static get styles(): CSSResultGroup {
return [
rowStyles,

View File

@@ -28,7 +28,7 @@ import {
CONDITION_BUILDING_BLOCKS,
subscribeConditions,
} from "../../../../data/condition";
import { subscribeLabFeatures } from "../../../../data/labs";
import { subscribeLabFeature } from "../../../../data/labs";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../../../types";
import {
@@ -90,14 +90,14 @@ export default class HaAutomationCondition extends SubscribeMixin(LitElement) {
protected hassSubscribe() {
return [
subscribeLabFeatures(this.hass!.connection, (features) => {
this._newTriggersAndConditions =
features.find(
(feature) =>
feature.domain === "automation" &&
feature.preview_feature === "new_triggers_conditions"
)?.enabled ?? false;
}),
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAppleKeyboardCommand,
@@ -25,18 +26,20 @@ import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { transform } from "../../../common/decorators/transform";
import { fireEvent } from "../../../common/dom/fire_event";
import { goBack, navigate } from "../../../common/navigate";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../components/ha-dropdown-item";
import "../../../components/ha-fab";
import "../../../components/ha-fade-in";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import "../../../components/ha-yaml-editor";
@@ -74,7 +77,6 @@ import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info
import "../../../layouts/hass-subpage";
import { KeyboardShortcutMixin } from "../../../mixins/keyboard-shortcut-mixin";
import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { haStyle } from "../../../resources/styles";
import type { Entries, HomeAssistant, Route } from "../../../types";
import { isMac } from "../../../util/is_mac";
@@ -86,10 +88,10 @@ import {
type EntityRegistryUpdate,
showAutomationSaveDialog,
} from "./automation-save-dialog/show-dialog-automation-save";
import { showAutomationSaveTimeoutDialog } from "./automation-save-timeout-dialog/show-dialog-automation-save-timeout";
import "./blueprint-automation-editor";
import "./manual-automation-editor";
import type { HaManualAutomationEditor } from "./manual-automation-editor";
import { showAutomationSaveTimeoutDialog } from "./automation-save-timeout-dialog/show-dialog-automation-save-timeout";
declare global {
interface HTMLElementTagNameMap {
@@ -292,7 +294,10 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
</ha-button>
`
: ""}
<ha-button-menu slot="toolbar-icon">
<ha-dropdown
slot="toolbar-icon"
@wa-select=${this._handleDropdownSelect}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
@@ -300,99 +305,73 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
></ha-icon-button>
${this._mode === "gui" && this.narrow
? html`<ha-list-item
graphic="icon"
@click=${this._undo}
? html`<ha-dropdown-item
value="undo"
.disabled=${!this._undoRedoController.canUndo}
>
${this.hass.localize("ui.common.undo")}
<ha-svg-icon slot="graphic" .path=${mdiUndo}></ha-svg-icon>
</ha-list-item>
<ha-list-item
graphic="icon"
@click=${this._redo}
<ha-svg-icon slot="icon" .path=${mdiUndo}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item
value="redo"
.disabled=${!this._undoRedoController.canRedo}
>
${this.hass.localize("ui.common.redo")}
<ha-svg-icon slot="graphic" .path=${mdiRedo}></ha-svg-icon>
</ha-list-item>`
<ha-svg-icon slot="icon" .path=${mdiRedo}></ha-svg-icon>
</ha-dropdown-item>`
: nothing}
<ha-list-item
graphic="icon"
.disabled=${!stateObj}
@click=${this._showInfo}
>
<ha-dropdown-item .disabled=${!stateObj} value="info">
${this.hass.localize("ui.panel.config.automation.editor.show_info")}
<ha-svg-icon
slot="graphic"
slot="icon"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
</ha-dropdown-item>
<ha-list-item
graphic="icon"
.disabled=${!stateObj}
@click=${this._showSettings}
>
<ha-dropdown-item .disabled=${!stateObj} value="settings">
${this.hass.localize(
"ui.panel.config.automation.picker.show_settings"
)}
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="icon" .path=${mdiCog}></ha-svg-icon>
</ha-dropdown-item>
<ha-list-item
graphic="icon"
.disabled=${!stateObj}
@click=${this._editCategory}
>
<ha-dropdown-item .disabled=${!stateObj} value="category">
${this.hass.localize(
`ui.panel.config.scene.picker.${this._registryEntry?.categories?.automation ? "edit_category" : "assign_category"}`
)}
<ha-svg-icon slot="graphic" .path=${mdiTag}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="icon" .path=${mdiTag}></ha-svg-icon>
</ha-dropdown-item>
<ha-list-item
graphic="icon"
.disabled=${!stateObj}
@click=${this._runActions}
>
<ha-dropdown-item .disabled=${!stateObj} value="run">
${this.hass.localize("ui.panel.config.automation.editor.run")}
<ha-svg-icon slot="graphic" .path=${mdiPlay}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="icon" .path=${mdiPlay}></ha-svg-icon>
</ha-dropdown-item>
${stateObj && this.narrow
? html`<a
href="/config/automation/trace/${encodeURIComponent(
this._config.id!
)}"
>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.editor.show_trace"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiTransitConnection}
></ha-svg-icon>
</ha-list-item>
</a>`
? html`<ha-dropdown-item value="trace">
${this.hass.localize(
"ui.panel.config.automation.editor.show_trace"
)}
<ha-svg-icon
slot="icon"
.path=${mdiTransitConnection}
></ha-svg-icon>
</ha-dropdown-item>`
: nothing}
<ha-list-item
graphic="icon"
@click=${this._promptAutomationAlias}
<ha-dropdown-item
value="rename"
.disabled=${this._readOnly ||
!this.automationId ||
this._mode === "yaml"}
>
${this.hass.localize("ui.panel.config.automation.editor.rename")}
<ha-svg-icon slot="graphic" .path=${mdiRenameBox}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
</ha-dropdown-item>
${!useBlueprint
? html`
<ha-list-item
graphic="icon"
<ha-dropdown-item
@click=${this._promptAutomationMode}
.disabled=${this._readOnly || this._mode === "yaml"}
>
@@ -400,18 +379,17 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
"ui.panel.config.automation.editor.change_mode"
)}
<ha-svg-icon
slot="graphic"
slot="icon"
.path=${mdiDebugStepOver}
></ha-svg-icon>
</ha-list-item>
</ha-dropdown-item>
`
: nothing}
<ha-list-item
.disabled=${this._blueprintConfig ||
<ha-dropdown-item
.disabled=${!!this._blueprintConfig ||
(!this._readOnly && !this.automationId)}
graphic="icon"
@click=${this._duplicate}
value="duplicate"
>
${this.hass.localize(
this._readOnly
@@ -419,74 +397,60 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
: "ui.panel.config.automation.editor.duplicate"
)}
<ha-svg-icon
slot="graphic"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
</ha-list-item>
</ha-dropdown-item>
${useBlueprint
? html`
<ha-list-item
graphic="icon"
@click=${this._takeControl}
<ha-dropdown-item
value="take_control"
.disabled=${this._readOnly}
>
${this.hass.localize(
"ui.panel.config.automation.editor.take_control"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiFileEdit}
></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="icon" .path=${mdiFileEdit}></ha-svg-icon>
</ha-dropdown-item>
`
: nothing}
<ha-list-item
graphic="icon"
@click=${this._mode === "gui"
? this._switchYamlMode
: this._switchUiMode}
>
<ha-dropdown-item value="toggle_yaml_mode">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${this._mode === "gui" ? "yaml" : "ui"}`
)}
<ha-svg-icon slot="graphic" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
</ha-dropdown-item>
<li divider role="separator"></li>
<wa-divider></wa-divider>
<ha-list-item
graphic="icon"
.disabled=${!stateObj}
@click=${this._toggle}
>
<ha-dropdown-item .disabled=${!stateObj} value="disable">
${stateObj?.state === "off"
? this.hass.localize("ui.panel.config.automation.editor.enable")
: this.hass.localize("ui.panel.config.automation.editor.disable")}
<ha-svg-icon
slot="graphic"
slot="icon"
.path=${stateObj?.state === "off"
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-list-item>
</ha-dropdown-item>
<ha-list-item
<ha-dropdown-item
.disabled=${!this.automationId}
class=${classMap({ warning: Boolean(this.automationId) })}
graphic="icon"
@click=${this._deleteConfirm}
.variant=${this.automationId ? "danger" : "default"}
value="delete"
>
${this.hass.localize("ui.panel.config.automation.picker.delete")}
<ha-svg-icon
class=${classMap({ warning: Boolean(this.automationId) })}
slot="graphic"
slot="icon"
.path=${mdiDelete}
>
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>
</ha-dropdown-item>
</ha-dropdown>
<div
class=${this._mode === "yaml" ? "yaml-mode" : ""}
@subscribe-automation-config=${this._subscribeAutomationConfig}
@@ -1249,6 +1213,63 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
this._undoRedoController.redo();
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "undo":
this._undo();
break;
case "redo":
this._redo();
break;
case "info":
this._showInfo();
break;
case "settings":
this._showSettings();
break;
case "category":
this._editCategory();
break;
case "run":
this._runActions();
break;
case "rename":
this._promptAutomationAlias();
break;
case "change_mode":
this._promptAutomationMode();
break;
case "duplicate":
this._duplicate();
break;
case "take_control":
this._takeControl();
break;
case "toggle_yaml_mode":
if (this._mode === "gui") {
this._switchYamlMode();
break;
}
this._switchUiMode();
break;
case "disable":
this._toggle();
break;
case "delete":
this._deleteConfirm();
break;
case "trace":
this._showTrace();
break;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -1301,13 +1322,6 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
margin-inline-end: 8px;
margin-inline-start: initial;
}
li[role="separator"] {
border-bottom-color: var(--divider-color);
}
ha-button-menu a {
text-decoration: none;
color: var(--primary-color);
}
h1 {
margin: 0;
}

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiDotsVertical,
mdiDownload,
@@ -15,11 +16,13 @@ import { repeat } from "lit/directives/repeat";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { formatDateTimeWithSeconds } from "../../../common/datetime/format_date_time";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
import "../../../components/ha-button-menu";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-list-item";
import "../../../components/trace/ha-trace-blueprint-config";
import "../../../components/trace/ha-trace-config";
import "../../../components/trace/ha-trace-logbook";
@@ -104,9 +107,7 @@ export class HaAutomationTrace extends LitElement {
appearance="plain"
size="small"
class="trace-link"
href="/config/automation/edit/${encodeURIComponent(
stateObj.attributes.id
)}"
@click=${this._navigateToAutomation}
slot="toolbar-icon"
>
${this.hass.localize(
@@ -114,65 +115,50 @@ export class HaAutomationTrace extends LitElement {
)}
</ha-button>
`
: ""}
<ha-button-menu slot="toolbar-icon">
: nothing}
<ha-dropdown
slot="toolbar-icon"
@wa-select=${this._handleDropdownSelect}
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item
graphic="icon"
.disabled=${!stateObj}
@click=${this._showInfo}
>
<ha-dropdown-item .disabled=${!stateObj} value="show_info">
${this.hass.localize("ui.panel.config.automation.editor.show_info")}
<ha-svg-icon
slot="graphic"
slot="icon"
.path=${mdiInformationOutline}
></ha-svg-icon>
</ha-list-item>
</ha-dropdown-item>
${stateObj?.attributes.id && this.narrow
? html`
<a
class="trace-link"
href="/config/automation/edit/${encodeURIComponent(
stateObj.attributes.id
)}"
>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.config.automation.trace.edit_automation"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPencil}
></ha-svg-icon>
</ha-list-item>
</a>
<ha-dropdown-item value="edit_automation">
${this.hass.localize(
"ui.panel.config.automation.trace.edit_automation"
)}
<ha-svg-icon slot="icon" .path=${mdiPencil}></ha-svg-icon>
</ha-dropdown-item>
`
: ""}
: nothing}
<li divider role="separator"></li>
<wa-divider></wa-divider>
<ha-list-item graphic="icon" @click=${this._refreshTraces}>
<ha-dropdown-item value="refresh">
${this.hass.localize("ui.panel.config.automation.trace.refresh")}
<ha-svg-icon slot="graphic" .path=${mdiRefresh}></ha-svg-icon>
</ha-list-item>
<ha-svg-icon slot="icon" .path=${mdiRefresh}></ha-svg-icon>
</ha-dropdown-item>
<ha-list-item
graphic="icon"
.disabled=${!this._trace}
@click=${this._downloadTrace}
>
<ha-dropdown-item .disabled=${!this._trace} value="download_trace">
${this.hass.localize(
"ui.panel.config.automation.trace.download_trace"
)}
<ha-svg-icon slot="graphic" .path=${mdiDownload}></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class="toolbar">
${this._traces && this._traces.length > 0
@@ -520,6 +506,37 @@ export class HaAutomationTrace extends LitElement {
fireEvent(this, "hass-more-info", { entityId: this._entityId });
}
private _navigateToAutomation() {
if (this._entityId && this.hass.states[this._entityId]) {
navigate(
`/config/automation/edit/${encodeURIComponent(this.hass.states[this._entityId].attributes.id)}`
);
}
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "show_info":
this._showInfo();
break;
case "refresh":
this._refreshTraces();
break;
case "download_trace":
this._downloadTrace();
break;
case "edit_automation":
this._navigateToAutomation();
break;
}
}
static get styles(): CSSResultGroup {
return [
haStyle,

View File

@@ -20,10 +20,11 @@ import { capitalizeFirstLetter } from "../../../../common/string/capitalize-firs
import "../../../../components/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-svg-icon";
import type {
Condition,
@@ -36,6 +37,7 @@ import type { Action, Option } from "../../../../data/script";
import { showPromptDialog } from "../../../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showToast } from "../../../../util/toast";
import "../action/ha-automation-action";
import type HaAutomationAction from "../action/ha-automation-action";
import "../condition/ha-automation-condition";
@@ -46,7 +48,6 @@ import {
overflowStyles,
rowStyles,
} from "../styles";
import { showToast } from "../../../../util/toast";
@customElement("ha-automation-option-row")
export default class HaAutomationOptionRow extends LitElement {
@@ -155,15 +156,12 @@ export default class HaAutomationOptionRow extends LitElement {
${this.option
? html`
<ha-md-button-menu
quick
<ha-dropdown
slot="icons"
@click=${preventDefaultStopPropagation}
@closed=${stopPropagation}
@keydown=${stopPropagation}
positioning="fixed"
anchor-corner="end-end"
menu-corner="start-end"
@wa-select=${this._handleDropdownSelect}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
@@ -171,24 +169,18 @@ export default class HaAutomationOptionRow extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
@click=${this._renameOption}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
<ha-dropdown-item value="rename" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
@click=${this._duplicateOption}
.disabled=${this.disabled}
>
<ha-dropdown-item value="duplicate" .disabled=${this.disabled}>
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
@@ -197,45 +189,42 @@ export default class HaAutomationOptionRow extends LitElement {
"ui.panel.config.automation.editor.actions.duplicate"
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
${!this.optionsInSidebar
? html`
<ha-md-menu-item
.clickAction=${this._moveUp}
<ha-dropdown-item
value="move_up"
.disabled=${this.disabled || !!this.first}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_up"
)}
<ha-svg-icon
slot="start"
.path=${mdiArrowUp}
></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
<ha-svg-icon slot="icon" .path=${mdiArrowUp}></ha-svg-icon
></ha-dropdown-item>
<ha-dropdown-item
value="move_down"
.disabled=${this.disabled || !!this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiArrowDown}
></ha-svg-icon
></ha-md-menu-item>
></ha-dropdown-item>
`
: nothing}
<ha-md-menu-item
@click=${this._removeOption}
class="warning"
<ha-dropdown-item
value="delete"
variant="danger"
.disabled=${this.disabled}
>
<ha-svg-icon
class="warning"
slot="start"
slot="icon"
.path=${mdiDelete}
></ha-svg-icon>
${this._renderOverflowLabel(
@@ -246,7 +235,6 @@ export default class HaAutomationOptionRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -261,8 +249,8 @@ export default class HaAutomationOptionRow extends LitElement {
>
</span>`
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dropdown-item>
</ha-dropdown>
`
: nothing}
${!this.optionsInSidebar ? this._renderContent() : nothing}
@@ -361,6 +349,32 @@ export default class HaAutomationOptionRow extends LitElement {
fireEvent(this, "move-down");
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "rename":
this._renameOption();
break;
case "delete":
this._removeOption();
break;
case "duplicate":
this._duplicateOption();
break;
case "move_up":
this._moveUp();
break;
case "move_down":
this._moveDown();
break;
}
}
private _removeOption = () => {
if (this.option) {
fireEvent(this, "value-changed", {
@@ -513,9 +527,6 @@ export default class HaAutomationOptionRow extends LitElement {
overflowStyles,
indentStyle,
css`
li[role="separator"] {
border-bottom-color: var(--divider-color);
}
h4 {
color: var(--ha-color-text-secondary);
}

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiAppleKeyboardCommand,
mdiContentCopy,
@@ -16,8 +17,8 @@ import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
import type { ActionSidebarConfig } from "../../../../data/automation";
import { domainToName } from "../../../../data/integration";
@@ -97,9 +98,9 @@ export default class HaAutomationSidebarAction extends LitElement {
title = `${domainToName(this.hass.localize, domain)}: ${
this.hass.localize(
`component.${domain}.services.${service}.name`,
this.hass.services[domain][service].description_placeholders
this.hass.services[domain]?.[service]?.description_placeholders
) ||
this.hass.services[domain][service]?.name ||
this.hass.services[domain]?.[service]?.name ||
title
}`;
}
@@ -116,6 +117,7 @@ export default class HaAutomationSidebarAction extends LitElement {
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.narrow=${this.narrow}
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<span slot="subtitle"
@@ -126,38 +128,35 @@ export default class HaAutomationSidebarAction extends LitElement {
: ""}</span
>
<ha-md-menu-item slot="menu-items" .clickAction=${this.config.run}>
<ha-svg-icon slot="start" .path=${mdiPlay}></ha-svg-icon>
<ha-dropdown-item slot="menu-items" value="run">
<ha-svg-icon slot="icon" .path=${mdiPlay}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize("ui.panel.config.automation.editor.actions.run")}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.rename}
value="rename"
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-divider
</ha-dropdown-item>
<wa-divider slot="menu-items"></wa-divider>
<ha-dropdown-item
slot="menu-items"
role="separator"
tabindex="-1"
></ha-md-divider>
<ha-md-menu-item
slot="menu-items"
.clickAction=${this.config.duplicate}
value="duplicate"
.disabled=${this.disabled}
>
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
<div class="overflow-label">
@@ -166,9 +165,9 @@ export default class HaAutomationSidebarAction extends LitElement {
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item slot="menu-items" .clickAction=${this.config.copy}>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
</ha-dropdown-item>
<ha-dropdown-item slot="menu-items" value="copy">
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
@@ -178,7 +177,6 @@ export default class HaAutomationSidebarAction extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -190,13 +188,13 @@ export default class HaAutomationSidebarAction extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.cut}
value="cut"
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
@@ -206,7 +204,6 @@ export default class HaAutomationSidebarAction extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -218,32 +215,29 @@ export default class HaAutomationSidebarAction extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this._toggleYamlMode}
value="toggle_yaml_mode"
.disabled=${!this.config.uiSupported || !!this._warnings}
>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-divider
</ha-dropdown-item>
<wa-divider slot="menu-items"></wa-divider>
<ha-dropdown-item
slot="menu-items"
role="separator"
tabindex="-1"
></ha-md-divider>
<ha-md-menu-item
slot="menu-items"
.clickAction=${this.config.disable}
value="disable"
.disabled=${this.disabled}
>
<ha-svg-icon
slot="start"
slot="icon"
.path=${rowDisabled ? mdiPlayCircleOutline : mdiStopCircleOutline}
></ha-svg-icon>
<div class="overflow-label">
@@ -252,14 +246,14 @@ export default class HaAutomationSidebarAction extends LitElement {
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.delete}
value="delete"
variant="danger"
.disabled=${this.disabled}
class="warning"
>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
@@ -269,7 +263,6 @@ export default class HaAutomationSidebarAction extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -285,7 +278,7 @@ export default class HaAutomationSidebarAction extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
${description && !this.yamlMode
? html`<div class="description">${description}</div>`
: keyed(
@@ -341,6 +334,41 @@ export default class HaAutomationSidebarAction extends LitElement {
fireEvent(this, "toggle-yaml-mode");
};
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "rename":
this.config.rename();
break;
case "run":
this.config.run();
break;
case "duplicate":
this.config.duplicate();
break;
case "copy":
this.config.copy();
break;
case "cut":
this.config.cut();
break;
case "toggle_yaml_mode":
this._toggleYamlMode();
break;
case "disable":
this.config.disable();
break;
case "delete":
this.config.delete();
break;
}
}
static styles = [sidebarEditorStyles, overflowStyles];
}

View File

@@ -3,16 +3,16 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-card";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import { ScrollableFadeMixin } from "../../../../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
import { ScrollableFadeMixin } from "../../../../mixins/scrollable-fade-mixin";
export interface SidebarOverflowMenuEntry {
clickAction: () => void;
@@ -36,6 +36,10 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
@property({ attribute: false }) public warnings?: string[];
@property({ attribute: false }) public handleDropdownSelect!: (
ev: CustomEvent
) => void;
@property({ type: Boolean }) public narrow = false;
@query(".card-content") private _contentElement!: HTMLDivElement;
@@ -63,14 +67,10 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
<slot slot="title" name="title"></slot>
<slot slot="subtitle" name="subtitle"></slot>
<slot name="overflow-menu" slot="actionItems">
<ha-md-button-menu
quick
@click=${this._openOverflowMenu}
<ha-dropdown
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
.positioning=${this.narrow ? "absolute" : "fixed"}
anchor-corner="end-end"
menu-corner="start-end"
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
@@ -78,7 +78,7 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
.path=${mdiDotsVertical}
></ha-icon-button>
<slot name="menu-items"></slot>
</ha-md-button-menu>
</ha-dropdown>
</slot>
</ha-dialog-header>
${this.warnings
@@ -100,11 +100,6 @@ export default class HaAutomationSidebarCard extends ScrollableFadeMixin(
fireEvent(this, "close-sidebar");
}
private _openOverflowMenu(ev: MouseEvent) {
ev.stopPropagation();
ev.preventDefault();
}
static get styles() {
return [
...super.styles,

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiAppleKeyboardCommand,
mdiContentCopy,
@@ -16,9 +17,11 @@ import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import type {
LegacyCondition,
ConditionSidebarConfig,
LegacyCondition,
} from "../../../../data/automation";
import { testCondition } from "../../../../data/automation";
import {
@@ -117,6 +120,7 @@ export default class HaAutomationSidebarCondition extends LitElement {
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.narrow=${this.narrow}
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<span slot="subtitle"
@@ -124,42 +128,38 @@ export default class HaAutomationSidebarCondition extends LitElement {
? ` (${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")})`
: ""}</span
>
<ha-md-menu-item slot="menu-items" .clickAction=${this._testCondition}>
<ha-svg-icon slot="start" .path=${mdiFlask}></ha-svg-icon>
<ha-dropdown-item slot="menu-items" value="test">
<ha-svg-icon slot="icon" .path=${mdiFlask}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.test"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.rename}
value="rename"
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-divider
slot="menu-items"
role="separator"
tabindex="-1"
></ha-md-divider>
<wa-divider slot="menu-items"></wa-divider>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.duplicate}
value="duplicate"
.disabled=${this.disabled}
>
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
<div class="overflow-label">
@@ -168,10 +168,10 @@ export default class HaAutomationSidebarCondition extends LitElement {
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item slot="menu-items" .clickAction=${this.config.copy}>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
<ha-dropdown-item slot="menu-items" value="copy">
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
@@ -181,7 +181,6 @@ export default class HaAutomationSidebarCondition extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -193,14 +192,14 @@ export default class HaAutomationSidebarCondition extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.cut}
value="cut"
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
@@ -210,7 +209,6 @@ export default class HaAutomationSidebarCondition extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -222,32 +220,29 @@ export default class HaAutomationSidebarCondition extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this._toggleYamlMode}
value="toggle_yaml_mode"
.disabled=${!this.config.uiSupported || !!this._warnings}
>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-divider
</ha-dropdown-item>
<wa-divider slot="menu-items"></wa-divider>
<ha-dropdown-item
slot="menu-items"
role="separator"
tabindex="-1"
></ha-md-divider>
<ha-md-menu-item
slot="menu-items"
.clickAction=${this.config.disable}
value="disable"
.disabled=${this.disabled}
>
<ha-svg-icon
slot="start"
slot="icon"
.path=${rowDisabled ? mdiPlayCircleOutline : mdiStopCircleOutline}
></ha-svg-icon>
<div class="overflow-label">
@@ -256,14 +251,14 @@ export default class HaAutomationSidebarCondition extends LitElement {
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.delete}
value="delete"
variant="danger"
.disabled=${this.disabled}
class="warning"
>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
@@ -273,7 +268,6 @@ export default class HaAutomationSidebarCondition extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -289,7 +283,7 @@ export default class HaAutomationSidebarCondition extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
${description && !this.yamlMode
? html`<div class="description">${description}</div>`
: keyed(
@@ -419,6 +413,41 @@ export default class HaAutomationSidebarCondition extends LitElement {
fireEvent(this, "toggle-yaml-mode");
};
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "rename":
this.config.rename();
break;
case "test":
this._testCondition();
break;
case "duplicate":
this.config.duplicate();
break;
case "copy":
this.config.copy();
break;
case "cut":
this.config.cut();
break;
case "toggle_yaml_mode":
this._toggleYamlMode();
break;
case "disable":
this.config.disable();
break;
case "delete":
this.config.delete();
break;
}
}
static styles = [
sidebarEditorStyles,
overflowStyles,

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiAppleKeyboardCommand,
mdiDelete,
@@ -6,8 +7,9 @@ import {
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import type { OptionSidebarConfig } from "../../../../data/automation";
import type { HomeAssistant } from "../../../../types";
@@ -50,33 +52,34 @@ export default class HaAutomationSidebarOption extends LitElement {
.hass=${this.hass}
.isWide=${this.isWide}
.narrow=${this.narrow}
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
${this.config.defaultOption
? html`<span slot="overflow-menu"></span>`
: html`
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.rename}
value="rename"
.disabled=${!!disabled}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
@click=${this.config.duplicate}
value="duplicate"
.disabled=${this.disabled}
>
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
<div class="overflow-label">
@@ -85,19 +88,15 @@ export default class HaAutomationSidebarOption extends LitElement {
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-divider
</ha-dropdown-item>
<wa-divider slot="menu-items"></wa-divider>
<ha-dropdown-item
slot="menu-items"
role="separator"
tabindex="-1"
></ha-md-divider>
<ha-md-menu-item
slot="menu-items"
.clickAction=${this.config.delete}
value="delete"
.disabled=${this.disabled}
class="warning"
variant="danger"
>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.remove_option"
@@ -123,13 +122,33 @@ export default class HaAutomationSidebarOption extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
`}
<div class="description">${description}</div>
</ha-automation-sidebar-card>`;
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "rename":
this.config.rename();
break;
case "duplicate":
this.config.duplicate();
break;
case "delete":
this.config.delete();
break;
}
}
static styles = [sidebarEditorStyles, overflowStyles];
}

View File

@@ -4,6 +4,8 @@ import { customElement, property, query, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import type { ScriptFieldSidebarConfig } from "../../../../data/automation";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
@@ -62,29 +64,30 @@ export default class HaAutomationSidebarScriptFieldSelector extends LitElement {
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.narrow=${this.narrow}
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this._toggleYamlMode}
value="toggle_yaml_mode"
.disabled=${!!this._warnings}
>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.delete}
value="delete"
.disabled=${this.disabled}
class="warning"
variant="danger"
>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
@@ -94,7 +97,6 @@ export default class HaAutomationSidebarScriptFieldSelector extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -110,7 +112,7 @@ export default class HaAutomationSidebarScriptFieldSelector extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
${keyed(
this.sidebarKey,
html`<ha-script-field-selector-editor
@@ -160,6 +162,23 @@ export default class HaAutomationSidebarScriptFieldSelector extends LitElement {
fireEvent(this, "toggle-yaml-mode");
};
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "toggle_yaml_mode":
this._toggleYamlMode();
break;
case "delete":
this.config.delete();
break;
}
}
static styles = sidebarEditorStyles;
}

View File

@@ -3,6 +3,8 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import type { ScriptFieldSidebarConfig } from "../../../../data/automation";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
@@ -56,28 +58,29 @@ export default class HaAutomationSidebarScriptField extends LitElement {
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.narrow=${this.narrow}
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this._toggleYamlMode}
value="toggle_yaml_mode"
.disabled=${!!this._warnings}
>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.delete}
value="delete"
.disabled=${this.disabled}
class="warning"
variant="danger"
>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
@@ -87,7 +90,6 @@ export default class HaAutomationSidebarScriptField extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -103,7 +105,7 @@ export default class HaAutomationSidebarScriptField extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
${keyed(
this.sidebarKey,
html`<ha-script-field-editor
@@ -154,6 +156,23 @@ export default class HaAutomationSidebarScriptField extends LitElement {
fireEvent(this, "toggle-yaml-mode");
};
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "toggle_yaml_mode":
this._toggleYamlMode();
break;
case "delete":
this.config.delete();
break;
}
}
static styles = [sidebarEditorStyles, overflowStyles];
}

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import {
mdiAppleKeyboardCommand,
mdiContentCopy,
@@ -15,6 +16,8 @@ import { customElement, property, query, state } from "lit/decorators";
import { keyed } from "lit/directives/keyed";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import type {
LegacyTrigger,
TriggerSidebarConfig,
@@ -99,6 +102,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.narrow=${this.narrow}
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<span slot="subtitle"
@@ -106,60 +110,56 @@ export default class HaAutomationSidebarTrigger extends LitElement {
? ` (${this.hass.localize("ui.panel.config.automation.editor.actions.disabled")})`
: ""}</span
>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.rename}
value="rename"
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
</ha-dropdown-item>
${!this.yamlMode &&
!("id" in this.config.config) &&
!this._requestShowId
? html`<ha-md-menu-item
? html`<ha-dropdown-item
slot="menu-items"
.clickAction=${this._showTriggerId}
value="show_id"
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon slot="start" .path=${mdiIdentifier}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiIdentifier}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>`
</ha-dropdown-item>`
: nothing}
<ha-md-divider
slot="menu-items"
role="separator"
tabindex="-1"
></ha-md-divider>
<wa-divider slot="menu-items"></wa-divider>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.duplicate}
value="duplicate"
.disabled=${this.disabled}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate"
)}
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item slot="menu-items" .clickAction=${this.config.copy}>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
<ha-dropdown-item slot="menu-items" value="copy">
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
@@ -169,7 +169,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -181,14 +180,14 @@ export default class HaAutomationSidebarTrigger extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.cut}
value="cut"
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
@@ -198,7 +197,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -210,32 +208,28 @@ export default class HaAutomationSidebarTrigger extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this._toggleYamlMode}
value="toggle_yaml_mode"
.disabled=${!this.config.uiSupported || !!this._warnings}
>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-divider
</ha-dropdown-item>
<wa-divider slot="menu-items"></wa-divider>
<ha-dropdown-item
slot="menu-items"
role="separator"
tabindex="-1"
></ha-md-divider>
<ha-md-menu-item
slot="menu-items"
.clickAction=${this.config.disable}
value="disable"
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon
slot="start"
slot="icon"
.path=${rowDisabled ? mdiPlayCircleOutline : mdiStopCircleOutline}
></ha-svg-icon>
<div class="overflow-label">
@@ -244,14 +238,14 @@ export default class HaAutomationSidebarTrigger extends LitElement {
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-md-menu-item>
<ha-md-menu-item
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
.clickAction=${this.config.delete}
value="delete"
.disabled=${this.disabled}
class="warning"
variant="danger"
>
<ha-svg-icon slot="start" .path=${mdiDelete}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiDelete}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
@@ -261,7 +255,6 @@ export default class HaAutomationSidebarTrigger extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -277,7 +270,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
</span>`
: nothing}
</div>
</ha-md-menu-item>
</ha-dropdown-item>
${keyed(
this.sidebarKey,
html`<ha-automation-trigger-editor
@@ -335,6 +328,41 @@ export default class HaAutomationSidebarTrigger extends LitElement {
this._requestShowId = true;
};
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "rename":
this.config.rename();
break;
case "show_id":
this._showTriggerId();
break;
case "duplicate":
this.config.duplicate();
break;
case "copy":
this.config.copy();
break;
case "cut":
this.config.cut();
break;
case "toggle_yaml_mode":
this._toggleYamlMode();
break;
case "disable":
this.config.disable();
break;
case "delete":
this.config.delete();
break;
}
}
static styles = [sidebarEditorStyles, overflowStyles];
}

View File

@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAppleKeyboardCommand,
@@ -34,11 +35,11 @@ import "../../../../components/ha-alert";
import "../../../../components/ha-automation-row";
import type { HaAutomationRow } from "../../../../components/ha-automation-row";
import "../../../../components/ha-card";
import "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import type { HaDropdownItem } from "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-svg-icon";
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
import type {
@@ -208,41 +209,35 @@ export default class HaAutomationTriggerRow extends LitElement {
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
quick
<ha-dropdown
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
anchor-corner="end-end"
menu-corner="start-end"
@wa-select=${this._handleDropdownSelect}
placement="bottom-end"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
.clickAction=${this._renameTrigger}
<ha-dropdown-item
value="rename"
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiRenameBox}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<wa-divider></wa-divider>
<ha-md-menu-item
.clickAction=${this._duplicateTrigger}
.disabled=${this.disabled}
>
<ha-dropdown-item value="duplicate" .disabled=${this.disabled}>
<ha-svg-icon
slot="start"
slot="icon"
.path=${mdiPlusCircleMultipleOutline}
></ha-svg-icon>
@@ -251,13 +246,10 @@ export default class HaAutomationTriggerRow extends LitElement {
"ui.panel.config.automation.editor.actions.duplicate"
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
.clickAction=${this._copyTrigger}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCopy}></ha-svg-icon>
<ha-dropdown-item value="copy" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.copy"
@@ -266,7 +258,6 @@ export default class HaAutomationTriggerRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -277,13 +268,10 @@ export default class HaAutomationTriggerRow extends LitElement {
<span>C</span>
</span>`
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-menu-item
.clickAction=${this._cutTrigger}
.disabled=${this.disabled}
>
<ha-svg-icon slot="start" .path=${mdiContentCut}></ha-svg-icon>
<ha-dropdown-item value="cut" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiContentCut}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.triggers.cut"
@@ -292,7 +280,6 @@ export default class HaAutomationTriggerRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -303,51 +290,51 @@ export default class HaAutomationTriggerRow extends LitElement {
<span>X</span>
</span>`
)}
</ha-md-menu-item>
</ha-dropdown-item>
${!this.optionsInSidebar
? html`
<ha-md-menu-item
.clickAction=${this._moveUp}
<ha-dropdown-item
value="move_up"
.disabled=${this.disabled || !!this.first}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_up"
)}
<ha-svg-icon slot="start" .path=${mdiArrowUp}></ha-svg-icon
></ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._moveDown}
<ha-svg-icon slot="icon" .path=${mdiArrowUp}></ha-svg-icon
></ha-dropdown-item>
<ha-dropdown-item
value="move_down"
.disabled=${this.disabled || !!this.last}
>
${this.hass.localize(
"ui.panel.config.automation.editor.move_down"
)}
<ha-svg-icon slot="start" .path=${mdiArrowDown}></ha-svg-icon
></ha-md-menu-item>
<ha-svg-icon slot="icon" .path=${mdiArrowDown}></ha-svg-icon
></ha-dropdown-item>
`
: nothing}
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
<ha-dropdown-item
value="toggle_yaml_mode"
.disabled=${!supported || !!this._warnings}
>
<ha-svg-icon slot="start" .path=${mdiPlaylistEdit}></ha-svg-icon>
<ha-svg-icon slot="icon" .path=${mdiPlaylistEdit}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.edit_${!yamlMode ? "yaml" : "ui"}`
)
)}
</ha-md-menu-item>
</ha-dropdown-item>
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
<wa-divider></wa-divider>
<ha-md-menu-item
.clickAction=${this._onDisable}
<ha-dropdown-item
value="disable"
.disabled=${this.disabled || type === "list"}
>
<ha-svg-icon
slot="start"
slot="icon"
.path=${"enabled" in this.trigger && this.trigger.enabled === false
? mdiPlayCircleOutline
: mdiStopCircleOutline}
@@ -358,15 +345,15 @@ export default class HaAutomationTriggerRow extends LitElement {
`ui.panel.config.automation.editor.actions.${"enabled" in this.trigger && this.trigger.enabled === false ? "enable" : "disable"}`
)
)}
</ha-md-menu-item>
<ha-md-menu-item
.clickAction=${this._onDelete}
class="warning"
</ha-dropdown-item>
<ha-dropdown-item
value="delete"
variant="danger"
.disabled=${this.disabled}
>
<ha-svg-icon
class="warning"
slot="start"
slot="icon"
.path=${mdiDelete}
></ha-svg-icon>
${this._renderOverflowLabel(
@@ -377,7 +364,6 @@ export default class HaAutomationTriggerRow extends LitElement {
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
@@ -392,8 +378,8 @@ export default class HaAutomationTriggerRow extends LitElement {
>
</span>`
)}
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dropdown-item>
</ha-dropdown>
${!this.optionsInSidebar
? html`${this._warnings
? html`<ha-automation-editor-warning
@@ -804,6 +790,44 @@ export default class HaAutomationTriggerRow extends LitElement {
this._automationRowElement?.focus();
}
private _handleDropdownSelect(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
if (!action) {
return;
}
switch (action) {
case "rename":
this._renameTrigger();
break;
case "duplicate":
this._duplicateTrigger();
break;
case "copy":
this._copyTrigger();
break;
case "cut":
this._cutTrigger();
break;
case "move_up":
this._moveUp();
break;
case "move_down":
this._moveDown();
break;
case "toggle_yaml_mode":
this._toggleYamlMode(ev.target as HTMLElement);
break;
case "disable":
this._onDisable();
break;
case "delete":
this._onDelete();
break;
}
}
static get styles(): CSSResultGroup {
return [
rowStyles,

View File

@@ -24,7 +24,7 @@ import {
type Trigger,
type TriggerList,
} from "../../../../data/automation";
import { subscribeLabFeatures } from "../../../../data/labs";
import { subscribeLabFeature } from "../../../../data/labs";
import type { TriggerDescriptions } from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
@@ -85,14 +85,14 @@ export default class HaAutomationTrigger extends SubscribeMixin(LitElement) {
protected hassSubscribe() {
return [
subscribeLabFeatures(this.hass!.connection, (features) => {
this._newTriggersAndConditions =
features.find(
(feature) =>
feature.domain === "automation" &&
feature.preview_feature === "new_triggers_conditions"
)?.enabled ?? false;
}),
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersAndConditions = feature.enabled;
}
),
];
}

View File

@@ -1,15 +1,14 @@
import { mdiClose, mdiContentCopy, mdiDownload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-password-field";
@@ -31,20 +30,18 @@ type Step = (typeof STEPS)[number];
class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _open = false;
@state() private _step?: Step;
@state() private _params?: ChangeBackupEncryptionKeyDialogParams;
@query("ha-md-dialog") private _dialog!: HaMdDialog;
@state() private _newEncryptionKey?: string;
public showDialog(params: ChangeBackupEncryptionKeyDialogParams): void {
this._params = params;
this._step = STEPS[0];
this._opened = true;
this._open = true;
this._newEncryptionKey = generateEncryptionKey();
}
@@ -52,10 +49,10 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
if (this._params!.cancel) {
this._params!.cancel();
}
if (this._opened) {
if (this._open) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._open = false;
this._step = undefined;
this._params = undefined;
this._newEncryptionKey = undefined;
@@ -64,7 +61,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
private _done() {
this._params?.submit!(true);
this._dialog.close();
this.closeDialog();
}
private _previousStep() {
@@ -84,10 +81,6 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
}
protected render() {
if (!this._opened || !this._params) {
return nothing;
}
const dialogTitle =
this._step === "current" || this._step === "new"
? this.hass.localize(
@@ -96,36 +89,40 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
: "";
return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
${this._step === "new"
? html`
<ha-icon-button-prev
slot="navigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`
: html`
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
></ha-icon-button>
`}
<span slot="title">${dialogTitle}</span>
</ha-dialog-header>
<div slot="content">${this._renderStepContent()}</div>
<div slot="actions">
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${dialogTitle}
prevent-scrim-close
@closed=${this.closeDialog}
>
${this._step === "new"
? html`
<ha-icon-button-prev
slot="headerNavigationIcon"
@click=${this._previousStep}
></ha-icon-button-prev>
`
: html`
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
`}
${this._renderStepContent()}
<ha-dialog-footer slot="footer">
${this._step === "current"
? html`
<ha-button @click=${this._nextStep}>
<ha-button slot="primaryAction" @click=${this._nextStep}>
${this.hass.localize("ui.common.next")}
</ha-button>
`
: this._step === "new"
? html`
<ha-button
slot="primaryAction"
@click=${this._submit}
.disabled=${!this._newEncryptionKey}
variant="danger"
@@ -136,14 +133,14 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
</ha-button>
`
: html`
<ha-button @click=${this._done}>
<ha-button slot="primaryAction" @click=${this._done}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.change_encryption_key.actions.done"
)}
</ha-button>
`}
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -287,10 +284,8 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
width: 90vw;
max-width: 560px;
--dialog-content-padding: 8px 24px;
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
ha-md-list {
background: none;
@@ -321,14 +316,7 @@ class DialogChangeBackupEncryptionKey extends LitElement implements HassDialog {
flex: none;
margin: -16px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
p {
margin-top: 0;
}

View File

@@ -1,18 +1,12 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-password-field";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-password-field";
import {
canDecryptBackupOnDownload,
getPreferredAgentForDownload,
@@ -27,102 +21,96 @@ import type { DownloadDecryptedBackupDialogParams } from "./show-dialog-download
class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _opened = false;
@state() private _open = false;
@state() private _params?: DownloadDecryptedBackupDialogParams;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _encryptionKey = "";
@state() private _error = "";
public showDialog(params: DownloadDecryptedBackupDialogParams): void {
this._opened = true;
this._open = true;
this._params = params;
}
public closeDialog() {
this._dialog?.close();
this._open = false;
return true;
}
private _dialogClosed() {
if (this._opened) {
if (this._open) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._opened = false;
this._open = false;
this._params = undefined;
this._encryptionKey = "";
this._error = "";
}
protected render() {
if (!this._opened || !this._params) {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog open @closed=${this._dialogClosed} disable-cancel-action>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<span slot="title">
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
</span>
</ha-dialog-header>
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.title"
)}
prevent-scrim-close
@closed=${this._dialogClosed}
>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
{
download_it_encrypted: html`<button
class="link"
@click=${this._downloadEncrypted}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
)}
</button>`,
}
)}
</p>
<div slot="content">
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.description"
)}
</p>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_backup_encrypted",
{
download_it_encrypted: html`<button
class="link"
@click=${this._downloadEncrypted}
>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download_it_encrypted"
)}
</button>`,
}
)}
</p>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.encryption_key"
)}
@input=${this._keyChanged}
></ha-password-field>
<ha-password-field
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.download.encryption_key"
)}
@input=${this._keyChanged}
></ha-password-field>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
</div>
<div slot="actions">
<ha-button appearance="plain" @click=${this._cancel}>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._cancel}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._submit}>
<ha-button slot="primaryAction" @click=${this._submit}>
${this.hass.localize(
"ui.panel.config.backup.dialogs.download.download"
)}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -191,17 +179,8 @@ class DialogDownloadDecryptedBackup extends LitElement implements HassDialog {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
--dialog-content-padding: 8px 24px;
max-width: 500px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
button.link {

View File

@@ -1,15 +1,14 @@
import { mdiClose, mdiContentCopy, mdiDownload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-button-prev";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-wa-dialog";
import "../../../../components/ha-md-list";
import "../../../../components/ha-md-list-item";
import "../../../../components/ha-password-field";
@@ -24,89 +23,83 @@ import type { ShowBackupEncryptionKeyDialogParams } from "./show-dialog-show-bac
class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: ShowBackupEncryptionKeyDialogParams;
@state() private _open = false;
@query("ha-md-dialog") private _dialog!: HaMdDialog;
@state() private _params?: ShowBackupEncryptionKeyDialogParams;
public showDialog(params: ShowBackupEncryptionKeyDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog() {
if (this._open) {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
this._open = false;
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
private _closeDialog() {
this._dialog.close();
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-md-dialog disable-cancel-action open @closed=${this.closeDialog}>
<ha-dialog-header slot="headline">
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
prevent-scrim-close
@closed=${this.closeDialog}
>
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.description"
)}
</p>
<div class="encryption-key">
<p>${this._params?.currentKey}</p>
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._closeDialog}
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
<span slot="title">
${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.title"
)}
</span>
</ha-dialog-header>
<div slot="content">
<p>
${this.hass.localize(
"ui.panel.config.backup.dialogs.show_encryption_key.description"
)}
</p>
<div class="encryption-key">
<p>${this._params?.currentKey}</p>
<ha-icon-button
.path=${mdiContentCopy}
@click=${this._copyKeyToClipboard}
></ha-icon-button>
</div>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button
size="small"
appearance="plain"
slot="end"
@click=${this._download}
>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
</div>
<div slot="actions">
<ha-button @click=${this._closeDialog}>
<ha-md-list>
<ha-md-list-item>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit"
)}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_description"
)}
</span>
<ha-button slot="end" @click=${this._download}>
<ha-svg-icon .path=${mdiDownload} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.encryption_key.download_emergency_kit_action"
)}
</ha-button>
</ha-md-list-item>
</ha-md-list>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.close")}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -135,10 +128,8 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
haStyle,
haStyleDialog,
css`
ha-md-dialog {
width: 90vw;
max-width: 560px;
--dialog-content-padding: 8px 24px;
ha-wa-dialog {
--dialog-content-padding: var(--ha-space-2) var(--ha-space-6);
}
ha-md-list {
background: none;
@@ -169,14 +160,7 @@ class DialogShowBackupEncryptionKey extends LitElement implements HassDialog {
flex: none;
margin: -16px;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
ha-md-dialog {
max-width: none;
}
div[slot="content"] {
margin-top: 0;
}
}
p {
margin-top: 0;
}

View File

@@ -1,19 +1,17 @@
import { mdiClose, mdiFolderUpload } from "@mdi/js";
import { mdiFolderUpload } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-file-upload";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-dialog";
import type { HaMdDialog } from "../../../../components/ha-md-dialog";
import "../../../../components/ha-wa-dialog";
import {
CORE_LOCAL_AGENT,
HASSIO_LOCAL_AGENT,
@@ -43,11 +41,12 @@ export class DialogUploadBackup
@state() private _formData?: BackupUploadFileFormData;
@query("ha-md-dialog") private _dialog?: HaMdDialog;
@state() private _open = false;
public async showDialog(params: UploadBackupDialogParams): Promise<void> {
this._params = params;
this._formData = INITIAL_UPLOAD_FORM_DATA;
this._open = true;
}
private _dialogClosed() {
@@ -56,11 +55,12 @@ export class DialogUploadBackup
}
this._formData = undefined;
this._params = undefined;
this._open = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
public closeDialog() {
this._dialog?.close();
this._open = false;
return true;
}
@@ -74,52 +74,44 @@ export class DialogUploadBackup
}
return html`
<ha-md-dialog
open
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.title"
)}
?prevent-scrim-close=${this._uploading}
@closed=${this._dialogClosed}
.disableCancelAction=${this._uploading}
>
<ha-dialog-header slot="headline">
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this.closeDialog}
.disabled=${this._uploading}
></ha-icon-button>
<span slot="title">
${this.hass.localize("ui.panel.config.backup.dialogs.upload.title")}
</span>
</ha-dialog-header>
<div slot="content">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
.accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.hass.localize}
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.input_label"
)}
.supports=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.supports_tar"
)}
@file-picked=${this._filePicked}
@files-cleared=${this._filesCleared}
></ha-file-upload>
</div>
<div slot="actions">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-file-upload
.hass=${this.hass}
.uploading=${this._uploading}
.icon=${mdiFolderUpload}
.accept=${SUPPORTED_UPLOAD_FORMAT}
.localize=${this.hass.localize}
.label=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.input_label"
)}
.supports=${this.hass.localize(
"ui.panel.config.backup.dialogs.upload.supports_tar"
)}
@file-picked=${this._filePicked}
@files-cleared=${this._filesCleared}
></ha-file-upload>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
.disabled=${this._uploading}
>${this.hass.localize("ui.common.cancel")}</ha-button
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._upload}
.disabled=${!this._formValid() || this._uploading}
>
@@ -127,8 +119,8 @@ export class DialogUploadBackup
"ui.panel.config.backup.dialogs.upload.action"
)}
</ha-button>
</div>
</ha-md-dialog>
</ha-dialog-footer>
</ha-wa-dialog>
`;
}
@@ -183,15 +175,9 @@ export class DialogUploadBackup
haStyle,
haStyleDialog,
css`
ha-md-dialog {
max-width: 500px;
width: 100%;
max-width: 500px;
max-height: 100%;
}
ha-alert {
display: block;
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
`,
];

View File

@@ -120,9 +120,6 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
icon: category.icon || undefined,
icon_path: category.icon ? undefined : mdiTag,
sorting_label: category.name,
search_labels: [category.name, category.category_id].filter(
(v): v is string => Boolean(v)
),
}));
return items;
@@ -203,6 +200,9 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.components.category-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>

View File

@@ -1,20 +1,18 @@
import {
mdiCheckboxBlankOutline,
mdiCheckboxMarked,
mdiDotsVertical,
mdiLocationEnter,
mdiLocationExit,
mdiRefresh,
} from "@mdi/js";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { mdiDotsVertical, mdiRefresh } from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-check-list-item";
import "../../../components/ha-list-item";
import "../../../components/ha-metric";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type {
@@ -35,9 +33,6 @@ import "../../../layouts/hass-subpage";
import type { HomeAssistant } from "../../../types";
import "../dashboard/ha-config-updates";
import { showJoinBetaDialog } from "./updates/show-dialog-join-beta";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "@home-assistant/webawesome/dist/components/divider/divider";
@customElement("ha-config-section-updates")
class HaConfigSectionUpdates extends LitElement {
@@ -78,44 +73,35 @@ class HaConfigSectionUpdates extends LitElement {
.path=${mdiRefresh}
@click=${this._checkUpdates}
></ha-icon-button>
<ha-dropdown @wa-select=${this._handleOverflowAction}>
<ha-button-menu multi>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-dropdown-item value="show_skipped">
<ha-svg-icon
.path=${this._showSkipped
? mdiCheckboxMarked
: mdiCheckboxBlankOutline}
slot="icon"
></ha-svg-icon>
<ha-check-list-item
left
@request-selected=${this._toggleSkipped}
.selected=${this._showSkipped}
>
${this.hass.localize("ui.panel.config.updates.show_skipped")}
</ha-dropdown-item>
</ha-check-list-item>
${this._supervisorInfo
? html`
<wa-divider></wa-divider>
<ha-dropdown-item
value="toggle_beta"
<li divider role="separator"></li>
<ha-list-item
@request-selected=${this._toggleBeta}
.disabled=${this._supervisorInfo.channel === "dev"}
>
<ha-svg-icon
.path=${this._supervisorInfo.channel === "stable"
? mdiLocationEnter
: mdiLocationExit}
slot="icon"
></ha-svg-icon>
${this._supervisorInfo.channel === "stable"
? this.hass.localize("ui.panel.config.updates.join_beta")
: this.hass.localize(
"ui.panel.config.updates.leave_beta"
)}
</ha-dropdown-item>
</ha-list-item>
`
: ""}
</ha-dropdown>
</ha-button-menu>
</div>
<div class="content">
<ha-card outlined>
@@ -147,19 +133,27 @@ class HaConfigSectionUpdates extends LitElement {
this._supervisorInfo = await fetchHassioSupervisorInfo(this.hass);
}
private async _handleOverflowAction(
ev: CustomEvent<{ item: { value: string } }>
private _toggleSkipped(ev: CustomEvent<RequestSelectedDetail>): void {
if (ev.detail.source !== "property") {
return;
}
this._showSkipped = !this._showSkipped;
}
private async _toggleBeta(
ev: CustomEvent<RequestSelectedDetail>
): Promise<void> {
if (ev.detail.item.value === "toggle_beta") {
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: async () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
}
} else if (ev.detail.item.value === "show_skipped") {
this._showSkipped = !this._showSkipped;
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
if (this._supervisorInfo!.channel === "stable") {
showJoinBetaDialog(this, {
join: async () => this._setChannel("beta"),
});
} else {
this._setChannel("stable");
}
}

View File

@@ -26,6 +26,7 @@ import { computeStateName } from "../../../common/entity/compute_state_name";
import { stringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
import { getConfigurationUrl } from "../../../common/util/configuration-url";
import "../../../components/entity/ha-battery-icon";
import "../../../components/ha-alert";
import "../../../components/ha-button";
@@ -1090,17 +1091,12 @@ export class HaConfigDevicePage extends LitElement {
const deviceActions: DeviceAction[] = [];
const configurationUrlIsHomeAssistant =
device.configuration_url?.startsWith("homeassistant://") || false;
const processedUrl = getConfigurationUrl(device.configuration_url);
const configurationUrl = configurationUrlIsHomeAssistant
? device.configuration_url!.replace("homeassistant://", "/")
: device.configuration_url;
if (configurationUrl) {
if (processedUrl) {
deviceActions.push({
href: configurationUrl,
target: configurationUrlIsHomeAssistant ? undefined : "_blank",
href: processedUrl.url,
target: processedUrl.isLocal ? undefined : "_blank",
icon: mdiCog,
label: this.hass.localize(
"ui.panel.config.devices.open_configuration_url"

View File

@@ -9,6 +9,7 @@ import "../../../../components/ha-dialog";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-button";
import "../../../../components/ha-markdown";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/ha-textfield";
import type { GasSourceTypeEnergyPreference } from "../../../../data/energy";
@@ -109,6 +110,15 @@ export class DialogEnergyGasSettings
? `${this.hass.config.currency}/${this._pickedDisplayUnit}`
: undefined;
const pickedUnitClass =
this._pickedDisplayUnit &&
this._energy_units?.includes(this._pickedDisplayUnit)
? "energy"
: this._pickedDisplayUnit &&
this._gas_units?.includes(this._pickedDisplayUnit)
? "volume"
: undefined;
const externalSource =
this._source.stat_energy_from &&
isExternalStatistic(this._source.stat_energy_from);
@@ -213,9 +223,33 @@ export class DialogEnergyGasSettings
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${`${this.hass.localize(
.label=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_input"
)} ${unitPrice ? ` (${unitPrice})` : ""}`}
)}
.helper=${pickedUnitClass
? html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper",
pickedUnitClass === "energy"
? {
currency: this.hass.config.currency,
class: this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper_energy"
),
unit1: "kWh",
unit2: "Wh",
}
: {
currency: this.hass.config.currency,
class: this.hass.localize(
"ui.panel.config.energy.gas.dialog.cost_entity_helper_volume"
),
unit1: "m³",
unit2: "ft³",
}
)}
></ha-markdown>`
: nothing}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}

View File

@@ -9,6 +9,7 @@ import "../../../../components/ha-dialog";
import "../../../../components/ha-button";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-markdown";
import type { HaRadio } from "../../../../components/ha-radio";
import "../../../../components/ha-textfield";
import type { WaterSourceTypeEnergyPreference } from "../../../../data/energy";
@@ -16,11 +17,7 @@ import {
emptyWaterEnergyPreference,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getDisplayUnit,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
import { isExternalStatistic } from "../../../../data/recorder";
import { getSensorDeviceClassConvertibleUnits } from "../../../../data/sensor";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
@@ -40,8 +37,6 @@ export class DialogEnergyWaterSettings
@state() private _costs?: "no-costs" | "number" | "entity" | "statistic";
@state() private _pickedDisplayUnit?: string | null;
@state() private _water_units?: string[];
@state() private _error?: string;
@@ -55,11 +50,6 @@ export class DialogEnergyWaterSettings
this._source = params.source
? { ...params.source }
: emptyWaterEnergyPreference();
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
params.source?.stat_energy_from,
params.metadata
);
this._costs = this._source.entity_energy_price
? "entity"
: this._source.number_energy_price
@@ -79,7 +69,6 @@ export class DialogEnergyWaterSettings
this._params = undefined;
this._source = undefined;
this._error = undefined;
this._pickedDisplayUnit = undefined;
this._excludeList = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
@@ -92,10 +81,6 @@ export class DialogEnergyWaterSettings
const pickableUnit = this._water_units?.join(", ") || "";
const unitPriceSensor = this._pickedDisplayUnit
? `${this.hass.config.currency}/${this._pickedDisplayUnit}`
: undefined;
const unitPriceFixed = `${this.hass.config.currency}/${
this.hass.config.unit_system.volume === "gal" ? "gal" : "m³"
}`;
@@ -202,9 +187,15 @@ export class DialogEnergyWaterSettings
.hass=${this.hass}
include-domains='["sensor", "input_number"]'
.value=${this._source.entity_energy_price}
.label=${`${this.hass.localize(
.label=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity_input"
)}${unitPriceSensor ? ` (${unitPriceSensor})` : ""}`}
)}
.helper=${html`<ha-markdown
.content=${this.hass.localize(
"ui.panel.config.energy.water.dialog.cost_entity_helper",
{ currency: this.hass.config.currency }
)}
></ha-markdown>`}
@value-changed=${this._priceEntityChanged}
></ha-entity-picker>`
: ""}
@@ -287,16 +278,6 @@ export class DialogEnergyWaterSettings
}
private async _statisticChanged(ev: CustomEvent<{ value: string }>) {
if (ev.detail.value) {
const metadata = await getStatisticMetadata(this.hass, [ev.detail.value]);
this._pickedDisplayUnit = getDisplayUnit(
this.hass,
ev.detail.value,
metadata[0]
);
} else {
this._pickedDisplayUnit = undefined;
}
if (isExternalStatistic(ev.detail.value) && this._costs !== "statistic") {
this._costs = "no-costs";
}

View File

@@ -1525,17 +1525,17 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
ha-textfield.entityId ha-icon-button {
position: relative;
right: -8px;
right: calc(var(--ha-space-2) * -1);
--mdc-icon-button-size: 36px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
inset-inline-start: initial;
inset-inline-end: -8px;
inset-inline-end: calc(var(--ha-space-2) * -1);
direction: var(--direction);
}
ha-switch {
margin-right: 16px;
margin-inline-end: 16px;
margin-right: var(--ha-space-4);
margin-inline-end: var(--ha-space-4);
margin-inline-start: initial;
}
ha-settings-row ha-switch {
@@ -1548,7 +1548,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
ha-select,
ha-area-picker {
display: block;
margin: 8px 0;
margin: var(--ha-space-2) 0;
width: 100%;
}
li[divider] {

View File

@@ -244,14 +244,15 @@ export class EntityRegistrySettings extends SubscribeMixin(LitElement) {
display: block;
}
.container {
padding: 8px 24px 20px 24px;
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-5)
var(--ha-space-6);
}
.buttons {
box-sizing: border-box;
display: flex;
padding: 16px;
padding: var(--ha-space-4);
justify-content: space-between;
padding-bottom: max(var(--safe-area-inset-bottom), 16px);
padding-bottom: max(var(--safe-area-inset-bottom), var(--ha-space-4));
background-color: var(--mdc-theme-surface, #fff);
border-top: 1px solid var(--divider-color);
position: sticky;

View File

@@ -25,6 +25,7 @@ import { computeCssColor } from "../../../common/color/compute-color";
import { storage } from "../../../common/decorators/storage";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { navigate } from "../../../common/navigate";
import type {
LocalizeFunc,
@@ -132,6 +133,7 @@ interface HelperItem {
configEntry?: ConfigEntry;
entity?: HassEntity;
category: string | undefined;
area?: string;
label_entries: LabelRegistryEntry[];
disabled?: boolean;
}
@@ -347,6 +349,12 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
filterable: true,
sortable: true,
},
area: {
title: localize("ui.panel.config.helpers.picker.headers.area"),
sortable: true,
filterable: true,
groupable: true,
},
labels: {
title: "",
hidden: true,
@@ -565,6 +573,11 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
entityRegistryByEntityId(entityReg)[item.entity_id];
const labels = labelReg && entityRegEntry?.labels;
const category = entityRegEntry?.categories.helpers;
const areaId = entityRegEntry?.area_id;
const area =
areaId && this.hass.areas?.[areaId]
? computeAreaName(this.hass.areas[areaId])
: undefined;
return {
...item,
localized_type:
@@ -579,6 +592,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
category: category
? categoryReg?.find((cat) => cat.category_id === category)?.name
: undefined,
area,
};
});
}

View File

@@ -8,6 +8,7 @@ import {
mdiDotsVertical,
mdiDownload,
mdiHandExtendedOutline,
mdiOpenInNew,
mdiPlayCircleOutline,
mdiPlus,
mdiProgressHelper,
@@ -73,6 +74,7 @@ import "./ha-config-entry-device-row";
import { renderConfigEntryError } from "./ha-config-integration-page";
import "./ha-config-sub-entry-row";
import { copyToClipboard } from "../../../common/util/copy-clipboard";
import { getConfigurationUrl } from "../../../common/util/configuration-url";
import { showToast } from "../../../util/toast";
@customElement("ha-config-entry-row")
@@ -213,34 +215,53 @@ class HaConfigEntryRow extends LitElement {
? html`<ha-button slot="end" @click=${this._handleEnable}>
${this.hass.localize("ui.common.enable")}
</ha-button>`
: configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-icon-button
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button
></a>`
: item.supports_options
? html`
<ha-icon-button
slot="end"
@click=${this._showOptions}
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
: (() => {
const processedUrl = getConfigurationUrl(item.configuration_url);
return html`
${processedUrl
? html`<a
slot="end"
href=${processedUrl.url}
target=${processedUrl.isLocal ? undefined : "_blank"}
rel=${processedUrl.isLocal ? undefined : "noreferrer"}
>
<ha-icon-button
.path=${mdiOpenInNew}
.label=${processedUrl.url}
></ha-icon-button>
</a>`
: nothing}
${configPanel &&
(item.domain !== "matter" ||
isDevVersion(this.hass.config.version)) &&
!stateText
? html`<a
slot="end"
href=${`/${configPanel}?config_entry=${item.entry_id}`}
><ha-icon-button
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button
></a>`
: item.supports_options
? html`
<ha-icon-button
slot="end"
@click=${this._showOptions}
.path=${mdiCogOutline}
.label=${this.hass.localize(
"ui.panel.config.integrations.config_entry.configure"
)}
>
</ha-icon-button>
`
: nothing}
`;
})()}
<ha-md-button-menu positioning="popover" slot="end">
<ha-icon-button
slot="trigger"

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