Compare commits

...

192 Commits

Author SHA1 Message Date
Aidan Timson
06cdef459d Log error 2026-01-21 14:45:02 +00:00
Aidan Timson
5f1f537fbd Memoize related updates 2026-01-21 09:57:18 +00:00
Aidan Timson
6024cacc0c Remove floor from set, use area 2026-01-21 09:52:40 +00:00
Aidan Timson
bca8cd013a Add context check and catch findRelated errors 2026-01-21 09:29:43 +00:00
Aidan Timson
e04034f9a0 Reuse type 2026-01-21 09:11:03 +00:00
Aidan Timson
122d607f3c Consolidate and cleanup 2026-01-21 09:07:45 +00:00
Aidan Timson
11b0708cc1 Add floor and sort 2026-01-21 09:01:01 +00:00
Aidan Timson
51b1042dec Fix area card 2026-01-21 08:49:57 +00:00
Aidan Timson
44632d043a Add integration icon for related device 2026-01-20 16:47:35 +00:00
Aidan Timson
10631e4c5b Add context based related section 2026-01-20 16:36:11 +00:00
Aidan Timson
5c701a6cf6 Clean 2026-01-20 16:03:52 +00:00
Aidan Timson
611886916e Use PANEL_DASHBOARDS to assign system dashboards to the dashboards section 2026-01-20 15:48:59 +00:00
Aidan Timson
a642cc7dae Add sections to navigation picker 2026-01-20 15:44:18 +00:00
Paul Bottein
2d496afdbc Add discovered devices to add integration dialog (#29092) 2026-01-20 14:28:29 +00:00
Wendelin
681b60614f Migrate button-menu to ha-dropdown in 9 files (#29089)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-20 14:20:29 +00:00
Petar Petrov
1654a67d30 Refactor area control picker into a separate component (#29080) 2026-01-20 15:04:20 +01:00
renovate[bot]
8f00494d53 Update dependency rspack-manifest-plugin to v5.2.1 (#29091)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-20 15:31:53 +02:00
Joost Lekkerkerker
d9c7c0422b Allow the main entry type button to be translatable (#28721)
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-01-20 14:25:48 +01:00
Aidan Timson
2d24447c3c Rename Add-ons to Apps in more areas (#29076)
* Remove add-ons mention in agents file

* Rename user facing form and selector name

* Everything else

* Update more

* Update more

* Update more

* Update more

* Update key

* Update key

* Update keys

* Use translation

* More changes

* Update key

* Backward / Forward compat

* Drop "the"

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

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-01-20 13:39:30 +02:00
Paul Bottein
3b8d485ec6 Update log icons (#29084) 2026-01-20 11:40:03 +01:00
Wendelin
4e4a00e3e9 Migrate ha-button-menu to ha-dropdown in 6 files (#29072)
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-20 09:29:29 +00:00
Paul Bottein
14f7328f92 Add context to group more info (#29077)
* Add area context in more info group

* Use entity name instead of entry

* Remove filter
2026-01-20 10:26:03 +02:00
Paul Bottein
c5ad074dfb Create reusable ha tile container component. (#29038)
* Create reusable component for tile based card

* Fix icon interaction

* Add icon and iconPath props

* Migrate discovered devices card

* Refactor

* Share card style
2026-01-20 08:49:10 +02:00
Paulus Schoutsen
07aa8706ce App store to link to app panel (#29079) 2026-01-20 06:11:56 +01:00
TheJulianJES
1665fa3775 Fix Z-Wave dashboard picker showing disabled config entries (#29078)
* Fix Z-Wave dashboard picker showing disabled config entries

* Fix Z-Wave dashboard picker showing ignored discoveries
2026-01-19 20:08:44 +01:00
Aidan Timson
3be6a87658 Bring scene editor in line with automations and scripts (#29002)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-19 16:49:54 +01:00
ildar170975
9092de5c28 Entity card: add support of actions (#28949) 2026-01-19 15:13:36 +01:00
Petar Petrov
e0fc661920 Allow specific entity controls in Area card (#29025)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-19 14:44:10 +01:00
JLo
aaad8e5434 Add distribution card (#28886)
* Add horizontal stacked bar card

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

* Refactor to use ha-segmented-bar component

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

* Fix spacing when heading is empty

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

* Rename card from horizontal-stacked-bar to distribution

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

* Fix remaining translation references

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

* Add fixed row setting to prevent layout issues

* Add spacing between bar and legend

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

* PR review changes

* Improve accessibility and performance

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-19 12:46:14 +00:00
Jan Layola
35c668744a Update ha-base-time-input to accept decimal input for seconds (#29058)
* Update ha-base-time-input to accept decimal input for seconds

* Add support for decimal values in time formatting ha-base-time-input
2026-01-19 14:28:08 +02:00
Wendelin
081b0a0222 Migrate ha-button-menu to ha-dropdown in 8 files (#29070)
Migrates the following files from ha-button-menu to ha-dropdown component:
- ha-config-backup-details.ts
- ha-config-areas-dashboard.ts
- ha-config-dashboard.ts
- ha-panel-history.ts
- ha-scene-editor.ts
- ha-config-integrations-dashboard.ts
- thread-config-panel.ts
- ha-panel-developer-tools.ts

Changes:
- Replace ha-button-menu with ha-dropdown
- Replace ha-list-item with ha-dropdown-item
- Change @action event handlers to @wa-select
- Change slot="graphic" to slot="icon" for icons
- Update event handlers from index-based to value-based selection
- Replace <li divider role="separator"> with <wa-divider>
- Update CSS selectors from ha-button-menu to ha-dropdown
- Use type="checkbox" for checkbox dropdown items in integrations dashboard
- Remove unused li[role="separator"] CSS styles

Contributes to #26537

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-19 13:11:29 +02:00
Paulus Schoutsen
829cd96e9b Add apps panel as built-in panel (#28245)
* Add apps panel as built-in panel

* Fix missing translation

* Address cursor comment

* Another cursor fix

* One more cursor fix

* Address PR review comments: localize error messages, fix deprecated method, use spacing tokens (#28246)

* Initial plan

* Address PR review comments: localize error messages, replace substr with substring, use spacing tokens

Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>

* Cursor fix

* Cursor: use willUpdate

* prettier

* Cursor: fix translation placeholder swap

* Apply suggestions from code review

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

* Cursor: clearTimeout

* Cursor: fix race condition

* Update src/translations/en.json

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

* Apply spacing tokens

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-01-19 13:08:15 +02:00
Paulus Schoutsen
c404e66ee5 Add new app panel (#28214)
* Add ingress panel to Home Assistant

* Better wait until app loaded logic

* Cleaner slug extraction

* Support HA-aware apps integrating more tightly

* Add new file too

* Memoize

* Cursor: use clearTimeout

* Cursor: fix race conditions

* Claude: fixes

* Cursor: fix issues

* Rename hideToolbar to kioskMode

* Hook kiosk mode into native kiosk mode

* Update src/data/route.ts

* Move computeRouteTail to common URL module

* dry refactor

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-19 13:08:07 +02:00
Aidan Timson
d47d3f9694 Create shared ai task metadata suggestion task (#29012)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-19 09:42:36 +01:00
dependabot[bot]
622df52167 Bump actions/cache from 5.0.1 to 5.0.2 (#29068)
Bumps [actions/cache](https://github.com/actions/cache) from 5.0.1 to 5.0.2.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](9255dc7a25...8b402f58fb)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 09:44:26 +02:00
dependabot[bot]
20345c3771 Bump actions/setup-node from 6.1.0 to 6.2.0 (#29067)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.1.0 to 6.2.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](395ad32622...6044e13b5d)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 09:44:01 +02:00
dependabot[bot]
fc7468a43b Bump github/codeql-action from 4.31.9 to 4.31.10 (#29069)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.31.9 to 4.31.10.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](5d4e8d1aca...cdefb33c0f)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-19 09:43:43 +02:00
renovate[bot]
c8ab65cde9 Lock file maintenance (#29065)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-19 08:01:36 +02:00
Kristel
001ade24ea Reuse <voice-assistant-brand-icon> (#29046) 2026-01-18 23:55:53 +01:00
karwosts
f987cfe91e Use water-heater operation_mode icon translations (#29051) 2026-01-18 23:31:11 +01:00
renovate[bot]
0bc0acebe0 Update formatjs monorepo (#29062)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-18 23:30:07 +01:00
renovate[bot]
2087efca51 Update dependency prettier to v3.8.0 (#29049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-18 09:57:51 +01:00
renovate[bot]
16a4a07080 Update dependency @rsdoctor/rspack-plugin to v1.5.0 (#29044)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-17 13:26:05 +01:00
renovate[bot]
58eefcb216 Update CodeMirror (#29043)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-17 13:24:01 +01:00
Wendelin
92a36ac687 Migrate ha-button-menu to ha-dropdown in logs and forms (#29036)
Migrate four components from ha-button-menu to ha-dropdown:
- error-log-card: overflow menu with log view switching
- system-log-card: overflow menu for full logs
- ha-filter-categories: category edit/delete actions
- ha-form-optional_actions: add interaction dropdown

Changes:
- Replace ha-button-menu with ha-dropdown
- Replace ha-list-item with ha-dropdown-item
- Update event handlers from @action to @wa-select
- Use value-based selection instead of index-based
- Add proper accessibility labels to trigger buttons

Part of #26537

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-16 19:27:14 +01:00
Wendelin
8221ca8971 Add artifact upload step for frontend build in CI workflow (#29034) 2026-01-16 17:30:37 +01:00
Paul Bottein
8f69cbb6c1 Add discovered devices card (#29035) 2026-01-16 17:25:08 +01:00
renovate[bot]
4c111e1a7d Update dependency @codemirror/search to v6.6.0 (#29029)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 15:17:31 +02:00
Aidan Timson
af8659d8ed Migrate automation/script dialogs to ha-wa-dialog (#29030)
* Migrate dialog-automation-save to ha-wa-dialog

* Migrate dialog-automation-mode to ha-wa-dialog

* Migrate dialog-paste-replace to ha-wa-dialog

* Migrate dialog-new-automation to ha-wa-dialog

* Migrate ha-device-automation-dialog to ha-wa-dialog
2026-01-16 13:23:56 +02:00
renovate[bot]
c9e1c9e0a3 Update dependency ua-parser-js to v2.0.8 (#29028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 10:14:57 +00:00
renovate[bot]
9d15499953 Update dependency @rspack/core to v1.7.2 (#29027)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 10:03:25 +00:00
Wendelin
7d54dd4940 Fix shortcuts ctrl translation (#29024)
* fix: update localization for CTRL key in shortcuts dialog

* fix: update search label in translations for improved clarity
2026-01-16 11:09:34 +02:00
renovate[bot]
8ef717df6e Update dependency typescript-eslint to v8.53.0 (#29019)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 11:08:06 +02:00
renovate[bot]
7b4a7403c8 Update babel monorepo to v7.28.6 (#29018)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-16 11:07:40 +02:00
ildar170975
22b7c52828 "Voice assistant" column in tables: standardize (#28889)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-16 09:10:02 +01:00
Remy Sharp
04dbeb5e84 Fixed dark mode hidden text in debug assistant (#29021)
Co-authored-by: Wendelin <w@pe8.at>
2026-01-16 08:33:45 +01:00
Simon Lamon
db5f823b6b Remove twine and introduce trusted publishing (#27110)
* Remove twine and introduce trusted publishing

* Update release.yaml
2026-01-15 22:50:52 -05:00
uptimeZERO_
1d241aa49a Truncate long menu item labels in the sidebar (#29005) 2026-01-15 15:41:48 +00:00
Pegasus
fece231faf fix: restrict to exact match for data table (#28853) 2026-01-15 15:50:15 +01:00
Aidan Timson
fffb3c3a28 Migrate category dialogs to ha-wa-dialog (#29009) 2026-01-15 15:32:43 +01:00
renovate[bot]
fe14d436ff Update vitest monorepo to v4.0.17 (#29007)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-15 15:46:10 +02:00
Petar Petrov
42e02be928 Add subpage titles in for config panel pages (#28990)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-15 12:48:33 +01:00
Aydar Gumerbaev
6213b6cd2a Always use fallback for brands URL (#28994) 2026-01-15 11:42:17 +00:00
Aidan Timson
cd75c55392 Entity context: voice assistants expose entities (#28992)
* Entity context: voice assistants expose entities

* Load virtualiser

* Refactor filter entities, reduce duplicate renders

* Fix logic
2026-01-15 13:09:38 +02:00
Marcin Bauer
ca325020d7 Add Labs feature note to automation element picker (#28874)
Co-authored-by: Wendelin <w@pe8.at>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-15 11:09:11 +00:00
Jeremy Cook
6250402661 Fix vertical-align in markdown tables with presentation role (#29001) 2026-01-15 11:48:43 +01:00
Amit Finkelstein
0bfca79851 Stop dropdown select events from bubbling in automation rows (#28985) 2026-01-15 11:22:23 +01:00
Wendelin
49bddf6139 Automation add TCA: fix: prevent multiple dialog closures by tracking closing state (#28978) 2026-01-15 08:50:55 +01:00
Wendelin
0daf94e98f Quick bar: new design and area search (#28678)
* Add "Commands" title to quick bar translations in English

* Enhance QuickBar dialog handling and localize commands title

* add nav icons

* Add icons and styles and separate navigation from commands

* handle non admin

* Add areas

* Fix import and shortcuts

* Restructure

* remove area sort

* move keys

* area search keys review

* Fix adaptive dialog slots without header

* Design review

* Review marcin

* Fix safe area bottom

* Fix ios focus

* Make it clearable

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-01-15 09:45:57 +02:00
uptimeZERO_
00a3237611 Persist theme settings to user profile and allow migration (#28965)
* Moved theme functionality and persistence target

* fixed type mismatch

* using SubscribeMixin

* returning no-op unsub to handle rejection path

* added notification if theme save fails

* using hass instead of state

* renamed theme variable for clarity

* Added toast if theme pref is unavailable

* Always saving theme to localStorage

* Removing localStorage fallback

* Updating local cache when new theme comes from core
2026-01-15 09:40:43 +02:00
StormDev
53deb3f419 Removed uneccessary import in landing-page-network.ts (#29000)
* Removed uneccessary console on landing-page-networks.ts

* Uneccessary import removed in landing-page-networks.ts
2026-01-15 07:26:50 +00:00
Pegasus
6c1c7cead3 Fixes duplicate "Device info" section name when viewing Matter devices. (#28984)
ha-device-info-matter: rename 'Device info' to 'Matter info'

Fixes duplicate "Device info" section name when viewing Matter devices.
The nested expansion panel now displays "Matter info" consistent with
other integrations (e.g., ZHA uses "Zigbee info", Z-Wave uses "Z-Wave info").

Also adds a gallery demo for testing the component.
2026-01-15 09:18:21 +02:00
Jason Madigan
f8d65cc0ec Make entities on the energy now sankey graph clickable (#28998)
* enhancement: make entities on the energy now sankey graph clickable to show details

Signed-off-by: Jason Madigan <jason@jasonmadigan.com>

* add a test

Signed-off-by: Jason Madigan <jason@jasonmadigan.com>

* format

---------

Signed-off-by: Jason Madigan <jason@jasonmadigan.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-15 06:44:03 +00:00
Paul Bottein
5be7bad176 Allow to add context to tile card secondary line (#28995) 2026-01-14 19:48:27 +01:00
Paul Bottein
0a54a93a39 Use tabs for bluetooth panel (#28824)
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-14 14:38:10 +01:00
Petar Petrov
156583aff1 Include the area when duplicating a scene from the scene dashboard (#28955) 2026-01-14 12:28:05 +01:00
Aidan Timson
7572257821 Match expose config dashboard for assistants columns (#28956) 2026-01-14 11:43:56 +01:00
Pegasus
4703cf802f Change border-quiet token values from 80 to 90 (#28976) 2026-01-14 09:28:32 +00:00
ildar170975
55c2315329 ha-label-picker: remove valueRenderer (#28975) 2026-01-14 10:15:39 +01:00
Wendelin
7d7e95ac55 Improve device automation UI (#28967)
* Improve device automation rows

* Improve device automation type picker

* Update src/panels/config/automation/condition/ha-automation-condition-row.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-14 07:06:56 +00:00
ildar170975
6d7694caff ha-label-picker, ha-category-picker: fix icon for "no items available" (#28973)
* remove NO_LABELS

* remove NO_CATEGORIES

* reverted removed icon
2026-01-14 08:42:24 +02:00
calm
d7b6243698 Fix tree view heading overlapping Show more button (#28872) (#28968) 2026-01-13 18:34:39 +01:00
calm
73feef9e92 Remove box-shadow from automation dialog "Show more" button (#28945) (#28960) 2026-01-13 17:31:55 +01:00
renovate[bot]
453a546574 Update Node.js to v24.13.0 (#28963)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 15:16:03 +00:00
Petar Petrov
52c0e6f1f5 Respect user-configured grid options for fixed_rows/fixed_columns cards (#28961) 2026-01-13 16:24:25 +02:00
Aidan Timson
444f8d87b3 Ignore all node_modules, not just from root dir (#28959) 2026-01-13 13:51:54 +01:00
Pegasus
57a586c3a7 fix: update the z-index of search button mainly for yaml mode (#28878) 2026-01-13 13:41:53 +01:00
Pegasus
1975265e6b Update the Select Option type from any to string per documentation (#28954) 2026-01-13 10:44:02 +01:00
Wendelin
66e6cb8dbc Fix category-picker unknown check (#28957) 2026-01-13 09:39:05 +00:00
Petar Petrov
9ce9d254f8 Picture elements position by click (#28597) 2026-01-13 10:01:07 +01:00
ildar170975
1beca4bfa6 ha-data-table: issues with "numeric" column (#28916)
Co-authored-by: uptimeZERO_ <pavilionsahota@gmail.com>
2026-01-13 08:38:15 +00:00
Kristel
82ab29cfc5 Add "Voice assistant" filter to helpers, automations, scenes and scripts pages (#28914) 2026-01-13 08:29:28 +00:00
Simon Lamon
3579c66f71 Update dropdown adjustments (#28294) 2026-01-13 08:54:17 +01:00
ildar170975
c042a8e310 ha-sidebar: remove scrollIntoViewIfNeeded() (#28938)
remove scrollIntoViewIfNeeded()
2026-01-13 07:23:20 +01:00
renovate[bot]
8d2794a4ee Update dependency vite-tsconfig-paths to v6.0.4 (#28952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-13 07:15:15 +01:00
Paul Bottein
50be1d9345 Use action button text name for empty state card (#28948) 2026-01-12 17:42:01 +01:00
Petar Petrov
c551bf03b6 Sanitize names in history card and map card (#28947) 2026-01-12 15:28:32 +00:00
Paul Bottein
cd062293fc Add config to empty state card and use it in area empty page (#28946)
* Add config to empty state card and use it in area empty page

* Remove old translations
2026-01-12 16:58:59 +02:00
TheJulianJES
e89ea47d3a Add Matter status to config dashboard (#28825)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2026-01-12 15:45:18 +01:00
SmartCoder
2cd209a6a4 Fixed modal visibility issue in settings -> areas -> edit room (#28907)
* Fixed modal visibility issue in settings -> areas -> edit room

* converting both components to use ha-wa-dialog

* removed z-index from ha-wa-dialog

* fixed hardcoded .open in media browser dialog and remove unnecessary z-index CSS variables
2026-01-12 15:07:56 +02:00
Marcin Bauer
9bbc761736 Fix: Allow dismissing add integration and helper dialogs with escape/click (#28944)
* refactor: polish automation dialog UI and component styles

* Revert "Merge pull request #1 from marcinbauer85/fix/ui-polish-automation-dialog"

This reverts commit c2c47197e2, reversing
changes made to 49bed5e6a6.

* Fix: Allow dismissing add integration and helper dialogs

* Apply suggestions from code review

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-01-12 13:39:16 +01:00
Daniel O'Connor
9097faa04b Config > Helpers > Add loading filter state from URL (#28924) 2026-01-12 13:38:04 +01:00
SmartCoder
fcf844cf1a Fix issue #28896: "Last 12 months" in the Datetime Picker selects last year (#28902)
Summary of the fix:
The Problem:
now-12m was selecting the calendar year (Jan 1st to Dec 31st) instead of the last 12 months from now
It used startOfMonth and endOfMonth, which snap to month boundaries
The Solution:
Changed to match the now-7d and now-30d pattern
Now uses subMonths(today, 12) for start and subMonths(today, 0) (which equals today) for end
This gives exactly the last 12 months (365/366 days) ending at the current time
The Fix:
// Before (WRONG):calcDate(subMonths(today, 12), startOfMonth, ...)  // Jan 1st of 12 months agocalcDate(subMonths(today, 1), endOfMonth, ...)     // Dec 31st of last month// After (CORRECT):calcDate(today, subMonths, hass.locale, hass.config, 12)  // 12 months ago from nowcalcDate(today, subMonths, hass.locale, hass.config, 0)   // now
2026-01-12 11:53:08 +00:00
dcapslock
8808c31e98 Fix ha-card styling of .card-content when not first element but not following .card-header (#28935) 2026-01-12 12:41:14 +01:00
Michael
e0a9f5a08a Show also not installable updates on update overview page (#28717)
* add "show not installable option" to update page

* split updates by install feature and show always

* fix

* fix "no update" panel

* use `nothing` instead of empty string

* re-add `outlined` to ha-card

* keep title, use different for not-installable updates
2026-01-12 13:18:53 +02:00
Petar Petrov
56d71c8e54 Use temp & humidity data from attributes in Area card (#28530)
* Use temp & humidity data from attributes in Area card

* Avoid duplicate sensor readings by tracking devices contributing values
2026-01-12 12:01:12 +01:00
karwosts
125ab4c671 Update energy summary visibility condition (#28913)
* Update energy summary visibility condition

* add grid power as special case

* Always show summary when you have powersource
2026-01-12 12:42:16 +02:00
Eduardo Tsen
8014216c45 Fix ha-entity-toggle not restoring old state on exception (#28915) 2026-01-12 10:28:23 +00:00
ildar170975
55ba331489 developer-tools-statistics: alignment for "fix" column (#28942) 2026-01-12 11:25:44 +01:00
karwosts
ad2ff672b0 Add configurable confirmation title & button text (#28931) 2026-01-12 10:19:09 +00:00
JLo
00907ecd17 Add area and device context to media player join dialog (#28926)
* Add area and device context to media player join dialog

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

* Add memoization to avoid recomputing display data

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

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-12 11:08:44 +01:00
Petar Petrov
07d8219136 Add ES5-compatible keyed directive implementation (#28941) 2026-01-12 10:50:38 +01:00
Eduardo Tsen
f37241c84c Fix hui-select-entity-row restoring old state (#28918) 2026-01-12 09:43:31 +00:00
SmartCoder
65d046132d Updated entity name to friendly name (#28928) 2026-01-12 10:14:23 +01:00
Simon Lamon
122cf40092 Don't close dialog upon tooltip close (#28927) 2026-01-11 20:23:42 -05:00
SmartCoder
28ed5c86c7 Fix automation row menu icon being pushed off-screen on mobile (#28893)
When entity names are too long, the header text would push the three-dot menu icon off the right edge of the screen, making it inaccessible. This fix ensures the menu icon remains visible by:

- Adding min-width: 0 to the header slot to allow proper flexbox shrinking and text wrapping

- Adding flex-shrink: 0 to the icons container to prevent it from being compressed

The fix uses standard flexbox properties that work universally across all screen sizes, ensuring the menu icon stays visible on both mobile and desktop views.
2026-01-10 15:58:28 +01:00
Kristel
1f99c3d895 Add Voice assistants filter to Entities page (#28854)
* create Assistants filter

* render logo and name

* make the Voice assistants filter work

* integrate cloudStatus

* code clean-up

* remove cloudStatus

* bugfix

* remove console log

* remove cloudstatus

* set ha-list clientHeight to 49px
2026-01-10 12:57:33 +01:00
LG-ThinQ-Integration
f2293713de Add target_humidity_step to humidifier (#28005)
Co-authored-by: yunseon.park <yunseon.park@lge.com>
2026-01-10 10:12:55 +01:00
Brendan Annable
b3f202400c Fix timer restore bug (#28898) 2026-01-10 09:51:19 +01:00
ildar170975
010d87bd0d ha-dialog-automation-save: small improvements & fixes (#28561)
* explictly set line-height for "helper" element

* move "description" to bottom, css tweaks

* revert

* revert, make a helper persistent
2026-01-10 09:40:10 +01:00
karwosts
b403b8f09e Implement allow_negative for duration selector (#28909) 2026-01-10 08:58:14 +01:00
karwosts
b9a3dc795b Duration selector: migrate legacy duration formats (#28880) 2026-01-09 20:30:09 +01:00
Bram Kragten
35dbfdebcf Add support for choose selector to initial form data (#28876)
* Add support for choose selector to initial form data

* Update compute-initial-ha-form-data.ts
2026-01-09 19:57:32 +01:00
renovate[bot]
c5e5fb3ace Update formatjs monorepo (#28905)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 17:41:54 +00:00
Yosi Levy
e649472b20 Arrow fixes in media browser (#28890) 2026-01-09 18:31:25 +01:00
Yosi Levy
3cbb24a4c5 Fix for volume scroll in media player (#28891) 2026-01-09 18:30:45 +01:00
renovate[bot]
f92608a9d3 Update dependency @codemirror/view to v6.39.9 (#28903)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 18:23:11 +01:00
renovate[bot]
6591cdc5c1 Update dependency @rspack/core to v1.7.1 (#28892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 18:22:51 +01:00
renovate[bot]
0ae1ac367d Update dependency lit-html to v3.3.2 (#28762)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-09 08:32:27 +02:00
renovate[bot]
6d3a1b93e1 Update dependency lit to v3.3.2 (#28761)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 21:03:20 +01:00
renovate[bot]
6d7b22a21c Update dependency typescript-eslint to v8.52.0 (#28879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 20:54:45 +01:00
Petar Petrov
784ee22623 Removes duplicate closing tag in ha-auth-form-string (#28883) 2026-01-08 20:53:55 +01:00
Aidan Timson
c03654ef8e Fix wa dialog esc behaviour when preventing scrim closure (#28875)
* Fix wa dialog esc behaviour when preventing scrim closure

* Use wa-hide event to prevend closure
2026-01-08 17:10:53 +00:00
Pegasus
826cb3117d Fix: update the id, pan id to capitalize (#28873)
fix: update the id, pan id to capitalize
2026-01-08 12:26:49 +00:00
Aidan Timson
f77fa26ffe Fix type error for calendar card (#28869) 2026-01-08 12:57:46 +02:00
Bram Kragten
35e30f9184 Fix color palette creation (#28867) 2026-01-08 10:14:03 +00:00
DAccord
7dd3ade678 Handling empty history (#28852)
Co-authored-by: DAccord <11232265+DAccord@users.noreply.github.com>
2026-01-08 09:58:38 +00:00
karwosts
6d1e15d11a Add a devtools event listener filter (#28849) 2026-01-08 10:58:24 +01:00
Timothy
f5b33922ff Move companion app settings to a dedicated section in the settings (#28830) 2026-01-08 10:36:50 +01:00
dcapslock
ceb7baf851 Fix choose selector active_choice when card editor config changes (#28858) 2026-01-08 10:20:42 +01:00
ildar170975
d195fd3244 Views: allow showing both icon & text title (#28690) 2026-01-07 19:03:48 +00:00
renovate[bot]
231cd632d6 Update dependency @bundle-stats/plugin-webpack-filter to v4.21.8 (#28846)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-07 17:23:37 +01:00
Wendelin
82d72ea39c Fix logs provider picker mobile width (#28847) 2026-01-07 16:30:49 +01:00
Wendelin
022bebb14f Throttle unknown value checks in ha-generic-picker (#28842) 2026-01-07 16:14:33 +01:00
Paul Bottein
0981ae1b4a Prefill the field with current value when editing a custom text item (#28840) 2026-01-07 15:42:47 +01:00
Paul Bottein
9608824a28 Remove ha-combo-box-textfield (#28841) 2026-01-07 15:39:58 +01:00
Marcin Bauer
33d215533e Add Shift+/ shortcut to shortcuts dialog and use Unicode command character (#28838)
* refactor: polish automation dialog UI and component styles

* Revert "Merge pull request #1 from marcinbauer85/fix/ui-polish-automation-dialog"

This reverts commit c2c47197e2, reversing
changes made to 49bed5e6a6.

* Add shortcuts dialog shortcut and use Unicode command character

* Update shortcut description text
2026-01-07 14:17:18 +00:00
Paul Bottein
5c503ecac0 Reduce shadow effect for scrollable fade mixin (#28832) 2026-01-07 14:16:41 +00:00
Wendelin
d114693fed Improve device picker performance (#28835) 2026-01-07 14:28:18 +01:00
Kristel
7a8cb80413 Add Voice assistant column to data tables (#28785)
* added Voice assistant column to data tables

* remove commented code

* fix column settings

* code review changes

* reuse voice-assistants-expose-assistant-icon

* refactor getEntityVoiceAssistantsKeys

* fix column width

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-07 13:15:01 +00:00
Marcin Bauer
f5cd234c4b Refactor: Polish automation dialog UI and component styles (#28831)
* refactor: polish automation dialog UI and component styles

* Update ha-automation-row-targets.ts

- added borders to main automation list chips
2026-01-07 09:27:01 +00:00
karwosts
49bed5e6a6 Standardize all energy period calculations (#28827) 2026-01-07 08:46:54 +02:00
Bram Kragten
b84a51235d Prevent showing error during loading of statistics picker (#28823) 2026-01-06 17:40:53 +01:00
Bram Kragten
602d6a2337 Use target selector to filter references entities (#28822)
* Use target selector to filter references entities

* Update ha-selector-state.ts
2026-01-06 16:16:23 +01:00
Matthias Alphart
6e614cd3f2 Explicitly set ha-wa-dialog content color (#28821) 2026-01-06 16:00:15 +01:00
Paulus Schoutsen
6793edd68b Bluetooth panel to support multi adapter (#28763)
* Support multiple adapters in bluetooth panel

* Move connection allocations up

* Make it tabs

* Add icons

* Revert "Add icons"

This reverts commit e338b6e578.

* Revert "Make it tabs"

This reverts commit d1b19d5c3e.

* Fix scanner matching and no active connection slot support

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

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 16:46:43 +02:00
Bram Kragten
ad6e3267c3 Fix translation loading of choose selector (#28817) 2026-01-06 14:24:19 +01:00
Bram Kragten
f941117ca4 Remove iOS focus handling from dialogs (#28818) 2026-01-06 13:45:21 +01:00
Bram Kragten
aef0bf03e3 Use single path for thread icon, add KNX, simplify (#28819)
* Use single path for thread icon, simplify

* Add custom path for KNX
2026-01-06 12:43:05 +00:00
Simon Lamon
f22f6b74db Remove used from energy usage header (#28775)
* Remove used

* Update src/translations/en.json

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 11:37:27 +00:00
Aidan Timson
913c4ae24e Remove duplicate custom items, remove "no matching ..." when allow-custom-value set (#28801)
* Remove duplicate custom items, allow default from picker

* Memoize

* Memoize

* Memoize func

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

* Remove no items found label now unused

* Cleanup unused translations

* Restore used value

* Remove no items found label now unused

* Remove redundant comment

* Remove searchFn

* Ensure custom value isnt identical

* Fix duplicated value

* Fix duplicated value

* Use additional items for entity state content

* Fix duplicate values

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-01-06 11:16:54 +01:00
Matthias Alphart
4b7b5fa21a Replace unload event handler for custom panels with pagehide (#28781)
* Replace `unload` event handler for custom panels

* Handle restore from bfcache

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-06 09:07:52 +00:00
Norbert Rittel
bf6887541b Capitalize counter button labels (#28814)
Capitalized counter button labels
2026-01-06 08:47:51 +02:00
karwosts
26da9f3a37 Fix statistic-graph-card cutoff w/ energy date picker (#28810)
* Fix statistics-graph energy-date mode end-time with 5min statistics

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

* suggestedMax use period instead of days

* go back to string types
2026-01-05 17:28:15 +02:00
Aidan Timson
d48520efdf Add option for any state and show translated label for entity state values (#28803)
* Add option for any state

* Use translated labels for value
2026-01-05 16:55:23 +02:00
Aidan Timson
d462356122 Reapply "Migrate dialog-device-registry-detail to ha-wa-dialog (#27668)" (#28804)
* Reapply "Migrate dialog-device-registry-detail to ha-wa-dialog (#27668)" (#27716)

This reverts commit 5f75fc5bcb.

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-05 11:51:12 +00:00
Copilot
9a5cdb0a99 Display template targets with neutral badge instead of "Unknown area" error (#28799)
* Initial plan

* Add template target display with neutral badge

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

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: piitaya <5878303+piitaya@users.noreply.github.com>
2026-01-05 12:44:58 +01:00
Paul Bottein
eaf012d5ff Show close button when zwave firmware update is finished (#28805) 2026-01-05 13:42:55 +02:00
Paul Bottein
19934dad72 Remove custom value for unknown icon in icon picker (#28800) 2026-01-05 10:57:09 +00:00
Paul Bottein
6194f73442 Use regular item for bottom padding in combobox (#28798) 2026-01-05 11:40:54 +01:00
Paul Bottein
dbc880fe35 Add warning about running tsc with file arguments (#28797)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 10:08:21 +00:00
karwosts
be4e46a3c6 Fix statistic names w/ energy_date_selection (#28787) 2026-01-05 09:51:49 +02:00
renovate[bot]
2fce89a689 Update dependency globals to v17 (#28789)
* Update dependency globals to v17

* Add global definitions for audioWorklet in ESLint configuration

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-01-05 07:11:10 +00:00
renovate[bot]
81d21b0907 Update formatjs monorepo (#28793)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 09:04:20 +02:00
dependabot[bot]
65381b1dc5 Bump relative-ci/agent-action from 3.2.1 to 3.2.2 (#28792)
Bumps [relative-ci/agent-action](https://github.com/relative-ci/agent-action) from 3.2.1 to 3.2.2.
- [Release notes](https://github.com/relative-ci/agent-action/releases)
- [Commits](c45aaa919e...3c68192601)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 08:51:48 +02:00
Norbert Rittel
7cbede2f6e A few small spelling fixes in user-facing strings (#28786)
- use correct spelling for "Wi-Fi" trademark
- capitalize "PIN" as abbreviation
- fix spelling of "set up" as verb
- fix sentence-casing
2026-01-04 18:07:40 +01:00
renovate[bot]
0a13dddaea Update dependency @rspack/core to v1.7.0 (#28774)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-04 09:12:12 +00:00
renovate[bot]
662be980e8 Update dependency @rspack/dev-server to v1.1.5 (#28773)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-04 10:03:10 +01:00
renovate[bot]
209abf466d Update dependency @codemirror/view to v6.39.8 (#28759)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-02 19:29:57 +01:00
Simon Lamon
db9a3bd562 Fix matter translations (#28752) 2026-01-02 11:22:45 +01:00
Paulus Schoutsen
36ecaa6610 Add config entry picker for Z-Wave JS panel (#28741) 2026-01-02 11:20:42 +01:00
Simon Lamon
4f46d0f4a3 Make cancel a secondary action in blueprint import (#28754) 2026-01-02 11:18:37 +01:00
Paulus Schoutsen
42ad47649d Verify bluetooth config entries exist before showing entry (#28745) 2026-01-02 11:18:02 +01:00
dependabot[bot]
c62ee6e692 Bump qs from 6.14.0 to 6.14.1 (#28760)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-02 11:16:37 +01:00
Simon Lamon
b38c8d7d5f Revert lit update (#28751) 2026-01-02 11:13:09 +01:00
renovate[bot]
83bcc39d5f Update dependency typescript-eslint to v8.51.0 (#28756) 2026-01-02 09:54:41 +01:00
Paulus Schoutsen
8d317d1e2c Hide dashboard controls in kiosk mode (#28742) 2026-01-01 00:36:49 +01:00
Simon Lamon
9acad2e83c Provide kioskmode in demo (#28739) 2025-12-30 22:45:01 +01:00
ildar170975
9099c5a92c Map card editor: add a basic sub-element editor (#28687)
* add subelement editor

* explicit type convertion

* test

* test

* test

* test

* prettier
2025-12-30 20:18:57 +01:00
Paulus Schoutsen
60c4d60d66 Protocol link updates (#28736)
* Update icons Thread & Insteon

* Remove matter link

* Remove back path from ZHA

* Fix ZHA dashboard config entry
2025-12-30 19:54:48 +01:00
sebcaps
e8a4cde643 Add energy percentage usage on pie chart view. (#28733)
* showPercent

* unnecessary change
2025-12-30 19:54:35 +01:00
renovate[bot]
148eab31b6 Update dependency jsdom to v27.4.0 (#28726)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-29 19:18:29 +01:00
370 changed files with 18812 additions and 7057 deletions

View File

@@ -22,11 +22,13 @@ You are an assistant helping with development of the Home Assistant frontend. Th
```bash
yarn lint # ESLint + Prettier + TypeScript + Lit
yarn format # Auto-fix ESLint + Prettier
yarn lint:types # TypeScript compiler
yarn lint:types # TypeScript compiler (run WITHOUT file arguments)
yarn test # Vitest
script/develop # Development server
```
> **WARNING:** Never run `tsc` or `yarn lint:types` with file arguments (e.g., `yarn lint:types src/file.ts`). When `tsc` receives file arguments, it ignores `tsconfig.json` and emits `.js` files into `src/`, polluting the codebase. Always run `yarn lint:types` without arguments. For individual file type checking, rely on IDE diagnostics. If `.js` files are accidentally generated, clean up with `git clean -fd src/`.
### Component Prefixes
- `ha-` - Home Assistant components
@@ -619,7 +621,6 @@ this.hass.localize("ui.panel.config.updates.update_available", {
#### Key Terminology
- **"add-on"** (hyphenated, not "addon")
- **"integration"** (preferred over "component")
- **Technical terms**: Use lowercase (automation, entity, device, service)
@@ -711,7 +712,7 @@ this.hass.localize("ui.panel.config.automation.delete_confirm", {
- [ ] American English spelling
- [ ] Friendly, informational tone
- [ ] Avoids abbreviations and jargon
- [ ] Correct terminology (add-on not addon, integration not component)
- [ ] Correct terminology (integration not component)
### Component-Specific Checks

View File

@@ -26,7 +26,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -61,7 +61,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -26,7 +26,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -37,7 +37,7 @@ jobs:
- name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache
uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
uses: actions/cache@8b402f58fbc84540c8b491a91e594a4576fec3d7 # v5.0.2
with:
path: |
node_modules/.cache/prettier
@@ -60,7 +60,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -78,7 +78,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -94,6 +94,13 @@ jobs:
name: frontend-bundle-stats
path: build/stats/*.json
if-no-files-found: error
- name: Upload frontend build
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: frontend-build
path: hass_frontend/
if-no-files-found: error
retention-days: 7
supervisor:
name: Build supervisor
needs: [lint, test]
@@ -102,7 +109,7 @@ jobs:
- name: Check out files from GitHub
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -36,14 +36,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/init@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/autobuild@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
# 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@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9
uses: github/codeql-action/analyze@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10

View File

@@ -27,7 +27,7 @@ jobs:
ref: dev
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -62,7 +62,7 @@ jobs:
ref: master
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -19,7 +19,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

@@ -28,7 +28,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

View File

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

View File

@@ -19,8 +19,11 @@ jobs:
release:
name: Release
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: write # Required to upload release assets
id-token: write # For "Trusted Publisher" to PyPi
if: github.repository_owner == 'home-assistant'
steps:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
@@ -34,7 +37,7 @@ jobs:
uses: home-assistant/actions/helpers/verify-version@master
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -46,14 +49,18 @@ jobs:
run: ./script/translations_download
env:
LOKALISE_TOKEN: ${{ secrets.LOKALISE_TOKEN }}
- name: Build and release package
run: |
python3 -m pip install twine build
export TWINE_USERNAME="__token__"
export TWINE_PASSWORD="${{ secrets.TWINE_TOKEN }}"
python3 -m pip install build
export SKIP_FETCH_NIGHTLY_TRANSLATIONS=1
script/release
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
with:
skip-existing: true
- name: Upload release assets
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
@@ -93,7 +100,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn
@@ -122,7 +129,7 @@ jobs:
- name: Checkout the repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version-file: ".nvmrc"
cache: yarn

2
.gitignore vendored
View File

@@ -15,7 +15,7 @@ dist/
!.yarn/sdks
!.yarn/versions
.pnp.*
/node_modules/
node_modules/
yarn-error.log
npm-debug.log

2
.nvmrc
View File

@@ -1 +1 @@
24.12.0
24.13.0

View File

@@ -205,6 +205,7 @@ const createRspackConfig = ({
"lit/decorators$": "lit/decorators.js",
"lit/directive$": "lit/directive.js",
"lit/directives/until$": "lit/directives/until.js",
"lit/directives/ref$": "lit/directives/ref.js",
"lit/directives/class-map$": "lit/directives/class-map.js",
"lit/directives/style-map$": "lit/directives/style-map.js",
"lit/directives/if-defined$": "lit/directives/if-defined.js",
@@ -213,7 +214,9 @@ const createRspackConfig = ({
"lit/directives/join$": "lit/directives/join.js",
"lit/directives/repeat$": "lit/directives/repeat.js",
"lit/directives/live$": "lit/directives/live.js",
"lit/directives/keyed$": "lit/directives/keyed.js",
"lit/directives/keyed$": latestBuild
? "lit/directives/keyed.js"
: path.resolve(__dirname, "../src/common/lit/keyed-es5.ts"),
"lit/polyfill-support$": "lit/polyfill-support.js",
"@lit-labs/virtualizer/layouts/grid":
"@lit-labs/virtualizer/layouts/grid.js",

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../src/data/area_registry";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAreaRegistry = (

View File

@@ -187,5 +187,11 @@ export default tseslint.config(
],
"no-use-before-define": "off",
},
},
{
files: ["src/util/recorder-worklet.js"],
languageOptions: {
globals: globals.audioWorklet,
},
}
);

View File

@@ -10,7 +10,7 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
import { computeInitialHaFormData } from "../../../../src/components/ha-form/compute-initial-ha-form-data";
import "../../../../src/components/ha-form/ha-form";
import type { HaFormSchema } from "../../../../src/components/ha-form/types";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
@@ -169,7 +169,7 @@ const SCHEMAS: {
{
title: "Selectors",
translations: {
addon: "Addon",
addon: "App",
entity: "Entity",
device: "Device",
area: "Area",

View File

@@ -11,7 +11,7 @@ import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
import "../../../../src/components/ha-formfield";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area_registry";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { BlueprintInput } from "../../../../src/data/blueprint";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { FloorRegistryEntry } from "../../../../src/data/floor_registry";
@@ -239,7 +239,7 @@ const SCHEMAS: {
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
addon: { name: "Addon", selector: { addon: {} } },
addon: { name: "App", selector: { addon: {} } },
number_box: {
name: "Number Box",
selector: {

View File

@@ -83,10 +83,10 @@ export class HassioAddonRepositoryEl extends LitElement {
? this.supervisor.localize(
"common.new_version_available"
)
: this.supervisor.localize("addon.state.installed")
: this.supervisor.localize("app.state.installed")
: addon.available
? this.supervisor.localize("addon.state.not_installed")
: this.supervisor.localize("addon.state.not_available")}
? this.supervisor.localize("app.state.not_installed")
: this.supervisor.localize("app.state.not_available")}
.iconClass=${addon.installed
? addon.update_available
? "update"

View File

@@ -120,7 +120,7 @@ export class HassioAddonStore extends LitElement {
? html`
<div class="advanced">
<a href="/profile" target="_top">
${this.supervisor.localize("store.missing_addons")}
${this.supervisor.localize("store.missing_apps")}
</a>
</div>
`

View File

@@ -44,7 +44,7 @@ class HassioAddonAudio extends LitElement {
return html`
<ha-card
outlined
.header=${this.supervisor.localize("addon.configuration.audio.header")}
.header=${this.supervisor.localize("app.configuration.audio.header")}
>
<div class="card-content">
${this._error
@@ -52,9 +52,7 @@ class HassioAddonAudio extends LitElement {
: nothing}
${this._inputDevices &&
html`<ha-select
.label=${this.supervisor.localize(
"addon.configuration.audio.input"
)}
.label=${this.supervisor.localize("app.configuration.audio.input")}
@selected=${this._setInputDevice}
@closed=${stopPropagation}
fixedMenuPosition
@@ -72,9 +70,7 @@ class HassioAddonAudio extends LitElement {
</ha-select>`}
${this._outputDevices &&
html`<ha-select
.label=${this.supervisor.localize(
"addon.configuration.audio.output"
)}
.label=${this.supervisor.localize("app.configuration.audio.output")}
@selected=${this._setOutputDevice}
@closed=${stopPropagation}
fixedMenuPosition
@@ -153,7 +149,7 @@ class HassioAddonAudio extends LitElement {
const noDevice: HassioHardwareAudioDevice = {
device: "default",
name: this.supervisor.localize("addon.configuration.audio.default"),
name: this.supervisor.localize("app.configuration.audio.default"),
};
try {

View File

@@ -81,7 +81,7 @@ class HassioAddonConfigDashboard extends LitElement {
`
: nothing}
`
: this.supervisor.localize("addon.configuration.no_configuration")}
: this.supervisor.localize("app.configuration.no_configuration")}
</div>
`;
}

View File

@@ -219,7 +219,7 @@ class HassioAddonConfig extends LitElement {
<ha-card outlined>
<div class="header">
<h2>
${this.supervisor.localize("addon.configuration.options.header")}
${this.supervisor.localize("app.configuration.options.header")}
</h2>
<div class="card-menu">
<ha-button-menu @action=${this._handleAction}>
@@ -231,10 +231,10 @@ class HassioAddonConfig extends LitElement {
<ha-list-item .disabled=${!this._canShowSchema || this.disabled}>
${this._yamlMode
? this.supervisor.localize(
"addon.configuration.options.edit_in_ui"
"app.configuration.options.edit_in_ui"
)
: this.supervisor.localize(
"addon.configuration.options.edit_in_yaml"
"app.configuration.options.edit_in_yaml"
)}
</ha-list-item>
<ha-list-item
@@ -279,7 +279,7 @@ class HassioAddonConfig extends LitElement {
: html`
<ha-alert alert-type="error">
${this.supervisor.localize(
"addon.configuration.options.invalid_yaml"
"app.configuration.options.invalid_yaml"
)}
</ha-alert>
`}
@@ -288,7 +288,7 @@ class HassioAddonConfig extends LitElement {
? html`<ha-formfield
class="show-additional"
.label=${this.supervisor.localize(
"addon.configuration.options.show_unused_optional"
"app.configuration.options.show_unused_optional"
)}
>
<ha-switch
@@ -397,7 +397,7 @@ class HassioAddonConfig extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_reset", {
this._error = this.supervisor.localize("app.failed_to_reset", {
error: extractApiErrorMessage(err),
});
}
@@ -440,7 +440,7 @@ class HassioAddonConfig extends LitElement {
await suggestAddonRestart(this, this.hass, this.supervisor, this.addon);
}
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_save", {
this._error = this.supervisor.localize("app.failed_to_save", {
error: extractApiErrorMessage(err),
});
eventdata.success = false;

View File

@@ -56,14 +56,12 @@ class HassioAddonNetwork extends LitElement {
return html`
<ha-card
outlined
.header=${this.supervisor.localize(
"addon.configuration.network.header"
)}
.header=${this.supervisor.localize("app.configuration.network.header")}
>
<div class="card-content">
<p>
${this.supervisor.localize(
"addon.configuration.network.introduction"
"app.configuration.network.introduction"
)}
</p>
${this._error
@@ -87,7 +85,7 @@ class HassioAddonNetwork extends LitElement {
? html`<ha-formfield
class="show-optional"
.label=${this.supervisor.localize(
"addon.configuration.network.show_disabled"
"app.configuration.network.show_disabled"
)}
>
<ha-switch
@@ -187,7 +185,7 @@ class HassioAddonNetwork extends LitElement {
await suggestAddonRestart(this, this.hass, this.supervisor, this.addon);
}
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_reset", {
this._error = this.supervisor.localize("app.failed_to_reset", {
error: extractApiErrorMessage(err),
});
button.actionError();
@@ -229,7 +227,7 @@ class HassioAddonNetwork extends LitElement {
await suggestAddonRestart(this, this.hass, this.supervisor, this.addon);
}
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_save", {
this._error = this.supervisor.localize("app.failed_to_save", {
error: extractApiErrorMessage(err),
});
button.actionError();

View File

@@ -83,7 +83,7 @@ class HassioAddonDocumentationDashboard extends LitElement {
);
} catch (err: any) {
this._error = this.supervisor.localize(
"addon.documentation.get_documentation",
"app.documentation.get_documentation",
{ error: extractApiErrorMessage(err) }
);
}

View File

@@ -2,7 +2,7 @@ import {
mdiCogs,
mdiFileDocument,
mdiInformationVariant,
mdiMathLog,
mdiTextBoxOutline,
} from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
@@ -89,7 +89,7 @@ class HassioAddonDashboard extends LitElement {
const addonTabs: PageNavigation[] = [
{
translationKey: "addon.panel.info",
translationKey: "app.panel.info",
path: `/hassio/addon/${this.addon.slug}/info`,
iconPath: mdiInformationVariant,
},
@@ -97,7 +97,7 @@ class HassioAddonDashboard extends LitElement {
if (this.addon.documentation) {
addonTabs.push({
translationKey: "addon.panel.documentation",
translationKey: "app.panel.documentation",
path: `/hassio/addon/${this.addon.slug}/documentation`,
iconPath: mdiFileDocument,
});
@@ -106,14 +106,14 @@ class HassioAddonDashboard extends LitElement {
if (this.addon.version) {
addonTabs.push(
{
translationKey: "addon.panel.configuration",
translationKey: "app.panel.configuration",
path: `/hassio/addon/${this.addon.slug}/config`,
iconPath: mdiCogs,
},
{
translationKey: "addon.panel.log",
translationKey: "app.panel.log",
path: `/hassio/addon/${this.addon.slug}/logs`,
iconPath: mdiMathLog,
iconPath: mdiTextBoxOutline,
}
);
}
@@ -195,10 +195,10 @@ class HassioAddonDashboard extends LitElement {
) {
if (
!(await showConfirmationDialog(this, {
title: this.supervisor.localize("my.add_addon_repository_title"),
title: this.supervisor.localize("my.add_app_repository_title"),
text: this.supervisor.localize(
"my.add_addon_repository_description",
{ addon: requestedAddon, repository: requestedAddonRepository }
"my.add_app_repository_description",
{ app: requestedAddon, repository: requestedAddonRepository }
),
confirmText: this.supervisor.localize("common.add"),
dismissText: this.supervisor.localize("common.cancel"),
@@ -224,7 +224,7 @@ class HassioAddonDashboard extends LitElement {
(addon) => addon.slug === requestedAddon
);
if (!validAddon) {
this._error = this.supervisor.localize("my.error_addon_not_found");
this._error = this.supervisor.localize("my.error_app_not_found");
} else {
navigate(`/hassio/addon/${requestedAddon}`, { replace: true });
}

View File

@@ -150,11 +150,11 @@ class HassioAddonInfo extends LitElement {
: undefined;
const metrics = [
{
description: this.supervisor.localize("addon.dashboard.cpu_usage"),
description: this.supervisor.localize("app.dashboard.cpu_usage"),
value: this._metrics?.cpu_percent,
},
{
description: this.supervisor.localize("addon.dashboard.ram_usage"),
description: this.supervisor.localize("app.dashboard.ram_usage"),
value: this._metrics?.memory_percent,
tooltip: `${bytesToString(this._metrics?.memory_usage)}/${bytesToString(
this._metrics?.memory_limit
@@ -181,11 +181,11 @@ class HassioAddonInfo extends LitElement {
<ha-alert
alert-type="error"
.title=${this.supervisor.localize(
"addon.dashboard.protection_mode.title"
"app.dashboard.protection_mode.title"
)}
>
${this.supervisor.localize(
"addon.dashboard.protection_mode.content"
"app.dashboard.protection_mode.content"
)}
<ha-button
variant="danger"
@@ -193,7 +193,7 @@ class HassioAddonInfo extends LitElement {
@click=${this._protectionToggled}
>
${this.supervisor.localize(
"addon.dashboard.protection_mode.enable"
"app.dashboard.protection_mode.enable"
)}
</ha-button>
</ha-alert>
@@ -220,7 +220,7 @@ class HassioAddonInfo extends LitElement {
? html`
<ha-svg-icon
.title=${this.supervisor.localize(
"dashboard.addon_running"
"dashboard.app_running"
)}
class="running"
.path=${mdiPlayCircle}
@@ -229,7 +229,7 @@ class HassioAddonInfo extends LitElement {
: html`
<ha-svg-icon
.title=${this.supervisor.localize(
"dashboard.addon_stopped"
"dashboard.app_stopped"
)}
class="stopped"
.path=${mdiCircleOffOutline}
@@ -242,22 +242,19 @@ class HassioAddonInfo extends LitElement {
<div class="description light-color">
${this.addon.version
? html`
${this.supervisor.localize(
"addon.dashboard.current_version",
{ version: this.addon.version }
)}
${this.supervisor.localize("app.dashboard.current_version", {
version: this.addon.version,
})}
<div class="changelog" @click=${this._openChangelog}>
(<span class="changelog-link"
>${this.supervisor.localize(
"addon.dashboard.changelog"
"app.dashboard.changelog"
)}</span
>)
</div>
`
: html`<span class="changelog-link" @click=${this._openChangelog}
>${this.supervisor.localize(
"addon.dashboard.changelog"
)}</span
>${this.supervisor.localize("app.dashboard.changelog")}</span
>`}
</div>
@@ -274,7 +271,7 @@ class HassioAddonInfo extends LitElement {
id="stage"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
`addon.dashboard.capability.stages.${this.addon.stage}`
`app.dashboard.capability.stages.${this.addon.stage}`
)
)}
>
@@ -298,7 +295,7 @@ class HassioAddonInfo extends LitElement {
id="rating"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.rating"
"app.dashboard.capability.label.rating"
)
)}
>
@@ -313,7 +310,7 @@ class HassioAddonInfo extends LitElement {
id="host_network"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.host"
"app.dashboard.capability.label.host"
)
)}
>
@@ -329,7 +326,7 @@ class HassioAddonInfo extends LitElement {
id="full_access"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.hardware"
"app.dashboard.capability.label.hardware"
)
)}
>
@@ -345,7 +342,7 @@ class HassioAddonInfo extends LitElement {
id="homeassistant_api"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.core"
"app.dashboard.capability.label.core"
)
)}
>
@@ -364,7 +361,7 @@ class HassioAddonInfo extends LitElement {
id="hassio_api"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
`addon.dashboard.capability.role.${this.addon.hassio_role}`
`app.dashboard.capability.role.${this.addon.hassio_role}`
) || this.addon.hassio_role
)}
>
@@ -383,7 +380,7 @@ class HassioAddonInfo extends LitElement {
id="docker_api"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.docker"
"app.dashboard.capability.label.docker"
)
)}
>
@@ -399,7 +396,7 @@ class HassioAddonInfo extends LitElement {
id="host_pid"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.host_pid"
"app.dashboard.capability.label.host_pid"
)
)}
>
@@ -416,7 +413,7 @@ class HassioAddonInfo extends LitElement {
id="apparmor"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.apparmor"
"app.dashboard.capability.label.apparmor"
)
)}
>
@@ -432,7 +429,7 @@ class HassioAddonInfo extends LitElement {
id="auth_api"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.auth"
"app.dashboard.capability.label.auth"
)
)}
>
@@ -448,7 +445,7 @@ class HassioAddonInfo extends LitElement {
id="ingress"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.ingress"
"app.dashboard.capability.label.ingress"
)
)}
>
@@ -467,7 +464,7 @@ class HassioAddonInfo extends LitElement {
id="signed"
.label=${capitalizeFirstLetter(
this.supervisor.localize(
"addon.dashboard.capability.label.signed"
"app.dashboard.capability.label.signed"
)
)}
>
@@ -482,7 +479,7 @@ class HassioAddonInfo extends LitElement {
@click=${this._showSystemManagedDialog}
id="system_managed"
.label=${capitalizeFirstLetter(
this.supervisor.localize("addon.system_managed.badge")
this.supervisor.localize("app.system_managed.badge")
)}
>
<ha-svg-icon
@@ -496,7 +493,7 @@ class HassioAddonInfo extends LitElement {
<div class="description light-color">
${this.addon.description}.<br />
${this.supervisor.localize("addon.dashboard.visit_addon_page", {
${this.supervisor.localize("app.dashboard.visit_app_page", {
name: html`<a
href=${this.addon.url!}
target="_blank"
@@ -527,12 +524,12 @@ class HassioAddonInfo extends LitElement {
<ha-settings-row ?three-line=${this.narrow}>
<span slot="heading">
${this.supervisor.localize(
"addon.dashboard.option.boot.title"
"app.dashboard.option.boot.title"
)}
</span>
<span slot="description">
${this.supervisor.localize(
"addon.dashboard.option.boot.description"
"app.dashboard.option.boot.description"
)}
</span>
<ha-switch
@@ -548,12 +545,12 @@ class HassioAddonInfo extends LitElement {
<ha-settings-row ?three-line=${this.narrow}>
<span slot="heading">
${this.supervisor.localize(
"addon.dashboard.option.watchdog.title"
"app.dashboard.option.watchdog.title"
)}
</span>
<span slot="description">
${this.supervisor.localize(
"addon.dashboard.option.watchdog.description"
"app.dashboard.option.watchdog.description"
)}
</span>
<ha-switch
@@ -572,12 +569,12 @@ class HassioAddonInfo extends LitElement {
<ha-settings-row ?three-line=${this.narrow}>
<span slot="heading">
${this.supervisor.localize(
"addon.dashboard.option.auto_update.title"
"app.dashboard.option.auto_update.title"
)}
</span>
<span slot="description">
${this.supervisor.localize(
"addon.dashboard.option.auto_update.description"
"app.dashboard.option.auto_update.description"
)}
</span>
<ha-switch
@@ -595,12 +592,12 @@ class HassioAddonInfo extends LitElement {
<ha-settings-row ?three-line=${this.narrow}>
<span slot="heading">
${this.supervisor.localize(
"addon.dashboard.option.ingress_panel.title"
"app.dashboard.option.ingress_panel.title"
)}
</span>
<span slot="description">
${this.supervisor.localize(
"addon.dashboard.option.ingress_panel.description"
"app.dashboard.option.ingress_panel.description"
)}
</span>
<ha-switch
@@ -618,12 +615,12 @@ class HassioAddonInfo extends LitElement {
<ha-settings-row ?three-line=${this.narrow}>
<span slot="heading">
${this.supervisor.localize(
"addon.dashboard.option.protected.title"
"app.dashboard.option.protected.title"
)}
</span>
<span slot="description">
${this.supervisor.localize(
"addon.dashboard.option.protected.description"
"app.dashboard.option.protected.description"
)}
</span>
<ha-switch
@@ -644,7 +641,7 @@ class HassioAddonInfo extends LitElement {
${this.addon.version && this.addon.state === "started"
? html`<ha-settings-row ?three-line=${this.narrow}>
<span slot="heading">
${this.supervisor.localize("addon.dashboard.hostname")}
${this.supervisor.localize("app.dashboard.hostname")}
</span>
<code slot="description"> ${this.addon.hostname} </code>
</ha-settings-row>
@@ -671,14 +668,14 @@ class HassioAddonInfo extends LitElement {
? html`
<ha-alert alert-type="warning">
${this.supervisor.localize(
"addon.dashboard.not_available_arch"
"app.dashboard.not_available_arch"
)}
</ha-alert>
`
: html`
<ha-alert alert-type="warning">
${this.supervisor.localize(
"addon.dashboard.not_available_version",
"app.dashboard.not_available_version",
{
core_version_installed: this.supervisor.core.version,
core_version_needed: addonStoreInfo!.homeassistant,
@@ -699,14 +696,14 @@ class HassioAddonInfo extends LitElement {
@click=${this._stopClicked}
.disabled=${systemManaged && !this.controlEnabled}
>
${this.supervisor.localize("addon.dashboard.stop")}
${this.supervisor.localize("app.dashboard.stop")}
</ha-progress-button>
<ha-progress-button
variant="danger"
appearance="plain"
@click=${this._restartClicked}
>
${this.supervisor.localize("addon.dashboard.restart")}
${this.supervisor.localize("app.dashboard.restart")}
</ha-progress-button>
`
: html`
@@ -715,7 +712,7 @@ class HassioAddonInfo extends LitElement {
.progress=${this.addon.state === "startup"}
appearance="plain"
>
${this.supervisor.localize("addon.dashboard.start")}
${this.supervisor.localize("app.dashboard.start")}
</ha-progress-button>
`
: nothing}
@@ -729,7 +726,7 @@ class HassioAddonInfo extends LitElement {
@click=${this._uninstallClicked}
.disabled=${systemManaged && !this.controlEnabled}
>
${this.supervisor.localize("addon.dashboard.uninstall")}
${this.supervisor.localize("app.dashboard.uninstall")}
</ha-progress-button>
${this.addon.build
? html`
@@ -738,7 +735,7 @@ class HassioAddonInfo extends LitElement {
appearance="plain"
@click=${this._rebuildClicked}
>
${this.supervisor.localize("addon.dashboard.rebuild")}
${this.supervisor.localize("app.dashboard.rebuild")}
</ha-progress-button>
`
: nothing}
@@ -761,7 +758,7 @@ class HassioAddonInfo extends LitElement {
: undefined}
>
${this.supervisor.localize(
"addon.dashboard.open_web_ui"
"app.dashboard.open_web_ui"
)}
</ha-button>
`
@@ -772,7 +769,7 @@ class HassioAddonInfo extends LitElement {
.disabled=${!this.addon.available}
@click=${this._installClicked}
>
${this.supervisor.localize("addon.dashboard.install")}
${this.supervisor.localize("app.dashboard.install")}
</ha-progress-button>
`}
</div>
@@ -804,7 +801,7 @@ class HassioAddonInfo extends LitElement {
"state" in this.addon &&
this.addon.state === "startup"
) {
// Addon is starting up, wait for it to start
// App is starting up, wait for it to start
this._scheduleDataUpdate();
}
}
@@ -858,11 +855,11 @@ class HassioAddonInfo extends LitElement {
private _showMoreInfo(ev): void {
const id = ev.currentTarget.id as AddonCapability;
showHassioMarkdownDialog(this, {
title: this.supervisor.localize(`addon.dashboard.capability.${id}.title`),
title: this.supervisor.localize(`app.dashboard.capability.${id}.title`),
content:
id === "stage"
? this.supervisor.localize(
`addon.dashboard.capability.${id}.description`,
`app.dashboard.capability.${id}.description`,
{
icon_stable: `<ha-svg-icon path="${STAGE_ICON.stable}"></ha-svg-icon>`,
icon_experimental: `<ha-svg-icon path="${STAGE_ICON.experimental}"></ha-svg-icon>`,
@@ -870,7 +867,7 @@ class HassioAddonInfo extends LitElement {
}
)
: this.supervisor.localize(
`addon.dashboard.capability.${id}.description`
`app.dashboard.capability.${id}.description`
),
});
}
@@ -902,7 +899,7 @@ class HassioAddonInfo extends LitElement {
}
private _openIngress(): void {
navigate(`/hassio/ingress/${this.addon.slug}`);
navigate(`/app/${this.addon.slug}`);
}
private get _computeShowIngressUI(): boolean {
@@ -936,7 +933,7 @@ class HassioAddonInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_save", {
this._error = this.supervisor.localize("app.failed_to_save", {
error: extractApiErrorMessage(err),
});
}
@@ -956,7 +953,7 @@ class HassioAddonInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_save", {
this._error = this.supervisor.localize("app.failed_to_save", {
error: extractApiErrorMessage(err),
});
}
@@ -976,7 +973,7 @@ class HassioAddonInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_save", {
this._error = this.supervisor.localize("app.failed_to_save", {
error: extractApiErrorMessage(err),
});
}
@@ -996,7 +993,7 @@ class HassioAddonInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_save", {
this._error = this.supervisor.localize("app.failed_to_save", {
error: extractApiErrorMessage(err),
});
}
@@ -1016,7 +1013,7 @@ class HassioAddonInfo extends LitElement {
};
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
this._error = this.supervisor.localize("addon.failed_to_save", {
this._error = this.supervisor.localize("app.failed_to_save", {
error: extractApiErrorMessage(err),
});
}
@@ -1030,13 +1027,13 @@ class HassioAddonInfo extends LitElement {
);
showHassioMarkdownDialog(this, {
title: this.supervisor.localize("addon.dashboard.changelog"),
title: this.supervisor.localize("app.dashboard.changelog"),
content: extractChangelog(this.addon as HassioAddonDetails, content),
});
} catch (err: any) {
showAlertDialog(this, {
title: this.supervisor.localize(
"addon.dashboard.action_error.get_changelog"
"app.dashboard.action_error.get_changelog"
),
text: extractApiErrorMessage(err),
});
@@ -1066,7 +1063,7 @@ class HassioAddonInfo extends LitElement {
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
showAlertDialog(this, {
title: this.supervisor.localize("addon.dashboard.action_error.install"),
title: this.supervisor.localize("app.dashboard.action_error.install"),
text: extractApiErrorMessage(err),
});
}
@@ -1091,7 +1088,7 @@ class HassioAddonInfo extends LitElement {
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
showAlertDialog(this, {
title: this.supervisor.localize("addon.dashboard.action_error.stop"),
title: this.supervisor.localize("app.dashboard.action_error.stop"),
text: extractApiErrorMessage(err),
});
}
@@ -1112,7 +1109,7 @@ class HassioAddonInfo extends LitElement {
fireEvent(this, "hass-api-called", eventdata);
} catch (err: any) {
showAlertDialog(this, {
title: this.supervisor.localize("addon.dashboard.action_error.restart"),
title: this.supervisor.localize("app.dashboard.action_error.restart"),
text: extractApiErrorMessage(err),
});
}
@@ -1127,7 +1124,7 @@ class HassioAddonInfo extends LitElement {
await rebuildLocalAddon(this.hass, this.addon.slug);
} catch (err: any) {
showAlertDialog(this, {
title: this.supervisor.localize("addon.dashboard.action_error.rebuild"),
title: this.supervisor.localize("app.dashboard.action_error.rebuild"),
text: extractApiErrorMessage(err),
});
}
@@ -1145,12 +1142,12 @@ class HassioAddonInfo extends LitElement {
if (!validate.valid) {
await showConfirmationDialog(this, {
title: this.supervisor.localize(
"addon.dashboard.action_error.start_invalid_config"
"app.dashboard.action_error.start_invalid_config"
),
text: validate.message.split(" Got ")[0],
confirm: () => this._openConfiguration(),
confirmText: this.supervisor.localize(
"addon.dashboard.action_error.go_to_config"
"app.dashboard.action_error.go_to_config"
),
dismissText: this.supervisor.localize("common.cancel"),
});
@@ -1162,7 +1159,7 @@ class HassioAddonInfo extends LitElement {
button.actionError();
button.progress = false;
showAlertDialog(this, {
title: "Failed to validate addon configuration",
title: "Failed to validate app configuration",
text: extractApiErrorMessage(err),
});
return;
@@ -1181,7 +1178,7 @@ class HassioAddonInfo extends LitElement {
button.actionError();
button.progress = false;
showAlertDialog(this, {
title: this.supervisor.localize("addon.dashboard.action_error.start"),
title: this.supervisor.localize("app.dashboard.action_error.start"),
text: extractApiErrorMessage(err),
});
return;
@@ -1207,13 +1204,13 @@ class HassioAddonInfo extends LitElement {
};
const confirmed = await showConfirmationDialog(this, {
title: this.supervisor.localize("dialog.uninstall_addon.title", {
title: this.supervisor.localize("dialog.uninstall_app.title", {
name: this.addon.name,
}),
text: html`
<ha-formfield
.label=${html`<p>
${this.supervisor.localize("dialog.uninstall_addon.remove_data")}
${this.supervisor.localize("dialog.uninstall_app.remove_data")}
</p>`}
>
<ha-switch
@@ -1223,7 +1220,7 @@ class HassioAddonInfo extends LitElement {
></ha-switch>
</ha-formfield>
`,
confirmText: this.supervisor.localize("dialog.uninstall_addon.uninstall"),
confirmText: this.supervisor.localize("dialog.uninstall_app.uninstall"),
dismissText: this.supervisor.localize("common.cancel"),
destructive: true,
});
@@ -1245,9 +1242,7 @@ class HassioAddonInfo extends LitElement {
button.actionSuccess();
} catch (err: any) {
showAlertDialog(this, {
title: this.supervisor.localize(
"addon.dashboard.action_error.uninstall"
),
title: this.supervisor.localize("app.dashboard.action_error.uninstall"),
text: extractApiErrorMessage(err),
});
button.actionError();

View File

@@ -19,14 +19,14 @@ class HassioAddonSystemManaged extends LitElement {
return html`
<ha-alert
alert-type="warning"
.title=${this.supervisor.localize("addon.system_managed.title")}
.title=${this.supervisor.localize("app.system_managed.title")}
.narrow=${this.narrow}
>
${this.supervisor.localize("addon.system_managed.description")}
${this.supervisor.localize("app.system_managed.description")}
${!this.hideButton
? html`
<ha-button slot="action" @click=${this._takeControl}>
${this.supervisor.localize("addon.system_managed.take_control")}
${this.supervisor.localize("app.system_managed.take_control")}
</ha-button>
`
: nothing}

View File

@@ -216,7 +216,7 @@ export class SupervisorBackupContent extends LitElement {
? html`
<ha-formfield
.label=${html`<supervisor-formfield-label
.label=${this.supervisor?.localize("backup.addons")}
.label=${this.supervisor?.localize("backup.apps")}
.iconPath=${mdiPuzzle}
>
</supervisor-formfield-label>`}

View File

@@ -33,13 +33,13 @@ class HassioAddons extends LitElement {
suffix
.filter=${this._filter}
@value-changed=${this._handleSearchChange}
.label=${this.supervisor.localize("dashboard.search_addons")}
.label=${this.supervisor.localize("dashboard.search_apps")}
>
</search-input>
</div>
<div class="content">
${!atLeastVersion(this.hass.config.version, 2021, 12)
? html`<h1>${this.supervisor.localize("dashboard.addons")}</h1>`
? html`<h1>${this.supervisor.localize("dashboard.apps")}</h1>`
: ""}
<div class="card-group">
${!this.supervisor.addon.addons.length
@@ -47,7 +47,7 @@ class HassioAddons extends LitElement {
<ha-card outlined>
<div class="card-content">
<button class="link" @click=${this._openStore}>
${this.supervisor.localize("dashboard.no_addons")}
${this.supervisor.localize("dashboard.no_apps")}
</button>
</div>
</ha-card>
@@ -67,14 +67,12 @@ class HassioAddons extends LitElement {
? mdiArrowUpBoldCircle
: mdiPuzzle}
.iconTitle=${addon.state !== "started"
? this.supervisor.localize("dashboard.addon_stopped")
? this.supervisor.localize("dashboard.app_stopped")
: addon.update_available!
? this.supervisor.localize(
"dashboard.addon_new_version"
"dashboard.app_new_version"
)
: this.supervisor.localize(
"dashboard.addon_running"
)}
: this.supervisor.localize("dashboard.app_running")}
.iconClass=${addon.update_available
? addon.state === "started"
? "update"

View File

@@ -39,7 +39,7 @@ class HassioDashboard extends LitElement {
.narrow=${this.narrow}
.route=${this.route}
back-path="/config"
.header=${this.supervisor.localize("panel.addons")}
.header=${this.supervisor.localize("panel.apps")}
>
<ha-icon-button
slot="toolbar-icon"
@@ -81,7 +81,7 @@ class HassioDashboard extends LitElement {
<span slot="header">
${this.supervisor.localize(
atLeastVersion(this.hass.config.version, 2021, 12)
? "panel.addons"
? "panel.apps"
: "panel.dashboard"
)}
</span>

View File

@@ -64,9 +64,9 @@ class HassioRepositoriesDialog extends LitElement {
repos
.filter(
(repo) =>
repo.slug !== "core" && // The core add-ons repository
repo.slug !== "local" && // Locally managed add-ons
repo.slug !== "a0d7b954" && // Home Assistant Community Add-ons
repo.slug !== "core" && // The core apps repository
repo.slug !== "local" && // Locally managed apps
repo.slug !== "a0d7b954" && // Home Assistant Community Apps
repo.slug !== "5c53de3b" && // The ESPHome repository
repo.slug !== "d5369777" // Music Assistant repository
)

View File

@@ -16,11 +16,11 @@ export const suggestAddonRestart = async (
addon: HassioAddonDetails
): Promise<void> => {
const confirmed = await showConfirmationDialog(element, {
title: supervisor.localize("dialog.restart_addon.title", {
title: supervisor.localize("dialog.restart_app.title", {
name: addon.name,
}),
text: supervisor.localize("dialog.restart_addon.text"),
confirmText: supervisor.localize("dialog.restart_addon.restart"),
text: supervisor.localize("dialog.restart_app.text"),
confirmText: supervisor.localize("dialog.restart_app.restart"),
dismissText: supervisor.localize("common.cancel"),
});
if (confirmed) {

View File

@@ -89,14 +89,12 @@ class HassioSystemManagedDialog extends LitElement {
? html`<img src=${addonImage} alt=${this._addon.name} />`
: html`<ha-svg-icon .path=${mdiPuzzle}></ha-svg-icon>`}
</div>
${this._supervisor.localize("addon.system_managed.title")}.<br />
${this._supervisor.localize("addon.system_managed.description")}
${this._supervisor.localize("app.system_managed.title")}.<br />
${this._supervisor.localize("app.system_managed.description")}
${this._configEntry
? html`
<h3>
${this._supervisor.localize(
"addon.system_managed.managed_by"
)}:
${this._supervisor.localize("app.system_managed.managed_by")}:
</h3>
<ha-md-list>
<ha-md-list-item

View File

@@ -14,7 +14,7 @@ export const supervisorTabs = (hass: HomeAssistant): PageNavigation[] =>
: [
{
translationKey: atLeastVersion(hass.config.version, 2021, 12)
? "panel.addons"
? "panel.apps"
: "panel.dashboard",
path: `/hassio/dashboard`,
iconPath: atLeastVersion(hass.config.version, 2021, 12)

View File

@@ -114,14 +114,14 @@ class HassioIngressView extends LitElement {
}
if (!addonInfo.version) {
await showAlertDialog(this, {
text: this.supervisor.localize("my.error_addon_not_installed"),
text: this.supervisor.localize("my.error_app_not_installed"),
title: addonInfo.name,
});
await nextRender();
navigate(`/hassio/addon/${addonInfo.slug}/info`, { replace: true });
} else if (!addonInfo.ingress) {
await showAlertDialog(this, {
text: this.supervisor.localize("my.error_addon_no_ingress"),
text: this.supervisor.localize("my.error_app_no_ingress"),
title: addonInfo.name,
});
await nextRender();
@@ -162,8 +162,8 @@ class HassioIngressView extends LitElement {
await this.updateComplete;
await showAlertDialog(this, {
text:
this.supervisor.localize("ingress.error_addon_info") ||
"Unable to fetch add-on info to start Ingress",
this.supervisor.localize("ingress.error_app_info") ||
"Unable to fetch app info to start Ingress",
title: "Supervisor",
});
await nextRender();
@@ -175,8 +175,8 @@ class HassioIngressView extends LitElement {
await this.updateComplete;
await showAlertDialog(this, {
text:
this.supervisor.localize("ingress.error_addon_not_installed") ||
"The add-on is not installed. Please install it first",
this.supervisor.localize("ingress.error_app_not_installed") ||
"The app is not installed. Please install it first",
title: addon.name,
});
await nextRender();
@@ -188,8 +188,8 @@ class HassioIngressView extends LitElement {
await this.updateComplete;
await showAlertDialog(this, {
text:
this.supervisor.localize("ingress.error_addon_not_supported") ||
"This add-on does not support Ingress",
this.supervisor.localize("ingress.error_app_not_supported") ||
"This app does not support Ingress",
title: addon.name,
});
await nextRender();
@@ -201,18 +201,18 @@ class HassioIngressView extends LitElement {
await this.updateComplete;
const confirm = await showConfirmationDialog(this, {
text:
this.supervisor.localize("ingress.error_addon_not_running") ||
"The add-on is not running. Do you want to start it now?",
this.supervisor.localize("ingress.error_app_not_running") ||
"The app is not running. Do you want to start it now?",
title: addon.name,
confirmText:
this.supervisor.localize("ingress.start_addon") || "Start add-on",
this.supervisor.localize("ingress.start_app") || "Start app",
dismissText: this.supervisor.localize("common.no") || "No",
});
if (confirm) {
try {
this._loadingMessage =
this.supervisor.localize("ingress.addon_starting") ||
"The add-on is starting, this can take some time...";
this.supervisor.localize("ingress.app_starting") ||
"The app is starting, this can take some time...";
await startHassioAddon(this.hass, addonSlug);
fireEvent(this, "supervisor-collection-refresh", {
collection: "addon",
@@ -222,8 +222,8 @@ class HassioIngressView extends LitElement {
} catch (_err) {
await showAlertDialog(this, {
text:
this.supervisor.localize("ingress.error_starting_addon") ||
"Error starting the add-on",
this.supervisor.localize("ingress.error_starting_app") ||
"Error starting the app",
title: addon.name,
});
await nextRender();
@@ -238,10 +238,10 @@ class HassioIngressView extends LitElement {
}
if (addon.state === "startup") {
// Addon is starting up, wait for it to start
// App is starting up, wait for it to start
this._loadingMessage =
this.supervisor.localize("ingress.addon_starting") ||
"The add-on is starting, this can take some time...";
this.supervisor.localize("ingress.app_starting") ||
"The app is starting, this can take some time...";
this._fetchDataTimeout = window.setTimeout(() => {
this._fetchData(addonSlug);
@@ -301,8 +301,8 @@ class HassioIngressView extends LitElement {
await this.updateComplete;
showConfirmationDialog(this, {
text:
this.supervisor.localize("ingress.error_addon_not_ready") ||
"The add-on seems to not be ready, it might still be starting. Do you want to try again?",
this.supervisor.localize("ingress.error_app_not_ready") ||
"The app seems to not be ready, it might still be starting. Do you want to try again?",
title: this._addon.name,
confirmText: this.supervisor.localize("ingress.retry") || "Retry",
dismissText: this.supervisor.localize("common.no") || "No",

View File

@@ -261,16 +261,16 @@ class UpdateAvailableCard extends LitElement {
private _computeCreateBackupTexts():
| { title: string; description?: string }
| undefined {
// Addon backup
// App backup
if (
this._updateType === "addon" &&
atLeastVersion(this.hass.config.version, 2025, 2, 0)
) {
const version = this._version;
return {
title: this.supervisor.localize("update_available.create_backup.addon"),
title: this.supervisor.localize("update_available.create_backup.app"),
description: this.supervisor.localize(
"update_available.create_backup.addon_description",
"update_available.create_backup.app_description",
{ version: version }
),
};
@@ -363,11 +363,11 @@ class UpdateAvailableCard extends LitElement {
)
) {
this._error = this.supervisor.localize(
"addon.dashboard.not_available_arch"
"app.dashboard.not_available_arch"
);
} else {
this._error = this.supervisor.localize(
"addon.dashboard.not_available_version",
"app.dashboard.not_available_version",
{
core_version_installed: this.supervisor.core.version,
core_version_needed: addonStoreInfo.homeassistant,

View File

@@ -1,4 +1,3 @@
import "@material/mwc-linear-progress/mwc-linear-progress";
import memoizeOne from "memoize-one";
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";

View File

@@ -26,26 +26,26 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.28.4",
"@babel/runtime": "7.28.6",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.20.0",
"@codemirror/commands": "6.10.1",
"@codemirror/language": "6.12.1",
"@codemirror/legacy-modes": "6.5.2",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.3",
"@codemirror/view": "6.39.7",
"@codemirror/search": "6.6.0",
"@codemirror/state": "6.5.4",
"@codemirror/view": "6.39.11",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.1.0",
"@formatjs/intl-displaynames": "7.1.0",
"@formatjs/intl-durationformat": "0.9.0",
"@formatjs/intl-getcanonicallocales": "3.1.0",
"@formatjs/intl-listformat": "8.1.0",
"@formatjs/intl-locale": "5.1.0",
"@formatjs/intl-numberformat": "9.1.0",
"@formatjs/intl-pluralrules": "6.1.0",
"@formatjs/intl-relativetimeformat": "12.1.0",
"@formatjs/intl-datetimeformat": "7.2.0",
"@formatjs/intl-displaynames": "7.2.0",
"@formatjs/intl-durationformat": "0.10.0",
"@formatjs/intl-getcanonicallocales": "3.2.0",
"@formatjs/intl-listformat": "8.2.0",
"@formatjs/intl-locale": "5.2.0",
"@formatjs/intl-numberformat": "9.2.0",
"@formatjs/intl-pluralrules": "6.2.0",
"@formatjs/intl-relativetimeformat": "12.2.0",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -112,7 +112,7 @@
"hls.js": "1.6.15",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.0.7",
"intl-messageformat": "11.1.0",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -133,7 +133,7 @@
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.7",
"ua-parser-js": "2.0.8",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -146,18 +146,18 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.28.5",
"@babel/core": "7.28.6",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.5",
"@babel/preset-env": "7.28.5",
"@bundle-stats/plugin-webpack-filter": "4.21.7",
"@babel/preset-env": "7.28.6",
"@bundle-stats/plugin-webpack-filter": "4.21.8",
"@lokalise/node-api": "15.6.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.0.3",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.4.0",
"@rspack/core": "1.6.8",
"@rspack/dev-server": "1.1.4",
"@rsdoctor/rspack-plugin": "1.5.0",
"@rspack/core": "1.7.2",
"@rspack/dev-server": "1.1.5",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.25",
"@types/chromecast-caf-sender": "1.0.11",
@@ -176,7 +176,7 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.0.16",
"@vitest/coverage-v8": "4.0.17",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
@@ -199,7 +199,7 @@
"gulp-rename": "2.1.0",
"html-minifier-terser": "7.2.0",
"husky": "9.1.7",
"jsdom": "27.3.0",
"jsdom": "27.4.0",
"jszip": "3.10.1",
"lint-staged": "16.2.7",
"lit-analyzer": "2.0.3",
@@ -207,17 +207,17 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.7.4",
"rspack-manifest-plugin": "5.2.0",
"prettier": "3.8.0",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.5",
"sinon": "21.0.1",
"tar": "7.5.2",
"terser-webpack-plugin": "5.3.16",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.3",
"typescript-eslint": "8.50.1",
"vite-tsconfig-paths": "6.0.3",
"vitest": "4.0.16",
"typescript-eslint": "8.53.0",
"vite-tsconfig-paths": "6.0.4",
"vitest": "4.0.17",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -229,13 +229,13 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
"globals": "16.5.0",
"globals": "17.0.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.12.0",
"volta": {
"node": "24.12.0"
"node": "24.13.0"
}
}

View File

@@ -1,5 +1,4 @@
#!/bin/sh
# Pushes a new version to PyPi.
# Stop on errors
set -e
@@ -12,5 +11,4 @@ yarn install
script/build_frontend
rm -rf dist home_assistant_frontend.egg-info
python3 -m build
python3 -m twine upload dist/*.whl --skip-existing
python3 -m build -q

View File

@@ -38,13 +38,11 @@ export class HaAuthFormString extends HaFormString {
}
</style>
<ha-auth-textfield
.type=${
!this.isPassword
.type=${!this.isPassword
? this.stringType
: this.unmaskedPassword
? "text"
: "password"
}
: "password"}
.label=${this.label}
.value=${this.data || ""}
.helper=${this.helper}
@@ -55,18 +53,17 @@ export class HaAuthFormString extends HaFormString {
.name=${this.schema.name}
.autocomplete=${this.schema.autocomplete}
?autofocus=${this.schema.autofocus}
.suffix=${
this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix
}
.validationMessage=${this.schema.required ? this.localize?.("ui.panel.page-authorize.form.error_required") : undefined}
.suffix=${this.isPassword
? // reserve some space for the icon.
html`<div style="width: 24px"></div>`
: this.schema.description?.suffix}
.validationMessage=${this.schema.required
? this.localize?.("ui.panel.page-authorize.form.error_required")
: undefined}
@input=${this._valueChanged}
@change=${this._valueChanged}
></ha-auth-textfield>
${this.renderIcon()}
</ha-auth-textfield>
></ha-auth-textfield>
${this.renderIcon()}
`;
}
}

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import type { FloorRegistryEntry } from "../../data/floor_registry";
export interface AreasFloorHierarchy {

View File

@@ -79,7 +79,7 @@ export const generateColorPalette = (
}
return steps.map((step) => {
const name = `color-${label}-${step}`;
const name = `ha-color-${label}-${step}`;
// Base color at 50%
if (step === 50) {

View File

@@ -93,8 +93,8 @@ export const calcDateRange = (
];
case "now-12m":
return [
calcDate(subMonths(today, 12), startOfMonth, hass.locale, hass.config),
calcDate(subMonths(today, 1), endOfMonth, hass.locale, hass.config),
calcDate(today, subMonths, hass.locale, hass.config, 12),
calcDate(today, subMonths, hass.locale, hass.config, 0),
];
case "now-1h":
return [

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../data/area_registry";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
export const computeAreaName = (area: AreaRegistryEntry): string | undefined =>
area.name?.trim();

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";

View File

@@ -1,4 +1,4 @@
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type { FloorRegistryEntry } from "../../../data/floor_registry";
import type { HomeAssistant } from "../../../types";

View File

@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { AreaRegistryEntry } from "../../../data/area_registry";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import type {
EntityRegistryDisplayEntry,

View File

@@ -0,0 +1,53 @@
/**
* ES5-compatible implementation of the keyed directive.
* Based on lit-html's keyed directive but written to avoid ES5 minification issues.
*
* This implementation avoids parameter destructuring in the update() method,
* which causes Terser with ecma: 5 to generate invalid references like `_k`.
*
* Used only for ES5 builds (legacy browsers). Modern builds use the original
* lit-html keyed directive.
*
* @see https://github.com/home-assistant/frontend/issues/28732
*/
// eslint-disable-next-line import/extensions
import { directive, Directive } from "lit-html/directive.js";
// eslint-disable-next-line import/extensions
import { setCommittedValue } from "lit-html/directive-helpers.js";
// eslint-disable-next-line lit/no-legacy-imports
import { nothing } from "lit-html";
// eslint-disable-next-line import/extensions
import type { Part } from "lit-html/directive.js";
class KeyedES5 extends Directive {
private _key: unknown = nothing;
render(k: unknown, v: unknown) {
this._key = k;
return v;
}
update(part: unknown, args: [unknown, unknown]) {
const k = args[0];
const v = args[1];
if (k !== this._key) {
// Clear the part before returning a value. The one-arg form of
// setCommittedValue sets the value to a sentinel which forces a
// commit the next render.
setCommittedValue(part as Part);
this._key = k;
}
return v;
}
}
/**
* Associates a renderable value with a unique key. When the key changes, the
* previous DOM is removed and disposed before rendering the next value, even
* if the value - such as a template - is the same.
*
* This is useful for forcing re-renders of stateful components, or working
* with code that expects new data to generate new HTML elements, such as some
* animation techniques.
*/
export const keyed = directive(KeyedES5);

14
src/common/url/route.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { Route } from "../../types";
export const computeRouteTail = (route: Route) => {
const dividerPos = route.path.indexOf("/", 1);
return dividerPos === -1
? {
prefix: route.prefix + route.path,
path: "",
}
: {
prefix: route.prefix + route.path.substring(0, dividerPos),
path: route.path.substring(dividerPos),
};
};

View File

@@ -1,6 +1,16 @@
// From https://github.com/epoberezkin/fast-deep-equal
// MIT License - Copyright (c) 2017 Evgeny Poberezkin
export const deepEqual = (a: any, b: any): boolean => {
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
}
export const deepEqual = (
a: any,
b: any,
options?: DeepEqualOptions
): boolean => {
if (a === b) {
return true;
}
@@ -18,7 +28,7 @@ export const deepEqual = (a: any, b: any): boolean => {
return false;
}
for (i = length; i-- !== 0; ) {
if (!deepEqual(a[i], b[i])) {
if (!deepEqual(a[i], b[i], options)) {
return false;
}
}
@@ -35,7 +45,7 @@ export const deepEqual = (a: any, b: any): boolean => {
}
}
for (i of a.entries()) {
if (!deepEqual(i[1], b.get(i[0]))) {
if (!deepEqual(i[1], b.get(i[0]), options)) {
return false;
}
}
@@ -93,11 +103,28 @@ export const deepEqual = (a: any, b: any): boolean => {
for (i = length; i-- !== 0; ) {
const key = keys[i];
if (!deepEqual(a[key], b[key])) {
if (!deepEqual(a[key], b[key], options)) {
return false;
}
}
// Compare Symbol properties if requested
if (options?.compareSymbols) {
const symbolsA = Object.getOwnPropertySymbols(a);
const symbolsB = Object.getOwnPropertySymbols(b);
if (symbolsA.length !== symbolsB.length) {
return false;
}
for (const sym of symbolsA) {
if (!Object.prototype.hasOwnProperty.call(b, sym)) {
return false;
}
if (!deepEqual(a[sym], b[sym], options)) {
return false;
}
}
}
return true;
}

View File

@@ -2,9 +2,13 @@ import { customElement, property, state } from "lit/decorators";
import { LitElement, html, css } from "lit";
import type { EChartsType } from "echarts/core";
import type { SankeySeriesOption } from "echarts/types/dist/echarts";
import type { CallbackDataParams } from "echarts/types/src/util/types";
import type {
CallbackDataParams,
ECElementEvent,
} from "echarts/types/src/util/types";
import memoizeOne from "memoize-one";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { ECOption } from "../../resources/echarts/echarts";
@@ -21,6 +25,7 @@ export interface Node {
label?: string;
color?: string;
passThrough?: boolean;
entityId?: string;
}
export interface Link {
source: string;
@@ -83,6 +88,7 @@ export class HaSankeyChart extends LitElement {
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@chart-click=${this._handleChartClick}
></ha-chart-base>`;
}
@@ -103,6 +109,22 @@ export class HaSankeyChart extends LitElement {
return null;
};
private _handleChartClick = (ev: CustomEvent<ECElementEvent>) => {
const detail = ev.detail;
// Only handle node clicks (not links)
if (detail.dataType !== "node") {
return;
}
const nodeId = (detail.data as Record<string, any>)?.id;
if (!nodeId) {
return;
}
const node = this.data.nodes.find((n) => n.id === nodeId);
if (node?.entityId) {
fireEvent(this, "node-click", { node });
}
};
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
@@ -294,4 +316,7 @@ declare global {
interface HTMLElementTagNameMap {
"ha-sankey-chart": HaSankeyChart;
}
interface HASSDomEvents {
"node-click": { node: Node };
}
}

View File

@@ -21,6 +21,7 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
const safeParseFloat = (value) => {
const parsed = parseFloat(value);
@@ -184,7 +185,7 @@ export class StateHistoryChartLine extends LitElement {
}
if (param.seriesName) {
return `${param.marker} ${param.seriesName}: ${value}`;
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})

View File

@@ -1364,6 +1364,9 @@ export class HaDataTable extends LitElement {
.mdc-data-table__header-cell > * {
transition: var(--float-start) 0.2s ease;
}
.mdc-data-table__header-cell--numeric > span {
transition: none;
}
.mdc-data-table__header-cell ha-svg-icon {
top: -3px;
position: absolute;

View File

@@ -1,9 +1,9 @@
import { expose } from "comlink";
import Fuse, { type FuseOptionKey } from "fuse.js";
import type { FuseOptionKey, IFuseOptions } from "fuse.js";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { ipCompare, stringCompare } from "../../common/string/compare";
import { stripDiacritics } from "../../common/string/strip-diacritics";
import { multiTermSearch } from "../../resources/fuseMultiTerm";
import type {
ClonedDataTableColumnData,
DataTableRowData,
@@ -11,46 +11,159 @@ import type {
SortingDirection,
} from "./ha-data-table";
const getSearchKeys = memoizeOne(
(columns: SortableColumnContainer): FuseOptionKey<DataTableRowData>[] => {
const searchKeys = new Set<string>();
interface FilterKeyConfig {
key: string;
filterKey?: string;
}
Object.entries(columns).forEach(([key, column]) => {
if (column.filterable) {
searchKeys.add(
column.filterKey
? `${column.valueColumn || key}.${column.filterKey}`
: key
);
}
});
return Array.from(searchKeys);
const getFilterKeys = memoizeOne(
(columns: SortableColumnContainer): FilterKeyConfig[] =>
Object.entries(columns)
.filter(([, column]) => column.filterable)
.map(([key, column]) => ({
key: column.valueColumn || key,
filterKey: column.filterKey,
}))
);
const getSearchableValue = (
row: DataTableRowData,
{ key, filterKey }: FilterKeyConfig
): string => {
let value = row[key];
if (value == null) {
return "";
}
);
const fuseIndex = memoizeOne(
(data: DataTableRowData[], keys: FuseOptionKey<DataTableRowData>[]) =>
Fuse.createIndex(keys, data)
);
if (filterKey && typeof value === "object" && !Array.isArray(value)) {
value = value[filterKey];
if (value == null) {
return "";
}
}
if (Array.isArray(value)) {
const stringValues = value
.filter((item) => item != null && typeof item !== "object")
.map(String);
return stripDiacritics(stringValues.join(" ").toLowerCase());
}
return stripDiacritics(String(value).toLowerCase());
};
/** Filters data using exact substring matching (all terms must match). */
const filterDataExact = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
if (terms.length === 1) {
const term = terms[0];
return data.filter((row) =>
filterKeys.some((config) =>
getSearchableValue(row, config).includes(term)
)
);
}
return data.filter((row) => {
const searchString = filterKeys
.map((config) => getSearchableValue(row, config))
.join(" ");
return terms.every((term) => searchString.includes(term));
});
};
const FUZZY_OPTIONS: IFuseOptions<DataTableRowData> = {
ignoreDiacritics: true,
isCaseSensitive: false,
threshold: 0.2, // Stricter than default 0.3
minMatchCharLength: 2,
ignoreLocation: true,
shouldSort: false,
};
interface FuseKeyConfig {
name: string | string[];
getFn: (row: DataTableRowData) => string;
}
/** Filters data using fuzzy matching with Fuse.js (all terms must match). */
const filterDataFuzzy = (
data: DataTableRowData[],
filterKeys: FilterKeyConfig[],
terms: string[]
): DataTableRowData[] => {
// Build Fuse.js search keys from filter keys
const fuseKeys: FuseKeyConfig[] = filterKeys.map((config) => ({
name: config.filterKey ? [config.key, config.filterKey] : config.key,
getFn: (row: DataTableRowData) => getSearchableValue(row, config),
}));
// Find minimum term length to adjust minMatchCharLength
const minTermLength = Math.min(...terms.map((t) => t.length));
const minMatchCharLength = Math.min(minTermLength, 2);
const fuse = new Fuse<DataTableRowData>(data, {
...FUZZY_OPTIONS,
keys: fuseKeys as FuseOptionKey<DataTableRowData>[],
minMatchCharLength,
});
// For single term, simple search
if (terms.length === 1) {
return fuse.search(terms[0]).map((r) => r.item);
}
// For multiple terms, all must match (AND logic)
const expression = {
$and: terms.map((term) => ({
$or: fuseKeys.map((key) => ({
$path: Array.isArray(key.name) ? key.name : [key.name],
$val: term,
})),
})),
};
return fuse.search(expression).map((r) => r.item);
};
/**
* Filters data with exact match priority and fuzzy fallback.
* - First tries exact substring matching
* - If exact matches found, returns only those
* - If no exact matches, falls back to fuzzy search with strict scoring
*/
const filterData = (
data: DataTableRowData[],
columns: SortableColumnContainer,
filter: string
) => {
filter = stripDiacritics(filter.toLowerCase());
): DataTableRowData[] => {
const normalizedFilter = stripDiacritics(filter.toLowerCase().trim());
if (filter === "") {
if (!normalizedFilter) {
return data;
}
const keys = getSearchKeys(columns);
const filterKeys = getFilterKeys(columns);
const index = fuseIndex(data, keys);
if (!filterKeys.length) {
return data;
}
return multiTermSearch<DataTableRowData>(data, filter, keys, index, {
threshold: 0.2, // reduce fuzzy matches in data tables
});
const terms = normalizedFilter.split(/\s+/);
// First, try exact substring matching
const exactMatches = filterDataExact(data, filterKeys, terms);
if (exactMatches.length > 0) {
return exactMatches;
}
// No exact matches, fall back to fuzzy search
return filterDataFuzzy(data, filterKeys, terms);
};
const sortData = (

View File

@@ -1,8 +1,9 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { fullEntitiesContext } from "../../data/context";
import type { DeviceAutomation } from "../../data/device/device_automation";
import {
@@ -11,11 +12,12 @@ import {
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { HomeAssistant } from "../../types";
import "../ha-generic-picker";
import "../ha-md-select";
import "../ha-md-select-option";
import type { PickerValueRenderer } from "../ha-picker-field";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";
export abstract class HaDeviceAutomationPicker<
T extends DeviceAutomation,
@@ -28,7 +30,7 @@ export abstract class HaDeviceAutomationPicker<
@property({ type: Object }) public value?: T;
@state() private _automations: T[] = [];
@state() private _automations?: T[];
// Trigger an empty render so we start with a clean DOM.
// paper-listbox does not like changing things around.
@@ -44,12 +46,6 @@ export abstract class HaDeviceAutomationPicker<
);
}
protected get UNKNOWN_AUTOMATION_TEXT() {
return this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
);
}
private _localizeDeviceAutomation: (
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
@@ -75,7 +71,7 @@ export abstract class HaDeviceAutomationPicker<
}
private get _value() {
if (!this.value) {
if (!this.value || !this._automations) {
return "";
}
@@ -88,7 +84,7 @@ export abstract class HaDeviceAutomationPicker<
);
if (idx === -1) {
return UNKNOWN_AUTOMATION_KEY;
return this.value.alias || this.value.type || "unknown";
}
return `${this._automations[idx].device_id}_${idx}`;
@@ -99,37 +95,21 @@ export abstract class HaDeviceAutomationPicker<
return nothing;
}
const value = this._value;
return html`
<ha-md-select
.label=${this.label}
.value=${value}
@change=${this._automationChanged}
@closed=${stopPropagation}
.disabled=${this._automations.length === 0}
>
${value === NO_AUTOMATION_KEY
? html`<ha-md-select-option .value=${NO_AUTOMATION_KEY}>
${this.NO_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${value === UNKNOWN_AUTOMATION_KEY
? html`<ha-md-select-option .value=${UNKNOWN_AUTOMATION_KEY}>
${this.UNKNOWN_AUTOMATION_TEXT}
</ha-md-select-option>`
: nothing}
${this._automations.map(
(automation, idx) => html`
<ha-md-select-option .value=${`${automation.device_id}_${idx}`}>
${this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
)}
</ha-md-select-option>
`
)}
</ha-md-select>
`;
return html`<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.value=${value}
.disabled=${!this._automations || this._automations.length === 0}
.getItems=${this._getItems(value, this._automations)}
@value-changed=${this._automationChanged}
.valueRenderer=${this._valueRenderer}
.unknownItemText=${this.hass.localize(
"ui.panel.config.devices.automation.actions.unknown_action"
)}
hide-clear-icon
>
</ha-generic-picker>`;
}
protected updated(changedProps) {
@@ -140,6 +120,57 @@ export abstract class HaDeviceAutomationPicker<
}
}
private _getItems = memoizeOne(
(value: string, automations: T[] | undefined) => {
if (!automations) {
return () => undefined;
}
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass,
this._entityReg,
automation
);
return {
id: `${automation.device_id}_${idx}`,
primary,
};
});
automationListItems.sort((a, b) =>
caseInsensitiveStringCompare(
a.primary,
b.primary,
this.hass.locale.language
)
);
if (value === NO_AUTOMATION_KEY) {
automationListItems.unshift({
id: NO_AUTOMATION_KEY,
primary: this.NO_AUTOMATION_TEXT,
});
}
return () => automationListItems;
}
);
private _valueRenderer: PickerValueRenderer = (value: string) => {
const automation = this._automations?.find(
(a, idx) => value === `${a.device_id}_${idx}`
);
const text = automation
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
return html`<span slot="headline">${text}</span>`;
};
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
@@ -161,13 +192,14 @@ export abstract class HaDeviceAutomationPicker<
this._renderEmpty = false;
}
private _automationChanged(ev) {
const value = ev.target.value;
if (!value || [UNKNOWN_AUTOMATION_KEY, NO_AUTOMATION_KEY].includes(value)) {
private _automationChanged(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value || NO_AUTOMATION_KEY === value) {
return;
}
const [deviceId, idx] = value.split("_");
const automation = this._automations[idx];
const automation = this._automations![idx];
if (automation.device_id !== deviceId) {
return;
}

View File

@@ -18,6 +18,7 @@ import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -94,7 +95,30 @@ export class HaDevicePicker extends LitElement {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
private _getDevicesMemoized = memoizeOne(getDevices);
private _getDevicesMemoized = memoizeOne(
(
_devices: HomeAssistant["devices"],
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string
) =>
getDevices(
this.hass,
configEntryLookup,
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value
)
);
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
@@ -110,7 +134,7 @@ export class HaDevicePicker extends LitElement {
private _getItems = () =>
this._getDevicesMemoized(
this.hass,
this.hass.devices,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,

View File

@@ -18,10 +18,7 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import {
NO_ITEMS_AVAILABLE_ID,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-sortable";
const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
@@ -184,18 +181,17 @@ export class HaEntityNamePicker extends LitElement {
.disabled=${this.disabled}
.required=${this.required && !value.length}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.rowRenderer=${rowRenderer}
.searchFn=${this._searchFn}
.notFoundLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.no_match"
)}
.value=${this._getPickerValue()}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.custom_name"
)}
@value-changed=${this._pickerValueChanged}
.searchFn=${this._searchFn}
.searchLabel=${this.hass.localize(
"ui.components.entity.entity-name-picker.search"
)}
>
<div slot="field" class="container">
<ha-sortable
@@ -279,6 +275,11 @@ export class HaEntityNamePicker extends LitElement {
this._editIndex = idx;
await this.updateComplete;
await this._picker?.open();
const value = this._items[idx];
// Pre-fill the field value when editing a text item
if (value.type === "text" && value.text) {
this._picker?.setFieldValue(value.text);
}
}
private get _items(): EntityNameItem[] {
@@ -316,10 +317,7 @@ export class HaEntityNamePicker extends LitElement {
return undefined;
}
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
private _getFilteredItems = (): PickerComboBoxItem[] => {
const items = this._getItems(this.entityId);
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
@@ -336,49 +334,27 @@ export class HaEntityNamePicker extends LitElement {
);
// When editing an existing text item, include it in the base items
if (currentItem?.type === "text" && currentItem.text && !searchString) {
if (currentItem?.type === "text" && currentItem.text) {
filteredItems.push(this._customNameOption(currentItem.text));
}
return filteredItems;
};
private _getAdditionalItems = (
searchString?: string
private _searchFn = (
searchString: string,
filteredItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
if (!searchString) {
return [];
}
const currentItem =
this._editIndex != null ? this._items[this._editIndex] : undefined;
const currentId =
currentItem?.type === "text" && currentItem.text
? this._customNameOption(currentItem.text).id
: undefined;
// Don't add if it's the same as the current item being edited
if (
currentItem?.type === "text" &&
currentItem.text &&
currentItem.text === searchString
) {
return [];
}
// Always return custom name option when there's a search string
// This prevents "No matching items found" from showing
return [this._customNameOption(searchString)];
};
private _searchFn = (
search: string,
filteredItems: PickerComboBoxItem[],
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
// Remove NO_ITEMS_AVAILABLE_ID if we have additional items (custom name option)
// This prevents "No matching items found" from showing when custom values are allowed
const hasAdditionalItems = this._getAdditionalItems(search).length > 0;
if (hasAdditionalItems) {
return filteredItems.filter(
(item) => typeof item !== "string" || item !== NO_ITEMS_AVAILABLE_ID
);
// Remove custom name option if search string is present to avoid duplicates
if (searchString && currentId) {
return filteredItems.filter((item) => item.id !== currentId);
}
return filteredItems;
};

View File

@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import {
STATE_DISPLAY_SPECIAL_CONTENT,
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS,
@@ -19,7 +20,10 @@ import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-input-helper-text";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import {
NO_ITEMS_AVAILABLE_ID,
type PickerComboBoxItem,
} from "../ha-picker-combo-box";
import "../ha-sortable";
const HIDDEN_ATTRIBUTES = [
@@ -92,6 +96,9 @@ export class HaStateContentPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-name" }) public allowName =
false;
@property({ type: Boolean, attribute: "allow-context" }) public allowContext =
false;
@property() public label?: string;
@property() public value?: string[] | string;
@@ -103,7 +110,12 @@ export class HaStateContentPicker extends LitElement {
private _editIndex?: number;
private _getItems = memoizeOne(
(entityId?: string, stateObj?: HassEntity, allowName?: boolean) => {
(
entityId?: string,
stateObj?: HassEntity,
allowName?: boolean,
allowContext?: boolean
) => {
const domain = entityId ? computeDomain(entityId) : undefined;
const items: PickerComboBoxItem[] = [
{
@@ -146,6 +158,52 @@ export class HaStateContentPicker extends LitElement {
"ui.components.state-content-picker.last_updated"
),
},
...(allowContext && stateObj
? (() => {
const context = getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const contextItems: PickerComboBoxItem[] = [];
if (context.device) {
contextItems.push({
id: "device_name",
primary: this.hass.localize(
"ui.components.state-content-picker.device_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.device_name"
),
});
}
if (context.area) {
contextItems.push({
id: "area_name",
primary: this.hass.localize(
"ui.components.state-content-picker.area_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.area_name"
),
});
}
if (context.floor) {
contextItems.push({
id: "floor_name",
primary: this.hass.localize(
"ui.components.state-content-picker.floor_name"
),
sorting_label: this.hass.localize(
"ui.components.state-content-picker.floor_name"
),
});
}
return contextItems;
})()
: []),
...(domain
? STATE_DISPLAY_SPECIAL_CONTENT.filter((content) =>
STATE_DISPLAY_SPECIAL_CONTENT_DOMAINS[domain]?.includes(content)
@@ -199,11 +257,7 @@ export class HaStateContentPicker extends LitElement {
.value=${this._getPickerValue()}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
allow-custom-value
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
)}
.searchFn=${this._searchFn}
@value-changed=${this._pickerValueChanged}
>
<div slot="field" class="container">
@@ -301,7 +355,8 @@ export class HaStateContentPicker extends LitElement {
const items = this._getItems(
this.entityId,
stateObjForItems,
this.allowName
this.allowName,
this.allowContext
);
return items.find((item) => item.id === value)?.primary;
}
@@ -328,7 +383,7 @@ export class HaStateContentPicker extends LitElement {
(text: string): PickerComboBoxItem => ({
id: text,
primary: this.hass.localize(
"ui.components.entity.entity-state-content-picker.custom_state"
"ui.components.entity.entity-state-content-picker.custom_attribute"
),
secondary: `"${text}"`,
search_labels: {
@@ -340,14 +395,16 @@ export class HaStateContentPicker extends LitElement {
})
);
private _getFilteredItems = (
searchString?: string,
_section?: string
): PickerComboBoxItem[] => {
private _getFilteredItems = (): PickerComboBoxItem[] => {
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(this.entityId, stateObj, this.allowName);
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
@@ -358,11 +415,7 @@ export class HaStateContentPicker extends LitElement {
);
// When editing an existing custom value, include it in the base items
if (
currentValue &&
!items.find((item) => item.id === currentValue) &&
!searchString
) {
if (currentValue && !items.find((item) => item.id === currentValue)) {
filteredItems.push(this._customValueOption(currentValue));
}
@@ -372,33 +425,39 @@ export class HaStateContentPicker extends LitElement {
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (!searchString) {
return [];
}
const currentValue =
this._editIndex != null ? this._value[this._editIndex] : undefined;
// Don't add if it's the same as the current item being edited
if (currentValue && currentValue === searchString) {
return [];
}
// Check if the search string matches an existing item
const stateObj = this.entityId
? this.hass.states[this.entityId]
: undefined;
const items = this._getItems(this.entityId, stateObj, this.allowName);
const existingItem = items.find((item) => item.id === searchString);
const items = this._getItems(
this.entityId,
stateObj,
this.allowName,
this.allowContext
);
// Only return custom value option if it doesn't match an existing item
if (!existingItem) {
// If the search string does not match with the id of any of the items,
// offer to add it as a custom attribute
const existingItem = items.find((item) => item.id === searchString);
if (searchString && !existingItem) {
return [this._customValueOption(searchString)];
}
return [];
};
private _searchFn = (
search: string,
filteredItems: PickerComboBoxItem[],
_allItems: PickerComboBoxItem[]
): PickerComboBoxItem[] => {
if (!search) {
return filteredItems;
}
// Always exclude NO_ITEMS_AVAILABLE_ID (since custom values are allowed) and currentValue (the custom value being edited)
return filteredItems.filter((item) => item.id !== NO_ITEMS_AVAILABLE_ID);
};
private async _moveItem(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;

View File

@@ -7,6 +7,7 @@ import { getStates } from "../../common/entity/get_states";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
@customElement("ha-entity-state-picker")
export class HaEntityStatePicker extends LitElement {
@@ -108,6 +109,12 @@ export class HaEntityStatePicker extends LitElement {
this.extraOptions
);
private _valueRenderer: PickerValueRenderer = (value: string) => {
const items = this._getFilteredItems();
const item = items.find((option) => option.id === value);
return html`<span slot="headline">${item?.primary ?? value}</span>`;
};
protected render() {
if (!this.hass) {
return nothing;
@@ -125,6 +132,7 @@ export class HaEntityStatePicker extends LitElement {
.helper=${this.helper}
.value=${this.value}
.getItems=${this._getFilteredItems}
.valueRenderer=${this._valueRenderer}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-picker.add_custom_state"

View File

@@ -143,17 +143,19 @@ export class HaEntityToggle extends LitElement {
// Optimistic update.
this._isOn = turnOn;
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
setTimeout(async () => {
// If after 2 seconds we have not received a state update
// reset the switch to it's original state.
if (this.stateObj === currentState) {
this._isOn = isOn(this.stateObj);
}
}, 2000);
try {
await this.hass.callService(serviceDomain, service, {
entity_id: this.stateObj.entity_id,
});
} finally {
setTimeout(async () => {
// If after 2 seconds we have not received a state update
// reset the switch to it's original state.
if (this.stateObj === currentState) {
this._isOn = isOn(this.stateObj);
}
}, 2000);
}
}
static styles = css`

View File

@@ -141,6 +141,7 @@ export class HaStatisticPicker extends LitElement {
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
this._picker?.requestUpdate();
}
private _getItems = () =>
@@ -177,9 +178,9 @@ export class HaStatisticPicker extends LitElement {
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticComboBoxItem[] => {
): StatisticComboBoxItem[] | undefined => {
if (!statisticIds) {
return [];
return undefined;
}
if (includeStatisticsUnitOfMeasurement) {

View File

@@ -1,8 +1,8 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HomeAssistant } from "../types";
import { listenMediaQuery } from "../common/dom/media_query";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -88,6 +88,9 @@ export class HaAdaptiveDialog extends LitElement {
@property({ type: Boolean, attribute: "block-mode-change" })
public blockModeChange = false;
@property({ type: Boolean, attribute: "without-header" })
public withoutHeader = false;
@state() private _mode: DialogSheetMode = "dialog";
private _unsubMediaQuery?: () => void;
@@ -118,27 +121,33 @@ export class HaAdaptiveDialog extends LitElement {
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>
${!this.withoutHeader
? html`<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>`
: nothing}
<slot></slot>
<slot name="footer" slot="footer"></slot>
</ha-bottom-sheet>
@@ -156,6 +165,7 @@ export class HaAdaptiveDialog extends LitElement {
.headerSubtitle=${this.headerSubtitle}
.headerSubtitlePosition=${this.headerSubtitlePosition}
flexcontent
.withoutHeader=${this.withoutHeader}
>
<slot name="headerNavigationIcon" slot="headerNavigationIcon">
<ha-icon-button

View File

@@ -57,13 +57,13 @@ class HaAddonPicker extends LitElement {
}
protected firstUpdated() {
this._getAddons();
this._getApps();
}
protected render() {
const label =
this.label === undefined && this.hass
? this.hass.localize("ui.components.addon-picker.addon")
? this.hass.localize("ui.components.app-picker.app")
: this.label;
if (this._error) {
@@ -92,7 +92,7 @@ class HaAddonPicker extends LitElement {
`;
}
private async _getAddons() {
private async _getApps() {
try {
if (isComponentLoaded(this.hass, "hassio")) {
const addonsInfo = await fetchHassioAddonsInfo(this.hass);
@@ -113,12 +113,12 @@ class HaAddonPicker extends LitElement {
}));
} else {
this._error = this.hass.localize(
"ui.components.addon-picker.error.no_supervisor"
"ui.components.app-picker.error.no_supervisor"
);
}
} catch (_err: any) {
this._error = this.hass.localize(
"ui.components.addon-picker.error.fetch_addons"
"ui.components.app-picker.error.fetch_apps"
);
}
}

View File

@@ -0,0 +1,342 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import Fuse from "fuse.js";
import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
import { computeRTL } from "../common/util/compute_rtl";
import type { LocalizeFunc } from "../common/translations/localize";
import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import {
AREA_CONTROLS_BUTTONS,
getAreaControlEntities,
type AreaControlDomain,
} from "../data/area/area_controls";
import type { HomeAssistant } from "../types";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-combo-box-item";
import "./ha-domain-icon";
import "./ha-generic-picker";
import "./ha-state-icon";
export interface AreaControlPickerItem extends PickerComboBoxItem {
type?: "domain" | "entity";
stateObj?: HassEntity;
domain?: string;
deviceClass?: string;
}
const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
"light",
"fan",
"switch",
"cover-shutter",
"cover-blind",
"cover-curtain",
"cover-shade",
"cover-awning",
"cover-garage",
"cover-gate",
"cover-door",
"cover-window",
"cover-damper",
] as const;
@customElement("ha-area-controls-picker")
export class HaAreaControlsPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "area-id" }) public areaId!: string;
@property({ type: Array, attribute: "exclude-entities" })
public excludeEntities?: string[];
@property() public value?: string;
@property({ type: Array, attribute: "exclude-values" })
public excludeValues?: string[];
@property() public label?: string;
@property() public placeholder?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
private _domainSearchKeys: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
];
private _entitySearchKeys: FuseWeightedKey[] = [
{
name: "primary",
weight: 10,
},
{
name: "secondary",
weight: 5,
},
{
name: "id",
weight: 3,
},
];
private _createFuseIndex = (
items: AreaControlPickerItem[],
keys: FuseWeightedKey[]
) => Fuse.createIndex(keys, items);
private _domainFuseIndex = memoizeOne((items: AreaControlPickerItem[]) =>
this._createFuseIndex(items, this._domainSearchKeys)
);
private _entityFuseIndex = memoizeOne((items: AreaControlPickerItem[]) =>
this._createFuseIndex(items, this._entitySearchKeys)
);
private _getItems = memoizeOne(
(
areaId: string,
excludeEntities: string[] | undefined,
currentValue: string | undefined,
excludeValues: string[] | undefined,
localize: LocalizeFunc,
_entities: HomeAssistant["entities"],
_devices: HomeAssistant["devices"],
_areas: HomeAssistant["areas"]
): ((
searchString?: string,
section?: string
) => (AreaControlPickerItem | string)[]) =>
(searchString?: string, section?: string) => {
if (!this.hass) {
return [];
}
const isSelected = (id: string): boolean =>
currentValue === id ||
(excludeValues !== undefined && excludeValues.includes(id));
const controlEntities = getAreaControlEntities(
AREA_CONTROL_DOMAINS as unknown as AreaControlDomain[],
areaId,
excludeEntities,
this.hass
);
const items: (AreaControlPickerItem | string)[] = [];
let domainItems: AreaControlPickerItem[] = [];
let entityItems: AreaControlPickerItem[] = [];
if (!section || section === "domain") {
const supportedControls = (
Object.keys(controlEntities) as (keyof typeof controlEntities)[]
).filter((control) => controlEntities[control].length > 0);
supportedControls.forEach((control) => {
if (isSelected(control)) {
return;
}
const label = localize(
`ui.panel.lovelace.editor.features.types.area-controls.controls_options.${control}`
);
const button = AREA_CONTROLS_BUTTONS[control];
const deviceClass = button.filter.device_class
? Array.isArray(button.filter.device_class)
? button.filter.device_class[0]
: button.filter.device_class
: undefined;
domainItems.push({
type: "domain",
id: control,
primary: label,
domain: button.filter.domain,
deviceClass,
});
});
if (searchString) {
const fuseIndex = this._domainFuseIndex(domainItems);
domainItems = multiTermSortedSearch(
domainItems,
searchString,
this._domainSearchKeys,
(item) => item.id,
fuseIndex
);
}
}
if (!section || section === "entity") {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(this.hass);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
return;
}
const stateObj = this.hass!.states[entityId];
if (!stateObj) {
return;
}
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
entityItems.push({
type: "entity",
id: entityId,
primary,
secondary,
stateObj,
});
});
if (searchString) {
const fuseIndex = this._entityFuseIndex(entityItems);
entityItems = multiTermSortedSearch(
entityItems,
searchString,
this._entitySearchKeys,
(item) => item.id,
fuseIndex
);
}
}
// Only add section headers if there are items in that section
if (!section) {
if (domainItems.length > 0) {
items.push(
localize(
"ui.panel.lovelace.editor.features.types.area-controls.sections.domain"
)
);
items.push(...domainItems);
}
if (entityItems.length > 0) {
items.push(
localize(
"ui.panel.lovelace.editor.features.types.area-controls.sections.entity"
)
);
items.push(...entityItems);
}
} else {
items.push(...domainItems, ...entityItems);
}
return items;
}
);
private _rowRenderer = (item: AreaControlPickerItem) => html`
<ha-combo-box-item type="button" compact>
${item.type === "entity" && item.stateObj
? html`<ha-state-icon
slot="start"
.hass=${this.hass}
.stateObj=${item.stateObj}
></ha-state-icon>`
: item.domain
? html`<ha-domain-icon
slot="start"
.hass=${this.hass}
.domain=${item.domain}
.deviceClass=${item.deviceClass}
></ha-domain-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.type === "entity" && item.stateObj
? html`<span slot="supporting-text" class="code">
${item.stateObj.entity_id}
</span>`
: nothing}
</ha-combo-box-item>
`;
protected render() {
if (!this.hass) {
return nothing;
}
return html`
<ha-generic-picker
.hass=${this.hass}
.value=${this.value || ""}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label}
.placeholder=${this.placeholder}
.helper=${this.helper}
.addButtonLabel=${this.addButtonLabel}
.getItems=${this._getItems(
this.areaId,
this.excludeEntities,
this.value,
this.excludeValues,
this.hass.localize,
this.hass.entities,
this.hass.devices,
this.hass.areas
)}
.rowRenderer=${this._rowRenderer as any}
.sections=${[
{
id: "domain",
label: this.hass.localize(
"ui.panel.lovelace.editor.features.types.area-controls.sections.domain"
),
},
{
id: "entity",
label: this.hass.localize(
"ui.panel.lovelace.editor.features.types.area-controls.sections.entity"
),
},
]}
></ha-generic-picker>
`;
}
static styles = css`
.code {
font-family: var(--ha-font-family-code);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-controls-picker": HaAreaControlsPicker;
}
}

View File

@@ -6,16 +6,10 @@ import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { createAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity/entity_registry";
import { areaComboBoxKeys, getAreas } from "../data/area/area_picker";
import { createAreaRegistryEntry } from "../data/area/area_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
@@ -30,12 +24,6 @@ import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const SEARCH_KEYS = [
{ name: "search_labels.areaName", weight: 10 },
{ name: "search_labels.aliases", weight: 8 },
{ name: "search_labels.floorName", weight: 6 },
{ name: "search_labels.id", weight: 3 },
];
@customElement("ha-area-picker")
export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -102,6 +90,8 @@ export class HaAreaPicker extends LitElement {
await this._picker?.open();
}
private _getAreasMemoized = memoizeOne(getAreas);
// Recompute value renderer when the areas change
private _computeValueRenderer = memoizeOne(
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
@@ -137,183 +127,13 @@ export class HaAreaPicker extends LitElement {
}
);
private _getAreas = memoizeOne(
(
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"]
): PickerComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
const items = outputAreas.map<PickerComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass.floors);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {
id: area.area_id,
primary: areaName || area.area_id,
secondary: floorName,
icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox,
search_labels: {
areaName: areaName || null,
floorName: floorName || null,
id: area.area_id,
aliases: area.aliases.join(" "),
},
};
});
return items;
}
);
private _getItems = () =>
this._getAreas(
this._getAreasMemoized(
this.hass.areas,
this.hass.floors,
this.hass.devices,
this.hass.entities,
this.hass.states,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
@@ -394,7 +214,7 @@ export class HaAreaPicker extends LitElement {
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.addButtonLabel=${this.addButtonLabel}
.searchKeys=${SEARCH_KEYS}
.searchKeys=${areaComboBoxKeys}
.unknownItemText=${this.hass.localize(
"ui.components.area-picker.unknown"
)}

View File

@@ -174,12 +174,14 @@ export class HaAutomationRow extends LitElement {
}
::slotted([slot="header"]) {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
margin: 0 var(--ha-space-3);
}
.icons {
display: flex;
align-items: center;
flex-shrink: 0;
}
:host([sort-selected]) .row {
outline: solid;

View File

@@ -208,7 +208,8 @@ export class HaBaseTimeInput extends LitElement {
? html`<ha-textfield
id="sec"
type="number"
inputmode="numeric"
inputmode="decimal"
step="any"
.value=${this._formatValue(this.seconds)}
.label=${this.secLabel}
@change=${this._valueChanged}
@@ -217,7 +218,6 @@ export class HaBaseTimeInput extends LitElement {
no-spinner
.required=${this.required}
.autoValidate=${this.autoValidate}
maxlength="2"
max="59"
min="0"
.disabled=${this.disabled}
@@ -311,7 +311,8 @@ export class HaBaseTimeInput extends LitElement {
* Format time fragments
*/
private _formatValue(value: number, padding = 2) {
return value.toString().padStart(padding, "0");
const str = value.toString();
return str.includes(".") ? str : str.padStart(padding, "0");
}
/**

View File

@@ -51,7 +51,10 @@ export class HaCard extends LitElement {
font-weight: var(--ha-font-weight-normal);
}
:host ::slotted(.card-content:not(:first-child)),
:host
::slotted(
.card-content:not(:nth-child(1 of .card-content, .card-header))
),
slot:not(:first-child)::slotted(.card-content) {
padding-top: 0;
margin-top: calc(var(--ha-space-2) * -1);

View File

@@ -255,6 +255,7 @@ export class HaCodeEditor extends ReactiveElement {
...this._loadedCodeMirror.tabKeyBindings,
saveKeyBinding,
]),
this._loadedCodeMirror.search({ top: true }),
this._loadedCodeMirror.langCompartment.of(this._mode),
this._loadedCodeMirror.haTheme,
this._loadedCodeMirror.haSyntaxHighlighting,

View File

@@ -1,24 +0,0 @@
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { HaTextField } from "./ha-textfield";
@customElement("ha-combo-box-textfield")
export class HaComboBoxTextField extends HaTextField {
@property({ type: Boolean, attribute: "force-blank-value" })
public forceBlankValue = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value") || changedProps.has("forceBlankValue")) {
if (this.forceBlankValue && this.value) {
this.value = "";
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-combo-box-textfield": HaComboBoxTextField;
}
}

View File

@@ -1,9 +1,11 @@
import { mdiMinusThick, mdiPlusThick } from "@mdi/js";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-base-time-input";
import type { TimeChangedEvent } from "./ha-base-time-input";
import "./ha-button-toggle-group";
export interface HaDurationData {
days?: number;
@@ -13,6 +15,8 @@ export interface HaDurationData {
milliseconds?: number;
}
const FIELDS = ["milliseconds", "seconds", "minutes", "hours", "days"];
@customElement("ha-duration-input")
class HaDurationInput extends LitElement {
@property({ attribute: false }) public data?: HaDurationData;
@@ -29,41 +33,80 @@ class HaDurationInput extends LitElement {
@property({ attribute: "enable-day", type: Boolean })
public enableDay = false;
@property({ attribute: "allow-negative", type: Boolean })
public allowNegative = false;
@property({ type: Boolean }) public disabled = false;
private _toggleNegative = false;
protected render(): TemplateResult {
return html`
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
.required=${this.required}
.clearable=${!this.required && this.data !== undefined}
.autoValidate=${this.required}
.disabled=${this.disabled}
errorMessage="Required"
enable-second
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
format="24"
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
<div class="row">
${this.allowNegative
? html`
<ha-button-toggle-group
size="small"
.buttons=${[
{ label: "+", iconPath: mdiPlusThick, value: "+" },
{ label: "-", iconPath: mdiMinusThick, value: "-" },
]}
.active=${this._negative ? "-" : "+"}
@value-changed=${this._negativeChanged}
></ha-button-toggle-group>
`
: nothing}
<ha-base-time-input
.label=${this.label}
.helper=${this.helper}
.required=${this.required}
.clearable=${!this.required && this.data !== undefined}
.autoValidate=${this.required}
.disabled=${this.disabled}
errorMessage="Required"
enable-second
.enableMillisecond=${this.enableMillisecond}
.enableDay=${this.enableDay}
format="24"
.days=${this._days}
.hours=${this._hours}
.minutes=${this._minutes}
.seconds=${this._seconds}
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
</div>
`;
}
private get _negative() {
return (
this._toggleNegative ||
(this.data?.days
? this.data.days < 0
: this.data?.hours
? this.data.hours < 0
: this.data?.minutes
? this.data.minutes < 0
: this.data?.seconds
? this.data.seconds < 0
: this.data?.milliseconds
? this.data.milliseconds < 0
: false)
);
}
private get _days() {
return this.data?.days
? Number(this.data.days)
? this.allowNegative
? Math.abs(Number(this.data.days))
: Number(this.data.days)
: this.required || this.data
? 0
: NaN;
@@ -71,7 +114,9 @@ class HaDurationInput extends LitElement {
private get _hours() {
return this.data?.hours
? Number(this.data.hours)
? this.allowNegative
? Math.abs(Number(this.data.hours))
: Number(this.data.hours)
: this.required || this.data
? 0
: NaN;
@@ -79,7 +124,9 @@ class HaDurationInput extends LitElement {
private get _minutes() {
return this.data?.minutes
? Number(this.data.minutes)
? this.allowNegative
? Math.abs(Number(this.data.minutes))
: Number(this.data.minutes)
: this.required || this.data
? 0
: NaN;
@@ -87,7 +134,9 @@ class HaDurationInput extends LitElement {
private get _seconds() {
return this.data?.seconds
? Number(this.data.seconds)
? this.allowNegative
? Math.abs(Number(this.data.seconds))
: Number(this.data.seconds)
: this.required || this.data
? 0
: NaN;
@@ -95,7 +144,9 @@ class HaDurationInput extends LitElement {
private get _milliseconds() {
return this.data?.milliseconds
? Number(this.data.milliseconds)
? this.allowNegative
? Math.abs(Number(this.data.milliseconds))
: Number(this.data.milliseconds)
: this.required || this.data
? 0
: NaN;
@@ -113,6 +164,14 @@ class HaDurationInput extends LitElement {
if ("days" in value) value.days ||= 0;
if ("milliseconds" in value) value.milliseconds ||= 0;
if (this.allowNegative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = Math.abs(value[t]);
}
});
}
if (!this.enableMillisecond && !value.milliseconds) {
// @ts-ignore
delete value.milliseconds;
@@ -135,12 +194,47 @@ class HaDurationInput extends LitElement {
value.days = (value.days ?? 0) + Math.floor(value.hours / 24);
value.hours %= 24;
}
if (this._negative) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = -Math.abs(value[t]);
}
});
}
}
fireEvent(this, "value-changed", {
value,
});
}
private _negativeChanged(ev) {
ev.stopPropagation();
const negative = (ev.detail?.value || ev.target.value) === "-";
this._toggleNegative = negative;
const value = this.data;
if (value) {
FIELDS.forEach((t) => {
if (value[t]) {
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
}
});
fireEvent(this, "value-changed", {
value,
});
}
}
static styles = css`
.row {
display: flex;
align-items: center;
}
ha-button-toggle-group {
margin: var(--ha-space-2);
}
`;
}
declare global {

View File

@@ -1,4 +1,4 @@
import type { ActionDetail, SelectedDetail } from "@material/mwc-list";
import type { SelectedDetail } from "@material/mwc-list";
import {
mdiDelete,
mdiDotsVertical,
@@ -25,7 +25,8 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showCategoryRegistryDetailDialog } from "../panels/config/category/show-dialog-category-registry-detail";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-button-menu";
import "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-list";
@@ -114,37 +115,36 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
slot="graphic"
></ha-svg-icon>`}
${category.name}
<ha-button-menu
<ha-dropdown
@click=${stopPropagation}
@action=${this._handleAction}
@wa-select=${this._handleAction}
slot="meta"
fixed
.categoryId=${category.category_id}
>
<ha-icon-button
.path=${mdiDotsVertical}
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
></ha-icon-button>
<ha-list-item graphic="icon"
><ha-svg-icon
<ha-dropdown-item value="edit">
<ha-svg-icon
slot="icon"
.path=${mdiPencil}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.category.editor.edit"
)}</ha-list-item
>
<ha-list-item graphic="icon" class="warning"
><ha-svg-icon
class="warning"
)}
</ha-dropdown-item>
<ha-dropdown-item value="delete" variant="danger">
<ha-svg-icon
slot="icon"
.path=${mdiDelete}
slot="graphic"
></ha-svg-icon
>${this.hass.localize(
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.category.editor.delete"
)}</ha-list-item
>
</ha-button-menu>
)}
</ha-dropdown-item>
</ha-dropdown>
</ha-list-item>`
)}
</ha-list>
@@ -174,13 +174,14 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
}
}
private _handleAction(ev: CustomEvent<ActionDetail>) {
private _handleAction(ev: CustomEvent<{ item: { value: string } }>) {
const categoryId = (ev.currentTarget as any).categoryId;
switch (ev.detail.index) {
case 0:
const action = ev.detail.item.value;
switch (action) {
case "edit":
this._editCategory(categoryId);
break;
case 1:
case "delete":
this._deleteCategory(categoryId);
break;
}
@@ -316,6 +317,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
--mdc-list-side-padding-right: 4px;
--mdc-icon-button-size: 36px;
}
ha-dropdown-item {
font-size: var(--ha-font-size-m);
}
.warning {
color: var(--error-color);
}

View File

@@ -0,0 +1,198 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-label";
import "./ha-list";
import "./ha-list-item";
import "./voice-assistant-brand-icon";
import { voiceAssistants } from "../data/expose";
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
@customElement("ha-filter-voice-assistants")
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// the list of selected voiceAssistantIds
@property({ attribute: false }) public value: string[] = [];
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _voiceAssistantOptions: string[] = [];
@state() private _shouldRender = false;
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize(
"ui.panel.config.dashboard.voice_assistants.main"
)}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
></ha-icon-button>`
: nothing}
</div>
${this._shouldRender
? html`<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._voiceAssistantOptions,
(voiceAssistantId) => voiceAssistantId,
(voiceAssistantId) =>
html`<ha-check-list-item
.value=${voiceAssistantId}
.selected=${(this.value || []).includes(voiceAssistantId)}
hasMeta
graphic="icon"
>
<voice-assistant-brand-icon
slot="graphic"
.voiceAssistantId=${voiceAssistantId}
.hass=${this.hass}
>
</voice-assistant-brand-icon>
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing}
</ha-expansion-panel>
`;
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
private async _assistantsSelected(
ev: CustomEvent<SelectedDetail<Set<number>>>
) {
if (!ev.detail.index) {
fireEvent(this, "data-table-filter-changed", {
value: [],
items: undefined,
});
this.value = [];
return;
}
const newvalue: string[] = [];
for (const index of ev.detail.index) {
newvalue.push(this._voiceAssistantOptions![index]);
}
this.value = newvalue;
fireEvent(this, "data-table-filter-changed", {
value: this.value,
items: undefined,
});
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = [];
fireEvent(this, "data-table-filter-changed", {
value: undefined,
items: undefined,
});
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-filter-voice-assistants": HaFilterVoiceAssistants;
}
}

View File

@@ -8,7 +8,7 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { updateAreaRegistryEntry } from "../data/area_registry";
import { updateAreaRegistryEntry } from "../data/area/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,

View File

@@ -1,5 +1,21 @@
import type { Selector } from "../../data/selector";
import type { HaFormSchema } from "./types";
import type { HaFormData, HaFormSchema } from "./types";
const setDefaultValue = (
field: HaFormSchema,
value: HaFormData | undefined
) => {
if ("selector" in field && "choose" in field.selector) {
const firstChoice = Object.keys(field.selector.choose.choices)[0];
if (firstChoice) {
return {
active_choice: firstChoice,
[firstChoice]: value,
};
}
}
return value;
};
export const computeInitialHaFormData = (
schema: HaFormSchema[] | readonly HaFormSchema[]
@@ -10,9 +26,12 @@ export const computeInitialHaFormData = (
field.description?.suggested_value !== undefined &&
field.description?.suggested_value !== null
) {
data[field.name] = field.description.suggested_value;
data[field.name] = setDefaultValue(
field,
field.description.suggested_value
);
} else if ("default" in field) {
data[field.name] = field.default;
data[field.name] = setDefaultValue(field, field.default);
} else if (field.type === "expandable") {
const expandableData = computeInitialHaFormData(field.schema);
if (field.required || Object.keys(expandableData).length) {
@@ -108,6 +127,21 @@ export const computeInitialHaFormData = (
data[field.name] = {};
} else if ("state" in selector) {
data[field.name] = selector.state?.multiple ? [] : "";
} else if ("choose" in selector) {
const firstChoice = Object.keys(selector.choose.choices)[0];
if (!firstChoice) {
data[field.name] = {};
} else {
data[field.name] = {
active_choice: firstChoice,
[firstChoice]: computeInitialHaFormData([
{
name: firstChoice,
selector: selector.choose.choices[firstChoice].selector,
},
])[firstChoice],
};
}
} else {
throw new Error(
`Selector ${Object.keys(selector)[0]} not supported in initial form data`

View File

@@ -7,7 +7,8 @@ import { stopPropagation } from "../../common/dom/stop_propagation";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-list-item";
import "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-svg-icon";
import "./ha-form";
import type {
@@ -116,9 +117,8 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
: nothing}
${hiddenActions.length > 0
? html`
<ha-button-menu
@action=${this._handleAddAction}
fixed
<ha-dropdown
@wa-select=${this._handleAddAction}
@closed=${stopPropagation}
>
<ha-button slot="trigger" appearance="filled" size="small">
@@ -129,26 +129,21 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
${hiddenActions.map((action) => {
const actionSchema = schemaMap.get(action);
return html`
<ha-list-item>
<ha-dropdown-item .value=${action}>
${this.computeLabel && actionSchema
? this.computeLabel(actionSchema)
: action}
</ha-list-item>
</ha-dropdown-item>
`;
})}
</ha-button-menu>
</ha-dropdown>
`
: nothing}
`;
}
private _handleAddAction(ev: CustomEvent) {
const hiddenActions = this._hiddenActions(
this.schema.schema,
this._displayActions ?? NO_ACTIONS
);
const index = ev.detail.index;
const action = hiddenActions[index];
private _handleAddAction(ev: CustomEvent<{ item: { value: string } }>) {
const action = ev.detail.item.value;
this._displayActions = [...(this._displayActions ?? []), action];
}
@@ -161,6 +156,9 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
:host ha-form {
display: block;
}
ha-dropdown {
display: inline-block;
}
`;
}

View File

@@ -1,12 +1,19 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlaylistPlus } from "@mdi/js";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} 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 { throttle } from "../common/util/throttle";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import type { HomeAssistant } from "../types";
@@ -39,7 +46,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
) => (PickerComboBoxItem | string)[] | undefined;
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -114,6 +121,8 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@state() private _openedNarrow = false;
@state() private _unknownValue = false;
static shadowRootOptions = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -130,6 +139,25 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
private _unsubscribeTinyKeys?: () => void;
protected willUpdate(changedProperties: PropertyValues) {
if (changedProperties.has("value")) {
this._setUnknownValue();
return;
}
if (changedProperties.has("hass")) {
this._throttleUnknownValue();
}
}
public setFieldValue(value: string) {
if (this._comboBox) {
this._comboBox.setFieldValue(value);
return;
}
// Store initial value to set when opened
this._initialFieldValue = value;
}
protected render() {
// Only show label if it's not a top label and there is a value.
const label = this.useTopLabel && this.value ? undefined : this.label;
@@ -157,11 +185,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
type="button"
class=${this._opened ? "opened" : ""}
compact
.unknown=${this._unknownValue(
this.allowCustomValue,
this.value,
this.getItems()
)}
.unknown=${this._unknownValue}
.unknownItemText=${this.unknownItemText}
aria-label=${ifDefined(this.label)}
@click=${this.open}
@@ -182,40 +206,42 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-picker-field>`}
</slot>
</div>
${!this._openedNarrow && (this._pickerWrapperOpen || this._opened)
? html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox()}
</wa-popover>
`
: this._pickerWrapperOpen || this._opened
? html`<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>`
: nothing}
${this._pickerWrapperOpen || this._opened
? this._openedNarrow
? html`
<ha-bottom-sheet
flexcontent
.open=${this._pickerWrapperOpen}
@wa-after-show=${this._dialogOpened}
@closed=${this._hidePicker}
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox(true)}
</ha-bottom-sheet>
`
: html`
<wa-popover
.open=${this._pickerWrapperOpen}
style="--body-width: ${this._popoverWidth}px;"
without-arrow
distance="-4"
.placement=${this.popoverPlacement}
for="picker"
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._dialogOpened}
@wa-after-hide=${this._hidePicker}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label || "Select option"}
>
${this._renderComboBox()}
</wa-popover>
`
: nothing}
</div>
${this._renderHelper()}`;
}
@@ -248,26 +274,29 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
`;
}
private _unknownValue = memoizeOne(
(
allowCustomValue: boolean,
value?: string,
items?: (PickerComboBoxItem | string)[]
) => {
if (
allowCustomValue ||
value === undefined ||
value === null ||
value === "" ||
!items
) {
return false;
}
return !items.some(
(item) => typeof item !== "string" && item.id === value
);
private _setUnknownValue = () => {
const items = this.getItems();
if (
this.allowCustomValue ||
this.value === undefined ||
this.value === null ||
this.value === "" ||
!items
) {
this._unknownValue = false;
return;
}
this._unknownValue = !items.some(
(item) => typeof item !== "string" && item.id === this.value
);
};
private _throttleUnknownValue = throttle(
this._setUnknownValue,
1000,
true,
false
);
private _renderHelper() {
@@ -283,9 +312,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
</ha-input-helper-text>`;
}
private _initialFieldValue?: string;
private _dialogOpened = () => {
this._opened = true;
requestAnimationFrame(() => {
// Set initial field value if needed
if (this._initialFieldValue) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
if (this.hass && isIosApp(this.hass)) {
this.hass.auth.external!.fireMessage({
type: "focus_element",
@@ -295,6 +331,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
});
return;
}
this._comboBox?.focus();
});
};
@@ -376,6 +413,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.container {
position: relative;
display: block;
max-width: 100%;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));

View File

@@ -124,9 +124,6 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
.notFoundLabel=${this.hass?.localize(
"ui.components.icon-picker.no_match"
)}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
@@ -173,20 +170,6 @@ export class HaIconPicker extends LitElement {
}
}
// Allow preview for custom icon not in list
if (rankedItems.length === 0) {
rankedItems.push({
item: {
id: filter,
primary: filter,
icon: filter,
search_labels: { keyword: filter },
sorting_label: filter,
},
rank: 0,
});
}
return rankedItems
.sort((itemA, itemB) => itemA.rank - itemB.rank)
.map((item) => item.item);

View File

@@ -1,4 +1,4 @@
import { mdiLabel, mdiPlus } from "@mdi/js";
import { mdiPlus } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
@@ -25,11 +25,9 @@ import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS = "___NO_LABELS___";
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@@ -108,52 +106,10 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
];
}
private _labelMap = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined
): Map<string, LabelRegistryEntry> => {
if (!labels) {
return new Map();
}
return new Map(labels.map((label) => [label.label_id, label]));
}
);
private _computeValueRenderer = memoizeOne(
(labels: LabelRegistryEntry[] | undefined): PickerValueRenderer =>
(value) => {
const label = this._labelMap(labels).get(value);
if (!label) {
return html`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
return html`
${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span>
`;
}
);
private _getLabelsMemoized = memoizeOne(getLabels);
private _getItems = () => {
if (!this._labels || this._labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
return this._getLabelsMemoized(
private _getItems = () =>
this._getLabelsMemoized(
this.hass.states,
this.hass.areas,
this.hass.devices,
@@ -166,7 +122,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.entityFilter,
this.excludeLabels
);
};
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
@@ -219,8 +174,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
const valueRenderer = this._computeValueRenderer(this._labels);
return html`
<ha-generic-picker
.disabled=${this.disabled}
@@ -237,7 +190,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.searchKeys=${labelComboBoxKeys}
@value-changed=${this._valueChanged}
>
@@ -251,10 +203,6 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
const value = ev.detail.value;
if (value === NO_LABELS) {
return;
}
if (!value) {
this._setValue(undefined);
return;

View File

@@ -138,10 +138,10 @@ export class HaMarkdown extends LitElement {
--markdown-table-padding-inline: 0;
--markdown-table-padding-block: 0;
th {
vertical-align: attr(align, center);
vertical-align: attr(valign, middle);
}
td {
vertical-align: attr(align, left);
vertical-align: attr(valign, middle);
}
}
table {

View File

@@ -44,6 +44,7 @@ class HaNavigationList extends LitElement {
>
<ha-svg-icon
.path=${page.iconPath}
.secondaryPath=${page.iconSecondaryPath}
.viewBox=${page.iconViewBox}
></ha-svg-icon>
</div>

View File

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

View File

@@ -1,6 +1,6 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import { mdiClose, mdiMagnify, mdiMinusBoxOutline, mdiPlus } from "@mdi/js";
import Fuse from "fuse.js";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -26,6 +26,8 @@ import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
import "./ha-combo-box-item";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -55,6 +57,7 @@ export interface PickerComboBoxItem {
}
export const NO_ITEMS_AVAILABLE_ID = "___no_items_available___";
const PADDING_ID = "___padding___";
const DEFAULT_ROW_RENDERER: RenderItemFunction<PickerComboBoxItem> = (
item
@@ -108,7 +111,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
public getItems!: (
searchString?: string,
section?: string
) => (PickerComboBoxItem | string)[];
) => PickerComboBoxItem[] | undefined;
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@@ -146,21 +149,29 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ attribute: "selected-section" }) public selectedSection?: string;
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
@property({ type: Boolean, reflect: true }) public clearable = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-textfield") private _searchFieldElement?: HaTextField;
@state() private _items: (PickerComboBoxItem | string)[] = [];
@state() private _items: PickerComboBoxItem[] = [];
public setFieldValue(value: string) {
if (this._searchFieldElement) {
this._searchFieldElement.value = value;
}
}
protected get scrollableElement(): HTMLElement | null {
return this._virtualizerElement as HTMLElement | null;
return this.virtualizerElement as HTMLElement | null;
}
@state() private _sectionTitle?: string;
@state() private _valuePinned = true;
private _allItems: (PickerComboBoxItem | string)[] = [];
private _allItems: PickerComboBoxItem[] = [];
private _selectedItemIndex = -1;
@@ -200,8 +211,17 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return html`<ha-textfield
.label=${searchLabel}
@blur=${this._resetSelectedItem}
@input=${this._filterChanged}
></ha-textfield>
.iconTrailing=${this.clearable && !!this._search}
>
<ha-icon-button
@click=${this._clearSearch}
slot="trailingIcon"
.label=${this.hass?.localize("ui.common.clear") || "Clear"}
.path=${mdiClose}
></ha-icon-button>
</ha-textfield>
${this._renderSectionButtons()}
${this.sections?.length
? html`
@@ -237,6 +257,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@unpinned=${this._handleUnpinned}
@scroll=${this._onScrollList}
@focus=${this._focusList}
@blur=${this._resetSelectedItem}
@visibilityChanged=${this._visibilityChanged}
>
</lit-virtualizer>
@@ -269,18 +290,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@eventOptions({ passive: true })
private _visibilityChanged(ev) {
if (
this._virtualizerElement &&
this.virtualizerElement &&
this.sectionTitleFunction &&
this.sections?.length
) {
const firstItem = this._virtualizerElement.items[ev.first];
const secondItem = this._virtualizerElement.items[ev.first + 1];
const firstItem = this.virtualizerElement.items[ev.first];
const secondItem = this.virtualizerElement.items[ev.first + 1];
this._sectionTitle = this.sectionTitleFunction({
firstIndex: ev.first,
lastIndex: ev.last,
firstItem: firstItem as PickerComboBoxItem | string,
secondItem: secondItem as PickerComboBoxItem | string,
itemsCount: this._virtualizerElement.items.length,
firstItem: firstItem as PickerComboBoxItem,
secondItem: secondItem as PickerComboBoxItem,
itemsCount: this.virtualizerElement.items.length,
});
}
}
@@ -294,7 +315,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this.getAdditionalItems?.(searchString) || [];
private _getItems = () => {
let items = [...this.getItems(this._search, this.selectedSection)];
let items = [...(this.getItems(this._search, this.selectedSection) || [])];
if (!this.sections?.length) {
items = items.sort((entityA, entityB) => {
@@ -323,28 +344,28 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
if (!items.length) {
items.push(NO_ITEMS_AVAILABLE_ID);
if (!items.length && !this.allowCustomValue) {
items.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
const additionalItems = this._getAdditionalItems();
items.push(...additionalItems);
if (this.mode === "dialog") {
items.push("padding"); // padding for safe area inset
items.push({ id: PADDING_ID, primary: "" }); // padding for safe area inset
}
return items;
};
private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
private _renderItem = (item: PickerComboBoxItem, index: number) => {
if (!item) {
return nothing;
}
if (item === "padding") {
if (item.id === PADDING_ID) {
return html`<div class="bottom-padding"></div>`;
}
if (item === NO_ITEMS_AVAILABLE_ID) {
if (item.id === NO_ITEMS_AVAILABLE_ID) {
return html`
<div class="combo-box-row">
<ha-combo-box-item type="text" compact>
@@ -396,9 +417,22 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _valueSelected = (ev: Event) => {
ev.stopPropagation();
const value = (ev.currentTarget as any).value as string;
const index = Number((ev.currentTarget as any).index);
const newValue = value?.trim();
fireEvent(this, "value-changed", { value: newValue });
this._fireSelectedEvents(newValue, index);
};
private _fireSelectedEvents(value: string, index: number) {
fireEvent(this, "value-changed", { value });
fireEvent(this, "index-selected", { index });
}
private _clearSearch = () => {
if (this._searchFieldElement) {
this._searchFieldElement.value = "";
this._searchFieldElement.dispatchEvent(new Event("input"));
}
};
private _fuseIndex = memoizeOne(
@@ -419,21 +453,18 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
return;
}
const index = this._fuseIndex(
this._allItems as PickerComboBoxItem[],
this.searchKeys
);
const index = this._fuseIndex(this._allItems, this.searchKeys);
let filteredItems = multiTermSortedSearch<PickerComboBoxItem>(
this._allItems as PickerComboBoxItem[],
this._allItems,
searchString,
this.searchKeys || DEFAULT_SEARCH_KEYS,
(item) => item.id,
index
) as (PickerComboBoxItem | string)[];
);
if (!filteredItems.length) {
filteredItems.push(NO_ITEMS_AVAILABLE_ID);
if (!filteredItems.length && !this.allowCustomValue) {
filteredItems.push({ id: NO_ITEMS_AVAILABLE_ID, primary: "" });
}
const additionalItems = this._getAdditionalItems(searchString);
@@ -442,8 +473,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
if (this.searchFn) {
filteredItems = this.searchFn(
searchString,
filteredItems as PickerComboBoxItem[],
this._allItems as PickerComboBoxItem[]
filteredItems,
this._allItems
);
}
@@ -459,7 +490,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
});
}
this._items = filteredItems as PickerComboBoxItem[];
this._items = filteredItems;
}
this._selectedItemIndex = -1;
@@ -483,8 +514,8 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
this._items = this._getItems();
// Reset scroll position when filter changes
if (this._virtualizerElement) {
this._virtualizerElement.scrollToIndex(0);
if (this.virtualizerElement) {
this.virtualizerElement.scrollToIndex(0);
}
}
@@ -507,13 +538,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectNextItem = (ev?: KeyboardEvent) => {
ev?.stopPropagation();
ev?.preventDefault();
if (!this._virtualizerElement) {
if (!this.virtualizerElement) {
return;
}
this._searchFieldElement?.focus();
const items = this._virtualizerElement.items as PickerComboBoxItem[];
const items = this.virtualizerElement.items as PickerComboBoxItem[];
const maxItems = items.length - 1;
@@ -547,14 +578,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectPreviousItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
ev.preventDefault();
if (!this._virtualizerElement) {
if (!this.virtualizerElement) {
return;
}
if (this._selectedItemIndex > 0) {
const nextIndex = this._selectedItemIndex - 1;
const items = this._virtualizerElement.items as PickerComboBoxItem[];
const items = this.virtualizerElement.items as PickerComboBoxItem[];
if (!items[nextIndex]) {
return;
@@ -576,13 +607,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectFirstItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
return;
}
const nextIndex = 0;
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -593,13 +624,13 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _selectLastItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
if (!this.virtualizerElement || !this.virtualizerElement.items.length) {
return;
}
const nextIndex = this._virtualizerElement.items.length - 1;
const nextIndex = this.virtualizerElement.items.length - 1;
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
if (typeof this.virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
this._selectedItemIndex = nextIndex;
@@ -609,14 +640,14 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
};
private _scrollToSelectedItem = () => {
this._virtualizerElement
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
this.virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
requestAnimationFrame(() => {
this._virtualizerElement
this.virtualizerElement
?.querySelector(`#list-item-${this._selectedItemIndex}`)
?.classList.add("selected");
});
@@ -624,12 +655,20 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _pickSelectedItem = (ev: KeyboardEvent) => {
ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (this._virtualizerElement?.items.length === 1) {
fireEvent(this, "value-changed", {
value: firstItem.id,
if (
this.virtualizerElement?.items?.length !== undefined &&
this.virtualizerElement.items.length < 4 && // it still can have a section title and a padding item
this.virtualizerElement.items.filter((item) => typeof item !== "string")
.length === 1
) {
(
this.virtualizerElement?.items as (PickerComboBoxItem | string)[]
).forEach((item, index) => {
if (typeof item !== "string") {
this._fireSelectedEvents(item.id, index);
}
});
return;
}
if (this._selectedItemIndex === -1) {
@@ -639,16 +678,16 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
// if filter button is focused
ev.preventDefault();
const item = this._virtualizerElement?.items[
const item = this.virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item) {
fireEvent(this, "value-changed", { value: item.id });
this._fireSelectedEvents(item.id, this._selectedItemIndex);
}
};
private _resetSelectedItem() {
this._virtualizerElement
this.virtualizerElement
?.querySelector(".selected")
?.classList.remove("selected");
this._selectedItemIndex = -1;
@@ -658,11 +697,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
typeof item === "string" ? item : item?.id;
private _getInitialSelectedIndex() {
if (!this._virtualizerElement || this._search || !this.value) {
if (!this.virtualizerElement || this._search || !this.value) {
return 0;
}
const index = this._virtualizerElement.items.findIndex(
const index = this.virtualizerElement.items.findIndex(
(item) =>
typeof item !== "string" &&
(item as PickerComboBoxItem).id === this.value
@@ -687,6 +726,10 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
flex: 1;
}
:host([clearable]) {
--text-field-padding: 0 0 0 var(--ha-space-4);
}
ha-textfield {
padding: 0 var(--ha-space-3);
margin-bottom: var(--ha-space-3);
@@ -788,8 +831,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
.section-title,
.title {
box-sizing: border-box;
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
padding: var(--ha-space-1) var(--ha-space-4);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
@@ -818,7 +862,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
opacity: 0;
position: absolute;
top: 1px;
width: calc(100% - var(--ha-space-8));
width: calc(100% - var(--ha-space-4));
}
.section-title.show {
@@ -842,4 +886,8 @@ declare global {
interface HTMLElementTagNameMap {
"ha-picker-combo-box": HaPickerComboBox;
}
interface HASSDomEvents {
"index-selected": { index: number };
}
}

View File

@@ -223,7 +223,6 @@ export class HaRelatedItems extends LitElement {
.src=${brandsUrl({
domain: entry.domain,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"
@@ -249,7 +248,6 @@ export class HaRelatedItems extends LitElement {
.src=${brandsUrl({
domain: integration,
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
})}
crossorigin="anonymous"

View File

@@ -10,7 +10,7 @@ class HaSectionTitle extends LitElement {
static styles = css`
:host {
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
padding: var(--ha-space-2) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);

View File

@@ -1,13 +1,16 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-tooltip";
export interface Segment {
value: number;
color: string;
label?: TemplateResult | string;
entityId?: string;
}
@customElement("ha-segmented-bar")
@@ -24,20 +27,32 @@ class HaSegmentedBar extends LitElement {
@property({ type: Boolean, attribute: "hide-tooltip" })
public hideTooltip = false;
@property({ type: Boolean }) public clickable = false;
@property({ type: Boolean, attribute: "bar-clickable" })
public barClickable = false;
@property({ attribute: false })
public hiddenSegments?: number[];
protected render(): TemplateResult {
const totalValue = this.segments.reduce(
(acc, segment) => acc + segment.value,
0
);
const totalValue = this.segments.reduce((acc, segment, index) => {
if (this.hiddenSegments?.includes(index)) return acc;
return acc + segment.value;
}, 0);
return html`
<div class="container">
<div class="heading">
<div class="title">
<span>${this.heading}</span>
<span>${this.description}</span>
</div>
<slot name="extra"></slot>
</div>
${this.heading || this.description
? html`
<div class="heading">
<div class="title">
<span>${this.heading}</span>
<span>${this.description}</span>
</div>
<slot name="extra"></slot>
</div>
`
: nothing}
<div class="bar">
${this.segments.map(
(segment, index) => html`
@@ -50,9 +65,15 @@ class HaSegmentedBar extends LitElement {
`}
<div
id="segment-${index}"
class=${classMap({ clickable: this.barClickable })}
data-index=${index}
@click=${this.barClickable ? this._handleSegmentClick : nothing}
style=${styleMap({
width: `${(segment.value / totalValue) * 100}%`,
backgroundColor: segment.color,
display: this.hiddenSegments?.includes(index)
? "none"
: "block",
})}
></div>
`
@@ -62,10 +83,19 @@ class HaSegmentedBar extends LitElement {
? nothing
: html`
<ul class="legend">
${this.segments.map((segment) =>
${this.segments.map((segment, index) =>
segment.label
? html`
<li>
<li
class=${classMap({
clickable: this.clickable,
hidden: this.hiddenSegments?.includes(index),
})}
data-index=${index}
@click=${this.clickable
? this._handleLegendClick
: nothing}
>
<div
class="bullet"
style=${styleMap({
@@ -83,6 +113,24 @@ class HaSegmentedBar extends LitElement {
`;
}
private _handleSegmentClick(ev: Event): void {
const target = ev.currentTarget as HTMLElement;
const index = Number(target.dataset.index);
const segment = this.segments[index];
if (segment) {
fireEvent(this, "segment-clicked", { index, segment });
}
}
private _handleLegendClick(ev: Event): void {
const target = ev.currentTarget as HTMLElement;
const index = Number(target.dataset.index);
const segment = this.segments[index];
if (segment) {
fireEvent(this, "legend-item-clicked", { index, segment });
}
}
static styles = css`
.container {
width: 100%;
@@ -118,7 +166,10 @@ class HaSegmentedBar extends LitElement {
.bar div {
height: 100%;
}
.bar div:hover {
.bar div.clickable {
cursor: pointer;
}
.bar div.clickable:hover {
opacity: 0.8;
}
.legend {
@@ -144,6 +195,18 @@ class HaSegmentedBar extends LitElement {
.spacer {
flex: 1;
}
.legend li.clickable {
cursor: pointer;
}
.legend li.clickable:hover {
opacity: 0.8;
}
.legend li.hidden {
opacity: 0.5;
}
.legend li.hidden .label {
text-decoration: line-through;
}
`;
}
@@ -151,4 +214,8 @@ declare global {
interface HTMLElementTagNameMap {
"ha-segmented-bar": HaSegmentedBar;
}
interface HASSDomEvents {
"segment-clicked": { index: number; segment: Segment };
"legend-item-clicked": { index: number; segment: Segment };
}
}

View File

@@ -38,6 +38,13 @@ export class HaChooseSelector extends LitElement {
) {
this._setActiveChoice();
}
if (
changedProperties.has("value") &&
changedProperties.get("value")?.active_choice &&
changedProperties.get("value")?.active_choice !== this._activeChoice
) {
this._setActiveChoice();
}
}
protected render() {
@@ -54,7 +61,8 @@ export class HaChooseSelector extends LitElement {
size="small"
.buttons=${this._toggleButtons(
this.selector.choose.choices,
this.selector.choose.translation_key
this.selector.choose.translation_key,
this.hass.localize
)}
.active=${this._activeChoice}
@value-changed=${this._choiceChanged}
@@ -72,7 +80,11 @@ export class HaChooseSelector extends LitElement {
}
private _toggleButtons = memoizeOne(
(choices: ChooseSelector["choose"]["choices"], translationKey?: string) =>
(
choices: ChooseSelector["choose"]["choices"],
translationKey?: string,
_localize?: HomeAssistant["localize"]
) =>
Object.keys(choices).map((choice) => ({
label:
this.localizeValue && translationKey

View File

@@ -1,3 +1,4 @@
import memoizeOne from "memoize-one";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DurationSelector } from "../../data/selector";
@@ -11,7 +12,10 @@ export class HaTimeDuration extends LitElement {
@property({ attribute: false }) public selector!: DurationSelector;
@property({ attribute: false }) public value?: HaDurationData;
@property({ attribute: false }) public value?:
| HaDurationData
| string
| number;
@property() public label?: string;
@@ -21,16 +25,47 @@ export class HaTimeDuration extends LitElement {
@property({ type: Boolean }) public required = true;
private _data = memoizeOne(
(value?: HaDurationData | string | number): HaDurationData | undefined => {
if (typeof value === "number") {
return { seconds: value };
}
if (typeof value === "string") {
const negative = value.trim()[0] === "-";
const parts = value
.split(":")
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
if (parts.length === 1) {
return { seconds: parts[0] };
}
if (parts.length === 2) {
return { hours: parts[0], minutes: parts[1] };
}
if (parts.length === 3) {
return {
hours: parts[0],
minutes: parts[1],
seconds: parts[2],
};
}
return undefined;
}
return value;
}
);
protected render() {
return html`
<ha-duration-input
.label=${this.label}
.helper=${this.helper}
.data=${this.value}
.data=${this._data(this.value)}
.disabled=${this.disabled}
.required=${this.required}
.enableDay=${this.selector.duration?.enable_day}
.enableMillisecond=${this.selector.duration?.enable_millisecond}
.allowNegative=${this.selector.duration?.allow_negative}
></ha-duration-input>
`;
}

View File

@@ -87,7 +87,6 @@ export class HaMediaSelector extends LitElement {
this._thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnail),
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
});
} else {

View File

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

View File

@@ -1,12 +1,17 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { StateSelector } from "../../data/selector";
import { extractFromTarget } from "../../data/target";
import memoizeOne from "memoize-one";
import {
resolveEntityIDs,
type StateSelector,
type TargetSelector,
} from "../../data/selector";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-state-picker";
import "../entity/ha-entity-states-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
@customElement("ha-selector-state")
export class HaSelectorState extends SubscribeMixin(LitElement) {
@@ -28,16 +33,33 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
filter_attribute?: string;
filter_entity?: string | string[];
filter_target?: HassServiceTarget;
target_selector?: TargetSelector;
};
@state() private _entityIds?: string | string[];
private _convertExtraOptions = memoizeOne(
(
extraOptions?: { label: string; value: any }[]
): PickerComboBoxItem[] | undefined => {
if (!extraOptions) {
return undefined;
}
return extraOptions.map((option) => ({
id: option.value,
primary: option.label,
sorting_label: option.label,
}));
}
);
willUpdate(changedProps) {
if (changedProps.has("selector") || changedProps.has("context")) {
this._resolveEntityIds(
this.selector.state?.entity_id,
this.context?.filter_entity,
this.context?.filter_target
this.context?.filter_target,
this.context?.target_selector
).then((entityIds) => {
this._entityIds = entityIds;
});
@@ -45,6 +67,9 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
}
protected render() {
const extraOptions = this._convertExtraOptions(
this.selector.state?.extra_options
);
if (this.selector.state?.multiple) {
return html`
<ha-entity-states-picker
@@ -52,7 +77,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
.extraOptions=${extraOptions}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -69,7 +94,7 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
.entityId=${this._entityIds}
.attribute=${this.selector.state?.attribute ||
this.context?.filter_attribute}
.extraOptions=${this.selector.state?.extra_options}
.extraOptions=${extraOptions}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
@@ -84,7 +109,8 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
private async _resolveEntityIds(
selectorEntityId: string | string[] | undefined,
contextFilterEntity: string | string[] | undefined,
contextFilterTarget: HassServiceTarget | undefined
contextFilterTarget: HassServiceTarget | undefined,
contextTargetSelector: TargetSelector | undefined
): Promise<string | string[] | undefined> {
if (selectorEntityId !== undefined) {
return selectorEntityId;
@@ -93,8 +119,14 @@ export class HaSelectorState extends SubscribeMixin(LitElement) {
return contextFilterEntity;
}
if (contextFilterTarget !== undefined) {
const result = await extractFromTarget(this.hass, contextFilterTarget);
return result.referenced_entities;
return resolveEntityIDs(
this.hass,
contextFilterTarget,
this.hass.entities,
this.hass.devices,
this.hass.areas,
contextTargetSelector
);
}
return undefined;
}

View File

@@ -5,6 +5,7 @@ import type { ActionConfig } from "../../data/lovelace/config/action";
import type { UiActionSelector } from "../../data/selector";
import "../../panels/lovelace/components/hui-action-editor";
import type { HomeAssistant } from "../../types";
import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor";
@customElement("ha-selector-ui_action")
export class HaSelectorUiAction extends LitElement {
@@ -14,6 +15,8 @@ export class HaSelectorUiAction extends LitElement {
@property({ attribute: false }) public value?: ActionConfig;
@property({ attribute: false }) public context?: ActionRelatedContext;
@property() public label?: string;
@property() public helper?: string;
@@ -24,6 +27,7 @@ export class HaSelectorUiAction extends LitElement {
.label=${this.label}
.hass=${this.hass}
.config=${this.value}
.context=${this.context}
.actions=${this.selector.ui_action?.actions}
.defaultAction=${this.selector.ui_action?.default_action}
.tooltipText=${this.helper}

View File

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

View File

@@ -52,8 +52,6 @@ import "./ha-spinner";
import "./ha-svg-icon";
import "./user/ha-user-badge";
const SUPPORT_SCROLL_IF_NEEDED = "scrollIntoViewIfNeeded" in document.body;
const SORT_VALUE_URL_PATHS = {
energy: 1,
map: 2,
@@ -344,17 +342,6 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
this._calculateCounts();
if (!SUPPORT_SCROLL_IF_NEEDED) {
return;
}
if (oldHass?.panelUrl !== this.hass.panelUrl) {
const selectedEl = this.shadowRoot!.querySelector(".selected");
if (selectedEl) {
// @ts-ignore
selectedEl.scrollIntoViewIfNeeded();
}
}
}
private _calculateCounts = throttle(() => {
@@ -602,10 +589,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
// On keypresses on the listbox, we're going to ignore mouse enter events
// for 100ms so that we ignore it when pressing down arrow scrolls the
// sidebar causing the mouse to hover a new icon
if (
this.alwaysExpand ||
new Date().getTime() < this._recentKeydownActiveUntil
) {
if (new Date().getTime() < this._recentKeydownActiveUntil) {
return;
}
if (this._mouseLeaveTimeout) {
@@ -625,7 +609,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
private _listboxFocusIn(ev) {
if (this.alwaysExpand || ev.target.localName !== "ha-md-list-item") {
if (ev.target.localName !== "ha-md-list-item") {
return;
}
this._showTooltip(ev.target);
@@ -665,6 +649,14 @@ class HaSidebar extends SubscribeMixin(LitElement) {
clearTimeout(this._tooltipHideTimeout);
this._tooltipHideTimeout = undefined;
}
const itemText = item.querySelector(".item-text") as HTMLElement | null;
if (this.hasAttribute("expanded") && itemText) {
const isTruncated = itemText.scrollWidth > itemText.clientWidth;
if (!isTruncated) {
this._hideTooltip();
return;
}
}
const tooltip = this._tooltip;
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!;
const listbox = [...allListbox].find((lb) => lb.contains(item));
@@ -675,9 +667,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
tooltip.innerText = (
item.querySelector(".item-text") as HTMLElement
).innerText;
tooltip.innerText = itemText?.innerText ?? "";
tooltip.style.display = "block";
tooltip.style.position = "fixed";
tooltip.style.top = `${top}px`;
@@ -859,6 +849,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
:host([expanded]) ha-md-list-item .item-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.divider {
@@ -926,7 +919,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
position: absolute;
opacity: 0.9;
border-radius: var(--ha-border-radius-sm);
white-space: nowrap;
max-width: calc(var(--ha-space-20) * 3);
white-space: normal;
overflow-wrap: break-word;
color: var(--sidebar-background-color);
background-color: var(--sidebar-text-color);
padding: var(--ha-space-1);

View File

@@ -57,6 +57,7 @@ export class HaSlider extends Slider {
#thumb {
border: none;
background-color: var(--ha-slider-thumb-color, var(--primary-color));
overflow: hidden;
}
#thumb:after {

View File

@@ -1,6 +1,7 @@
import "@home-assistant/webawesome/dist/components/dialog/dialog";
import type WaDialog from "@home-assistant/webawesome/dist/components/dialog/dialog";
import { mdiClose } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -13,7 +14,6 @@ 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 { isIosApp } from "../util/is_ios";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -50,7 +50,6 @@ export type DialogWidth = "small" | "medium" | "large" | "full";
* @cssprop --ha-dialog-hide-duration - Hide animation duration.
* @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-margin-top - Top margin for the dialog surface.
*
* @attr {boolean} open - Controls the dialog open state.
@@ -107,6 +106,9 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, reflect: true, attribute: "flexcontent" })
public flexContent = false;
@property({ type: Boolean, attribute: "without-header" })
public withoutHeader = false;
@state()
private _open = false;
@@ -115,6 +117,8 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
@state()
private _bodyScrolled = false;
private _escapePressed = false;
protected get scrollableElement(): HTMLElement | null {
return this.bodyContainer;
}
@@ -140,33 +144,41 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
(this.headerTitle !== undefined ? "ha-wa-dialog-title" : undefined)
)}
aria-describedby=${ifDefined(this.ariaDescribedBy)}
@keydown=${this._handleKeyDown}
@wa-hide=${this._handleHide}
@wa-show=${this._handleShow}
@wa-after-show=${this._handleAfterShow}
@wa-after-hide=${this._handleAfterHide}
>
<slot name="header">
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="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>
${!this.withoutHeader
? html` <slot name="header">
<ha-dialog-header
.subtitlePosition=${this.headerSubtitlePosition}
.showBorder=${this._bodyScrolled}
>
<slot name="headerNavigationIcon" slot="navigationIcon">
<ha-icon-button
data-dialog="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>`
: nothing}
<div class="content-wrapper">
<div class="body ha-scrollbar" @scroll=${this._handleBodyScroll}>
<slot></slot>
@@ -185,21 +197,22 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
if (isIosApp(this.hass)) {
const element = this.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-wa-dialog-autofocus";
}
this.hass.auth.external!.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
// temporary disabled because of issues with focus in iOS app, can be reenabled in 2026.2.0
// if (isIosApp(this.hass)) {
// const element = this.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-wa-dialog-autofocus";
// }
// this.hass.auth.external!.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
};
@@ -208,9 +221,11 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
fireEvent(this, "after-show");
};
private _handleAfterHide = () => {
this._open = false;
fireEvent(this, "closed");
private _handleAfterHide = (ev: CustomEvent<{ source: Element }>) => {
if (ev.eventPhase === Event.AT_TARGET) {
this._open = false;
fireEvent(this, "closed");
}
};
public disconnectedCallback(): void {
@@ -223,6 +238,23 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
this._bodyScrolled = (ev.target as HTMLDivElement).scrollTop > 0;
}
private _handleKeyDown(ev: KeyboardEvent) {
if (ev.key === "Escape") {
this._escapePressed = true;
}
}
private _handleHide(ev: CustomEvent<{ source: Element }>) {
if (
this.preventScrimClose &&
this._escapePressed &&
ev.detail.source === (ev.target as WaDialog).dialog
) {
ev.preventDefault();
}
this._escapePressed = false;
}
static get styles() {
return [
...super.styles,
@@ -271,6 +303,7 @@ export class HaWaDialog extends ScrollableFadeMixin(LitElement) {
}
wa-dialog::part(dialog) {
color: var(--primary-text-color);
min-width: var(--width, var(--full-width));
max-width: var(--width, var(--full-width));
max-height: var(

View File

@@ -24,6 +24,7 @@ import { setupLeafletMap } from "../../common/dom/setup-leaflet-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { DecoratedMarker } from "../../common/map/decorated_marker";
import { filterXSS } from "../../common/util/xss";
import type { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch";
import "../ha-icon-button";
@@ -381,7 +382,7 @@ export class HaMap extends ReactiveElement {
this.hass.config
);
}
return `${path.name}<br>${formattedTime}`;
return `${filterXSS(path.name ?? "")}<br>${formattedTime}`;
}
private _drawPaths(): void {
@@ -549,7 +550,7 @@ export class HaMap extends ReactiveElement {
iconHTML = el.outerHTML;
} else {
const el = document.createElement("span");
el.innerHTML = title;
el.textContent = title;
iconHTML = el.outerHTML;
}

View File

@@ -1,7 +1,5 @@
import type { ActionDetail } from "@material/mwc-list";
import {
mdiAlphaABoxOutline,
mdiArrowLeft,
mdiClose,
mdiDotsVertical,
mdiGrid,
@@ -21,9 +19,12 @@ import type {
} from "../../data/media-player";
import { haStyleDialog, haStyleDialogFixedTop } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-dialog";
import "../ha-dialog-header";
import "../ha-list-item";
import "../ha-dropdown";
import "../ha-dropdown-item";
import type { HaDropdownItem } from "../ha-dropdown-item";
import "../ha-icon-button-arrow-prev";
import "../ha-wa-dialog";
import "./ha-media-manage-button";
import "./ha-media-player-browse";
import type {
@@ -44,6 +45,8 @@ class DialogMediaPlayerBrowse extends LitElement {
@state() _preferredLayout: MediaPlayerLayoutType = "auto";
@state() private _open = false;
@query("ha-media-player-browse") private _browser!: HaMediaPlayerBrowse;
public showDialog(params: MediaPlayerBrowseDialogParams): void {
@@ -54,9 +57,11 @@ class DialogMediaPlayerBrowse extends LitElement {
media_content_type: undefined,
},
];
this._open = true;
}
public closeDialog() {
this._open = false;
this._params = undefined;
this._navigateIds = undefined;
this._currentItem = undefined;
@@ -71,28 +76,20 @@ class DialogMediaPlayerBrowse extends LitElement {
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
hideActions
flexContent
.heading=${!this._currentItem
? this.hass.localize(
"ui.components.media-browser.media-player-browser"
)
: this._currentItem.title}
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
@closed=${this.closeDialog}
@opened=${this._dialogOpened}
>
<ha-dialog-header show-border slot="heading">
<ha-dialog-header show-border slot="header">
${this._navigateIds.length > (this._params.minimumNavigateLevel ?? 1)
? html`
<ha-icon-button
<ha-icon-button-arrow-prev
slot="navigationIcon"
.path=${mdiArrowLeft}
@click=${this._goBack}
></ha-icon-button>
></ha-icon-button-arrow-prev>
`
: nothing}
<span slot="title">
@@ -108,52 +105,51 @@ class DialogMediaPlayerBrowse extends LitElement {
.currentItem=${this._currentItem}
@media-refresh=${this._refreshMedia}
></ha-media-manage-button>
<ha-button-menu
<ha-dropdown
slot="actionItems"
@action=${this._handleMenuAction}
@wa-select=${this._handleMenuAction}
@closed=${stopPropagation}
fixed
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-list-item graphic="icon">
${this.hass.localize("ui.components.media-browser.auto")}
<ha-dropdown-item value="auto">
<ha-svg-icon
class=${this._preferredLayout === "auto"
? "selected_menu_item"
: ""}
slot="graphic"
slot="icon"
.path=${mdiAlphaABoxOutline}
></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize("ui.components.media-browser.grid")}
${this.hass.localize("ui.components.media-browser.auto")}
</ha-dropdown-item>
<ha-dropdown-item value="grid">
<ha-svg-icon
class=${this._preferredLayout === "grid"
? "selected_menu_item"
: ""}
slot="graphic"
slot="icon"
.path=${mdiGrid}
></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize("ui.components.media-browser.list")}
${this.hass.localize("ui.components.media-browser.grid")}
</ha-dropdown-item>
<ha-dropdown-item value="list">
<ha-svg-icon
slot="graphic"
slot="icon"
class=${this._preferredLayout === "list"
? "selected_menu_item"
: ""}
.path=${mdiListBoxOutline}
></ha-svg-icon>
</ha-list-item>
</ha-button-menu>
${this.hass.localize("ui.components.media-browser.list")}
</ha-dropdown-item>
</ha-dropdown>
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
dialogAction="close"
data-dialog="close"
slot="actionItems"
></ha-icon-button>
</ha-dialog-header>
@@ -173,7 +169,7 @@ class DialogMediaPlayerBrowse extends LitElement {
@media-picked=${this._mediaPicked}
@media-browsed=${this._mediaBrowsed}
></ha-media-player-browse>
</ha-dialog>
</ha-wa-dialog>
`;
}
@@ -181,15 +177,16 @@ class DialogMediaPlayerBrowse extends LitElement {
this.classList.add("opened");
}
private async _handleMenuAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
private async _handleMenuAction(ev: CustomEvent<{ item: HaDropdownItem }>) {
const action = ev.detail?.item?.value;
switch (action) {
case "auto":
this._preferredLayout = "auto";
break;
case 1:
case "grid":
this._preferredLayout = "grid";
break;
case 2:
case "list":
this._preferredLayout = "list";
break;
}
@@ -225,8 +222,7 @@ class DialogMediaPlayerBrowse extends LitElement {
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-z-index: 9;
ha-wa-dialog {
--dialog-content-padding: 0;
}
@@ -241,9 +237,9 @@ class DialogMediaPlayerBrowse extends LitElement {
}
@media (min-width: 800px) {
ha-dialog {
--mdc-dialog-max-width: 800px;
--mdc-dialog-max-height: calc(
ha-wa-dialog {
--ha-dialog-max-width: 800px;
--ha-dialog-max-height: calc(
100vh - var(--ha-space-18) - var(--safe-area-inset-y)
);
}

View File

@@ -793,7 +793,6 @@ export class HaMediaPlayerBrowse extends LitElement {
thumbnailUrl = brandsUrl({
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
useFallback: true,
darkOptimized: this.hass.themes?.darkMode,
});
}

View File

@@ -1,14 +1,15 @@
import { type CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiSpeaker } from "@mdi/js";
import { mdiSpeaker, mdiSpeakerPause, mdiSpeakerPlay } from "@mdi/js";
import memoizeOne from "memoize-one";
import type { HomeAssistant } from "../../types";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeRTL } from "../../common/util/compute_rtl";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-switch";
import "../ha-svg-icon";
import type { MediaPlayerEntity } from "../../data/media-player";
@customElement("ha-media-player-toggle")
class HaMediaPlayerToggle extends LitElement {
@@ -20,15 +21,61 @@ class HaMediaPlayerToggle extends LitElement {
@property({ type: Boolean }) public disabled = false;
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"],
floors: HomeAssistant["floors"],
isRTL: boolean,
stateObj: HomeAssistant["states"][string]
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
);
protected render() {
const stateObj = this.hass.states[this.entityId];
let icon = mdiSpeaker;
if (stateObj.state === "playing") {
icon = mdiSpeakerPlay;
} else if (stateObj.state === "paused") {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(this.hass);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors,
isRTL,
stateObj
);
return html`<div class="list-item">
<ha-svg-icon .path=${mdiSpeaker}></ha-svg-icon>
<ha-svg-icon .path=${icon}></ha-svg-icon>
<div class="info">
<div class="main-text">${computeStateName(stateObj)}</div>
<div class="secondary-text">
${this._formatSecondaryText(stateObj as MediaPlayerEntity)}
</div>
<div class="main-text">${primary}</div>
<div class="secondary-text">${secondary}</div>
</div>
<ha-switch
.disabled=${this.disabled}
@@ -38,16 +85,6 @@ class HaMediaPlayerToggle extends LitElement {
</div>`;
}
private _formatSecondaryText(stateObj: MediaPlayerEntity): string {
if (stateObj.state !== "playing") {
return this.hass.localize("ui.card.media_player.idle");
}
return [stateObj.attributes.media_title, stateObj.attributes.media_artist]
.filter((segment) => segment)
.join(" · ");
}
static get styles(): CSSResultGroup {
return [
css`

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