Compare commits

...

79 Commits

Author SHA1 Message Date
Aidan Timson 5ce62ca5ec Prevent scrim closure on category dirty state 2026-06-02 13:50:39 +01:00
Aidan Timson 8a8b4bf138 Move dirty state provider to dialog build level using deferred state 2026-06-02 13:40:22 +01:00
Aidan Timson 6f6c860338 remove cast 2026-06-02 12:32:51 +01:00
Aidan Timson a79051caf1 Fix loop 2026-06-02 11:36:31 +01:00
Aidan Timson 2e459e8029 Deep state (existing) 2026-06-02 11:29:51 +01:00
Aidan Timson 373855ddb2 Shallow state (new) 2026-06-02 11:22:40 +01:00
Aidan Timson af8f250b60 Dirty state context provider 2026-06-02 11:19:33 +01:00
karwosts d2c868f904 Avoid double collection fetch when loading energy dashboard strategy (#52351)
* Avoid double collection fetch when loading energy

* Load the now collection in strategy if we're heading to /now
2026-06-02 12:19:05 +03:00
Copilot 9f34de5de6 Update dialog dismissal guideline (#52333)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-02 12:07:19 +03:00
Jan-Philipp Benecke 6c9452aa5a Use context instead passing narrow down to hass-tabs-subpage (#52345)
Use context instead passing narrow down in hass-tabs-subpage
2026-06-02 12:06:47 +03:00
Aidan Timson 2cf79853aa Improve messaging and consolidate add to dialogs (#52330) 2026-06-02 10:32:56 +02:00
Petar Petrov 6152812138 Skip in-progress updates when triggering Update all (#52353)
Filter out updates that are already installing before calling the
update.install service, and disable a group's Update all button when
every update in it is already in progress.
2026-06-02 09:11:26 +02:00
Aidan Timson 5540a6c1ff Use options object instead of multiple undefined params for getAreas, getDevices, getEntities (#52342) 2026-06-02 08:52:02 +02:00
renovate[bot] e04297f2bd Update dependency date-fns to v4.4.0 (#52350)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 08:20:39 +03:00
renovate[bot] e89f76bbbb Update dependency eslint to v10.4.1 (#52349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-02 08:20:07 +03:00
renovate[bot] 319ba3940e Update tsparticles to v4.1.0 (#52344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 16:27:46 +00:00
Paul Bottein b9920065a2 Fix vacuum and lawn mower features not showing default buttons (#52343) 2026-06-01 18:13:38 +02:00
Petar Petrov 3bb5201d41 Potential fix for code scanning alert no. 3: Incomplete string escaping or encoding (#52332)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-01 18:12:13 +02:00
Bram Kragten a0648b85ff Allow to set refresh url while dialog is open, use for matter device (#52341)
Allow to set refresh dialog while dialog is open, use for matter device
2026-06-01 18:11:23 +02:00
Petar Petrov 54f901c7c9 Group pending updates by category on the updates page (#52215) 2026-06-01 17:23:56 +02:00
Wendelin 2483a917f8 Fix picker default popover-placement (#52336) 2026-06-01 14:55:48 +01:00
renovate[bot] d9cae08f53 Update dependency @rspack/dev-server to v2.0.3 (#52338) 2026-06-01 14:55:47 +02:00
Aidan Timson 106b35d6cf Create related context, add automation element dialog search weighting for area (#52280)
* Create shared related context for quick bar and add automation element

* Provider mixin and dont pass sets when context can be used

* Direct use consume

* Move context provider into class and use in context mixin

* Cleanup

* Match signatures with lazy context provider

* Fix order of operations

* Typing

* Fix popstate on dialog launch/close

* Typing improvement
2026-06-01 15:49:50 +03:00
Wendelin f12d305688 App-Info: Hide app title on narrow (#52337)
Hide app title on narrow
2026-06-01 15:36:48 +03:00
Paul Bottein d2326b4f62 Respect backend order for floors and areas in entity tree (#52329) 2026-06-01 15:02:01 +03:00
Jan-Philipp Benecke ea9424053a Introduce narrow viewport context (#52320)
* Introduce narrow viewport context

* Format

* Revert sidebar narrow context

* Discard changes to src/components/ha-sidebar.ts
2026-06-01 14:58:57 +03:00
Petar Petrov 70ffef8807 Remove unused dependencies (#52328)
Drop dependencies that are no longer referenced anywhere in the codebase:

- @material/mwc-base: declared directly but never imported; still pulled
  in transitively by the other @material/mwc-* packages, so the explicit
  declaration was redundant.
- @types/mocha: the test runner is Vitest (test files import describe/it/
  expect from "vitest"); no Mocha globals or namespace are used.
- @types/webspeechapi: no Web Speech API usage in the codebase, and the
  modern TypeScript lib.dom already ships these definitions.
- @types/babel__plugin-transform-runtime: the plugin is only referenced by
  string name in the Babel config, never imported as a typed module, so the
  type stub is unused.

Co-authored-by: MindFreeze <noreply@anthropic.com>
2026-06-01 08:41:04 +02:00
Simon Lamon a32169f300 Ignore location in description (#52297) 2026-06-01 09:31:01 +03:00
Simon Lamon b508760d24 Show all counter actions if none specified (#52317)
Show all actions if none specified
2026-06-01 08:12:37 +03:00
Simon Lamon 541cab83de Fix lock workflow (#52318) 2026-06-01 08:11:45 +03:00
renovate[bot] 8e8f2bfa4c Update dependency @bundle-stats/plugin-webpack-filter to v4.22.2 (#52323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-01 08:11:09 +03:00
George Caliment bafe21ab48 Fixed label assignment dropdown scroll and width on mobile screens (#52326)
* Fixed ha-config-entities label assignment dropdown scroll and width on mobile screens

* Made ha-label handle text ellipsis by itself
2026-06-01 08:10:42 +03:00
George Caliment ee56d7d003 Fixed filter flex direction on mobile + removed unused classes (#52327)
* Fixed filter flex direction on mobile + removed unused classes

* Removed hard-coded height to fill all viewport
2026-06-01 08:09:42 +03:00
renovate[bot] 486b6bb561 Update dependency @rsdoctor/rspack-plugin to v1.5.12 (#52319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 11:47:39 +02:00
karwosts 9a9ceaebf2 Fix behavior for move view left/right (#52300) 2026-05-31 09:41:36 +02:00
Petar Petrov ff5bbf46ae Migrate date/time selectors to internationalization locale context (#52265)
Consume internationalizationContext for locale in ha-selector-date,
ha-selector-datetime, ha-selector-time, ha-selector-button-toggle, and
ha-selector-select (partial: hass kept where passed to children).
2026-05-31 09:16:43 +02:00
Petar Petrov 47fb4a2def Migrate hui-badge-edit-mode and hui-card-edit-mode to localize context (#52264)
* Migrate hui-badge-edit-mode and hui-card-edit-mode to localize context

Replace this.hass.localize with @consumeLocalize(); remove hass prop and
caller .hass bindings from Lovelace edit-mode overlays.

* Migrate hui-view-badges to localize context
2026-05-31 09:15:46 +02:00
Petar Petrov 0e716e5078 Migrate ha-person-badge and ha-user-badge to connection context (#52263)
* Migrate avatar badges off hass property (Group K)

Consume connectionContext for hassUrl and statesContext plus
consumeEntityState for the user badge person entity picture.
Remove .hass bindings from callers.

* Migrate ha-person-badge and ha-user-badge to connection context

Use connectionContext for hassUrl; ha-user-badge also consumes entity state
for person picture lookup.

* Fix ha-user-badge updates and restore hass binding

Use PropertyValues.has() to detect _states changes. Restore .hass bindings on
ha-user-picker callers and ha-generic-picker, which still require hass.

* Fix type error and restore hass on person subpage

- Type willUpdate with PropertyValues so changedProps.has("_states")
  type-checks (matches the other states-context consumers); fixes the
  failing lint:types CI check
- Restore .hass on hass-tabs-subpage in ha-config-person, which still
  requires hass and was crashing the person config page
- Drop now-dead .hass bindings on ha-user-badge in ha-user-picker
- Only rescan for the person entity when the user changes or the tracked
  entity is missing, instead of on every state update
2026-05-31 09:15:40 +02:00
dependabot[bot] 9e9fdfbad6 Bump home-assistant/actions from f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b to 868e6cb4607727d764341a158d98872cd63fa658 (#52312)
Bump home-assistant/actions

Bumps [home-assistant/actions](https://github.com/home-assistant/actions) from f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b to 868e6cb4607727d764341a158d98872cd63fa658.
- [Release notes](https://github.com/home-assistant/actions/releases)
- [Commits](https://github.com/home-assistant/actions/compare/f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b...868e6cb4607727d764341a158d98872cd63fa658)

---
updated-dependencies:
- dependency-name: home-assistant/actions
  dependency-version: 868e6cb4607727d764341a158d98872cd63fa658
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-31 09:13:37 +02:00
dependabot[bot] 5ebdb99ba7 Bump actions/stale from 10.2.0 to 10.3.0 (#52313)
Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to 10.3.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.3.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-05-31 09:13:22 +02:00
dependabot[bot] 1ca454cf02 Bump github/codeql-action from 4.35.5 to 4.36.0 (#52315)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.5 to 4.36.0.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/9e0d7b8d25671d64c341c19c0152d693099fb5ba...7211b7c8077ea37d8641b6271f6a365a22a5fbfa)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.36.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-05-31 09:13:20 +02:00
renovate[bot] 859d23c187 Update formatjs monorepo (#52309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 09:12:47 +02:00
renovate[bot] f9d205defe Update dependency @rspack/core to v2.0.5 (#52314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-31 09:12:44 +02:00
dependabot[bot] 00cc4e2a5a Bump dessant/lock-threads from 6.0.0 to 6.0.2 (#52311)
Bumps [dessant/lock-threads](https://github.com/dessant/lock-threads) from 6.0.0 to 6.0.2.
- [Release notes](https://github.com/dessant/lock-threads/releases)
- [Changelog](https://github.com/dessant/lock-threads/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dessant/lock-threads/compare/7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7...89ae32b08ed1a541efecbab17912962a5e38981c)

---
updated-dependencies:
- dependency-name: dessant/lock-threads
  dependency-version: 6.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-05-31 08:44:20 +02:00
renovate[bot] 6571feb556 Update dependency terser-webpack-plugin to v5.6.1 (#52307)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 18:54:29 +00:00
renovate[bot] 4150bc0806 Update dependency license-checker-rseidelsohn to v5 (#52304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 20:50:36 +02:00
renovate[bot] 958e3f2575 Update dependency @swc/helpers to v0.5.23 (#52303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-30 20:47:53 +02:00
Jan-Philipp Benecke 3d3292e2ad Use right token for topbar shadow transition (#52306) 2026-05-30 20:47:26 +02:00
karwosts 75b9fb2e34 Fix untracked legend in detail graph card (#52299) 2026-05-30 07:53:40 +03:00
Jan-Philipp Benecke 38f0ce306b Add box-shadow transition to top app bar (#52292) 2026-05-29 18:01:44 +02:00
Petar Petrov 1ffd19e20b Make battery dialog charge/discharge order consistent (#52295)
Order the energy fields as discharge then charge to match the power
fields in the energy panel battery settings dialog.

Co-authored-by: MindFreeze <noreply@anthropic.com>
2026-05-29 18:01:14 +02:00
Aidan Timson 9a216cae46 Add cover and valve favorite positions to suggestions (#52273) 2026-05-29 08:33:23 +03:00
karwosts 41e6408508 Fix missing location data in calendar (#52291) 2026-05-29 08:32:15 +03:00
renovate[bot] 97e85bc06f Update dependency typescript-eslint to v8.60.0 (#52290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-29 08:06:38 +03:00
Petar Petrov 5f2ad7fa01 Migrate hui-buttons-base and ha-selector-attribute to states context (partial hass) (#52262) 2026-05-28 16:16:10 +02:00
ildar170975 7b6b70023b Statistics graph card editor: add sub editor (#52182)
* add canEdit

* add canEdit

* add subEditor

* linter

* linter

* linter

* linter

* Remove div

* Update src/components/entity/ha-statistic-picker.ts

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

* Update src/components/entity/ha-statistic-picker.ts

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

* Update ha-statistic-picker.ts

* Update ha-statistic-picker.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-28 17:13:20 +03:00
Paul Bottein 256a06e35f Preserve PNG transparency on area pictures (#52282) 2026-05-28 16:08:16 +02:00
Paul Bottein 4e26c05ac6 Don't lowercase translated default action label (#52283) 2026-05-28 13:47:56 +00:00
Paul Bottein 04ee8ac415 Fix sun condition Between description showing reversed values (#52279) 2026-05-28 13:46:56 +00:00
Petar Petrov 63e144309c Migrate ha-selector choose/period/file/selector to localize context (#52266) 2026-05-28 15:32:21 +02:00
Petar Petrov 77039cda8e Migrate automation icon components to config and connection context (#52261) 2026-05-28 15:22:06 +02:00
Petar Petrov ab5b4ed792 Drop unused hass prop from ha-selector-boolean, ha-selector-duration, ha-sunburst-chart (#52253)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-28 13:21:47 +00:00
Petar Petrov a08905cd31 Migrate ha-relative-time and ha-absolute-time to localize/locale/config context (#52259)
* Migrate ha-relative-time and ha-absolute-time off hass property

Consume localize, locale, and config via Lit context so time primitives
only rerender when i18n or config slices change, and drop obsolete .hass
bindings from callers.

* Consume full i18n context in time display components

Use internationalizationContext directly for both localize and locale in
ha-relative-time and ha-absolute-time, avoiding mixed consumption patterns.
2026-05-28 16:16:11 +03:00
Petar Petrov a35349196f Migrate trace logbook components to localize context (#52260) 2026-05-28 15:14:44 +02:00
Petar Petrov dbdfdedd74 Migrate energy total badges to states/locale/localize context (partial hass) (#52255) 2026-05-28 15:01:31 +02:00
Petar Petrov a5c8547b2b Migrate ha-filter-blueprints and ha-filter-voice-assistants to localize context (#52252) 2026-05-28 14:59:13 +02:00
Jan-Philipp Benecke e373689a37 Refactor climate panel to use top bar component (#52245)
* Refactor climate panel to use top bar component

* Remove calc

* Remove

* Remove
2026-05-28 15:48:06 +03:00
Jan-Philipp Benecke 5edcdb8977 Refactor light panel to use top bar component (#52246)
* Refactor light panel to use top bar component

* Remove

* Remove

* Remove
2026-05-28 15:47:41 +03:00
Wendelin 26b8921e8c Fix automation behavior img file names (#52247)
fix behavior img names
2026-05-28 14:33:55 +02:00
Petar Petrov b8c201b6d3 Render echarts tooltips with Lit templates (#52235)
* Render echarts tooltips with Lit templates

Replace raw HTML string interpolation in echarts tooltip formatters with Lit templates so user-controlled fields (entity friendly_name, device names, node labels) are auto-escaped instead of relying on per-string filterXSS. ha-chart-base now wraps any function tooltip.formatter into a stable per-formatter container and handles Lit TemplateResult / nothing / null returns; the public HaECOption type lets charts express Lit-returning formatters without per-callsite casts.

* Simplify

* Refactor _getSeries

* Small fix

* Fix merge mistake

* Marker component and wrapper test
2026-05-28 14:27:47 +02:00
Wendelin 4a6c23c93e Fix automation add TCA paste (#52276)
Fix automation add paste
2026-05-28 14:22:52 +02:00
Wendelin e2712cb0b0 Fix row target count flickering, keyboard nav, type device (#52236)
* Fix row target count flickering

* Add noninteractive for device, fix keyboard nav

* Noninteractive action, conditon

* Remove unsued hass

* invert noninteractive
2026-05-28 14:14:26 +02:00
renovate[bot] db52cd0d8e Update babel monorepo to v7.29.7 (#52277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-28 15:01:16 +03:00
Wendelin 4891783c86 App details improve mobile and icon (#52275)
* icon instead of logo, enable wrap

* Keep logo

* revert test url
2026-05-28 13:23:55 +02:00
Wendelin b73732acdb Card visibility-status use ha-alert (#52271) 2026-05-28 10:57:41 +01:00
Wendelin d950514104 Fix automation note keyboard a11y (#52270) 2026-05-28 10:56:12 +01:00
Simon Lamon f37cf1e848 Don't redispatch the original event in a checklist item (#52242) 2026-05-28 08:26:00 +03:00
Paulus Schoutsen a188ef1b7a Fix resend-verification flash and concurrency on cloud signup (#52244)
Resending the confirmation email reused the registration code path, so
the flash on the login screen said "Account created!" even though no
new account was created. Pass a message key to _verificationEmailSent
so resend can show "Verification email sent." instead.

_handleResendVerifyEmail also never set _requestInProgress, so the
resend button (and the start-trial button, which share that flag) were
not disabled while a resend was in flight and could be clicked
repeatedly. Set the flag at the start and clear it on terminal errors;
_verificationEmailSent already clears it on success.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-28 08:11:54 +03:00
Wendelin 087ef159df Fix ha-radio-option checked theming (#52237)
Update ha-radio-option theming to use checked-icon-color for text and border
2026-05-27 15:58:00 +02:00
195 changed files with 4961 additions and 3464 deletions
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+3 -5
View File
@@ -13,13 +13,11 @@ jobs:
lock:
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
with:
github-token: ${{ github.token }}
process-only: "issues, prs"
issue-lock-inactive-days: "30"
issue-exclude-created-before: "2020-10-01T00:00:00Z"
issue-inactive-days: "30"
issue-lock-reason: ""
pr-lock-inactive-days: "1"
pr-exclude-created-before: "2020-11-01T00:00:00Z"
pr-inactive-days: "1"
pr-lock-reason: ""
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
python-version: ${{ env.PYTHON_VERSION }}
- name: Verify version
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 90 days stale policy
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-stale: 90
+3 -1
View File
@@ -57,7 +57,9 @@ gulp.task("gather-gallery-pages", async function gatherPages() {
if (descriptionContent === "") {
hasDescription = false;
} else {
descriptionContent = marked(descriptionContent).replace(/`/g, "\\`");
descriptionContent = marked(descriptionContent)
.replace(/\\/g, "\\\\")
.replace(/`/g, "\\`");
fs.mkdirSync(path.resolve(galleryBuild, category), { recursive: true });
fs.writeFileSync(
path.resolve(galleryBuild, `${pageId}-description.ts`),
@@ -13,7 +13,7 @@ Our dialogs are based on the latest version of Material Design. Please note that
- Dialogs have a max width of 560px. Alert and confirmation dialogs have a fixed width of 320px. If you need more width, consider a dedicated page instead.
- The close X-icon is on the top left, on all screen sizes. Except for alert and confirmation dialogs, they only have buttons and no X-icon. This is different compared to the Material guidelines.
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user needs to fill out. Instead it will animate "no" by a little shake.
- Dialogs can't be closed with ESC or clicked outside of the dialog when there is a form that the user **has made changes to**. Instead it will animate "no" by a little shake.
- Extra icon buttons are on the top right, for example help, settings and expand dialog. More than 2 icon buttons, they will be in an overflow menu.
- The submit button is grouped with a cancel button at the bottom right, on all screen sizes. Fullscreen mobile dialogs have them sticky at the bottom.
- Keep the labels short, for example `Save`, `Delete`, `Enable`.
+23 -27
View File
@@ -27,7 +27,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.2",
"@babel/runtime": "7.29.7",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
@@ -40,15 +40,15 @@
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.6",
"@formatjs/intl-displaynames": "7.3.8",
"@formatjs/intl-durationformat": "0.10.12",
"@formatjs/intl-datetimeformat": "7.4.7",
"@formatjs/intl-displaynames": "7.3.9",
"@formatjs/intl-durationformat": "0.10.13",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.8",
"@formatjs/intl-listformat": "8.3.9",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.9",
"@formatjs/intl-pluralrules": "6.3.8",
"@formatjs/intl-relativetimeformat": "12.3.8",
"@formatjs/intl-numberformat": "9.3.10",
"@formatjs/intl-pluralrules": "6.3.9",
"@formatjs/intl-relativetimeformat": "12.3.9",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -62,17 +62,16 @@
"@lit-labs/virtualizer": "2.1.1",
"@lit/context": "1.1.6",
"@lit/reactive-element": "2.1.2",
"@material/mwc-base": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/web": "2.4.1",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.5",
"@tsparticles/preset-links": "4.0.5",
"@tsparticles/engine": "4.1.0",
"@tsparticles/preset-links": "4.1.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -83,7 +82,7 @@
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.3.0",
"date-fns": "4.4.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
@@ -126,21 +125,20 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/core": "7.29.7",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.61.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.4",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@rsdoctor/rspack-plugin": "1.5.12",
"@rspack/core": "2.0.5",
"@rspack/dev-server": "2.0.3",
"@types/chromecast-caf-receiver": "6.0.26",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
@@ -152,17 +150,15 @@
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.1",
"@types/mocha": "10.0.10",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.7",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.4.0",
"eslint": "10.4.1",
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
@@ -183,7 +179,7 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "4.4.2",
"license-checker-rseidelsohn": "5.0.1",
"lint-staged": "17.0.5",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
@@ -195,10 +191,10 @@
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.15",
"terser-webpack-plugin": "5.6.0",
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.4",
"typescript-eslint": "8.60.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.7",
"webpack-stats-plugin": "1.1.3",

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+67 -19
View File
@@ -11,6 +11,7 @@ import {
} from "../../data/context";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { LocalizeFunc } from "../translations/localize";
import { ensureArray } from "../array/ensure-array";
import { transform } from "./transform";
interface ConsumeEntryConfig {
@@ -26,6 +27,28 @@ const resolveAtPath = (host: unknown, path: readonly string[]) => {
return cur;
};
/** Reuse `previous` when every entry still references the same `HassEntity`. */
export const preserveUnchangedEntityStatesRecord = <
T extends Record<string, HassEntity | undefined>,
>(
previous: T | undefined,
next: T
): T => {
if (!previous) {
return next;
}
const nextKeys = Object.keys(next);
if (Object.keys(previous).length !== nextKeys.length) {
return next;
}
for (const key of nextKeys) {
if (previous[key] !== next[key]) {
return next;
}
}
return previous;
};
const composeDecorator = <T, V>(
context: Parameters<typeof consume>[0]["context"],
watchKey: string | undefined,
@@ -63,27 +86,52 @@ export const consumeEntityState = (config: ConsumeEntryConfig) =>
);
/**
* Like {@link consumeEntityState} but for an array of entity IDs at
* `entityIdPath`. Resolves to a `HassEntity[]` containing one entry per
* currently-available entity (missing entities and non-string IDs are
* filtered out; original order is preserved).
* Like {@link consumeEntityState} but for one or more entity IDs at
* `entityIdPath` (a string or string array; wrapped with {@link ensureArray}).
* Resolves to a record keyed by entity ID containing the currently-available
* entities (missing entities and non-string IDs are filtered out). Returns the
* previous record when none of the selected entities changed.
*/
export const consumeEntityStates = (config: ConsumeEntryConfig) =>
composeDecorator<HassEntities, HassEntity[]>(
statesContext,
config.entityIdPath[0],
function (states) {
const ids = resolveAtPath(this, config.entityIdPath);
if (!Array.isArray(ids) || !states) return undefined;
const result: HassEntity[] = [];
for (const id of ids) {
if (typeof id !== "string") continue;
const state = states[id];
if (state !== undefined) result.push(state);
}
return result;
export const consumeEntityStates = (config: ConsumeEntryConfig) => {
const watchKey = config.entityIdPath[0];
const buildRecord = function (this: unknown, states: HassEntities) {
const ids = ensureArray(resolveAtPath(this, config.entityIdPath));
if (!ids || !states) return undefined;
const result: Record<string, HassEntity> = {};
for (const id of ids) {
if (typeof id !== "string") continue;
const state = states[id];
if (state !== undefined) result[id] = state;
}
);
return result;
};
return (proto: unknown, propertyKey: string) => {
const key = String(propertyKey);
const transformDec = transform<
HassEntities,
Record<string, HassEntity> | undefined
>({
transformer: function (this: unknown, states: HassEntities) {
const next = buildRecord.call(this, states);
if (next === undefined) {
return undefined;
}
const previous = (this as Record<string, unknown>)[
`__transform_${key}`
] as Record<string, HassEntity> | undefined;
return preserveUnchangedEntityStatesRecord(previous, next);
},
watch: watchKey ? [watchKey] : [],
});
const consumeDec = consume<any>({
context: statesContext,
subscribe: true,
});
transformDec(proto as never, propertyKey);
consumeDec(proto as never, propertyKey);
};
};
/**
* Consumes `entitiesContext` and narrows it to the
+13
View File
@@ -17,6 +17,19 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
* on load instead of cleaning up the stale dialog state by going back.
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
};
/**
* Ensures all dialogs are closed before navigation.
* Returns true if navigation can proceed, false if a dialog refused to close.
+37
View File
@@ -0,0 +1,37 @@
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { RelatedResult } from "../../data/search";
export interface RelatedIdSets {
areas: Set<string>;
devices: Set<string>;
entities: Set<string>;
}
/**
* Build a set of related IDs for a given related result.
* @param related - The related result to build the sets from.
* @returns The related ID sets.
*/
export const buildRelatedIdSets = (related?: RelatedResult): RelatedIdSets => ({
areas: new Set(related?.area || []),
devices: new Set(related?.device || []),
entities: new Set(related?.entity || []),
});
/**
* Stable partition sort: related items float to the top,
* preserving relative order (e.g. Fuse score) within each group.
* @param items - The items to sort.
* @returns The sorted items.
*/
export const sortRelatedFirst = (
items: PickerComboBoxItem[]
): PickerComboBoxItem[] =>
[...items].sort((a, b) => {
const aRelated = Boolean(a.isRelated);
const bRelated = Boolean(b.isRelated);
if (aRelated === bRelated) {
return 0;
}
return aRelated ? -1 : 1;
});
+82 -29
View File
@@ -14,6 +14,7 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
@@ -29,22 +30,59 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -66,9 +104,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public options?: ECOption;
@property({ attribute: false }) public options?: HaECOption;
@property({ type: String }) public height?: string;
@@ -614,7 +652,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: ECOption | undefined,
options: HaECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -634,7 +672,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -757,22 +795,34 @@ export class HaChartBase extends LitElement {
xAxis,
};
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
}
return options;
return options as ECOption;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -960,8 +1010,12 @@ export class HaChartBase extends LitElement {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if (s.sampling === "minmax") {
if ((s as LineSeriesOption).sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
? xAxis.min.getTime()
@@ -976,8 +1030,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
return {
...s,
result = {
...result,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -985,11 +1039,10 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
};
} as HaECSeriesItem;
}
}
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
}
@@ -1326,8 +1379,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -0,0 +1,41 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
) => TemplateResult | typeof nothing | null;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
(categories?: NetworkData["categories"]): HaECOption => ({
tooltip: {
trigger: "item",
confine: true,
+10 -6
View File
@@ -11,10 +11,10 @@ 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";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options = {
const options: HaECOption = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.hass=${this.hass}
@@ -103,12 +103,16 @@ export class HaSankeyChart extends LitElement {
: data.value;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${value}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${value}`;
}
return null;
};
+8 -8
View File
@@ -5,10 +5,9 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -25,8 +24,6 @@ export interface SunburstNode {
@customElement("ha-sunburst-chart")
export class HaSunburstChart extends LitElement {
public hass!: HomeAssistant;
@property({ attribute: false }) public data?: SunburstNode;
@property({ attribute: false }) public valueFormatter?: (
@@ -50,13 +47,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options = {
const options: HaECOption = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -71,7 +68,10 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
};
private _createData = memoizeOne(
@@ -0,0 +1,41 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -12,8 +12,9 @@ import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -24,7 +25,6 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
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";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +108,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
private _hiddenStats = new Set<string>();
@@ -141,12 +141,11 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -177,52 +176,44 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
color: dataset.color,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
};
private _datasetHidden(ev: CustomEvent) {
@@ -1,11 +1,10 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -15,8 +14,9 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _yWidth = 0;
@@ -69,7 +69,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as ECOption["series"]}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,42 +132,35 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
public willUpdate(changedProps: PropertyValues) {
if (
+93 -82
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -34,12 +34,13 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
@@ -126,7 +127,7 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _hiddenStats = new Set<string>();
@@ -251,91 +252,101 @@ export class StatisticsChart extends LitElement {
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
startTime = new Date(param.value[0]);
}
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(endTime, this.hass.locale, this.hass.config)}`
: "");
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "");
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
options
)}${unit}`;
this.hass.config
);
}
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
};
private _createOptions() {
+3 -5
View File
@@ -107,17 +107,15 @@ export class HaDevicePicker extends LitElement {
excludeDevices?: string[],
value?: string
) =>
getDevices(
this.hass,
configEntryLookup,
getDevices(this.hass, configEntryLookup, {
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value
)
value,
})
);
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
+23 -1
View File
@@ -309,7 +309,29 @@ export class HaEntityPicker extends LitElement {
}
);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getEntitiesMemoized = memoizeOne(
(
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string
) =>
getEntities(hass, {
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
})
);
private _getItems = () => {
const items = this._getEntitiesMemoized(
+34 -2
View File
@@ -1,11 +1,16 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiChartLine, mdiHelpCircleOutline, mdiShape } from "@mdi/js";
import {
mdiChartLine,
mdiHelpCircleOutline,
mdiPencil,
mdiShape,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../common/entity/compute_state_name";
import { computeRTL } from "../../common/util/compute_rtl";
@@ -53,6 +58,16 @@ const SEARCH_KEYS = [
{ name: "id", weight: 2 },
];
export interface StatisticElementChangedEvent {
statisticId: string;
}
declare global {
interface HASSDomEvents {
"edit-statistics-element": StatisticElementChangedEvent;
}
}
@customElement("ha-statistic-picker")
export class HaStatisticPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -130,6 +145,8 @@ export class HaStatisticPicker extends LitElement {
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
public willUpdate(changedProps: PropertyValues<this>) {
if (
(!this.hasUpdated && !this.statisticIds) ||
@@ -341,6 +358,15 @@ export class HaStatisticPicker extends LitElement {
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${this.canEdit
? html`<ha-icon-button
slot="end"
.value=${statisticId}
.label=${this.hass.localize("ui.common.edit")}
.path=${mdiPencil}
@click=${this._editItem}
></ha-icon-button>`
: nothing}
`;
}
@@ -350,6 +376,12 @@ export class HaStatisticPicker extends LitElement {
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
ev.stopPropagation();
const statisticId = (ev.currentTarget as any).value;
fireEvent(this, "edit-statistics-element", { statisticId });
}
private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId];
+17 -1
View File
@@ -1,9 +1,10 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import "./ha-statistic-picker";
import type { StatisticElementChangedEvent } from "./ha-statistic-picker";
@customElement("ha-statistics-picker")
class HaStatisticsPicker extends LitElement {
@@ -59,6 +60,8 @@ class HaStatisticsPicker extends LitElement {
})
public ignoreRestrictionsOnFirstStatistic = false;
@property({ attribute: "can-edit", type: Boolean }) public canEdit?;
protected render() {
if (!this.hass) {
return nothing;
@@ -99,7 +102,9 @@ class HaStatisticsPicker extends LitElement {
.statisticIds=${this.statisticIds}
.excludeStatistics=${this.value}
.allowCustomEntity=${this.allowCustomEntity}
.canEdit=${this.canEdit}
@value-changed=${this._statisticChanged}
@edit-statistics-element=${this._editItem}
></ha-statistic-picker>
</div>
`
@@ -122,6 +127,17 @@ class HaStatisticsPicker extends LitElement {
`;
}
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
const statisticId = ev.detail.statisticId;
const index = this._currentStatistics!.findIndex((e) => e === statisticId);
fireEvent(this, "edit-detail-element", {
subElementConfig: {
index,
type: "row",
},
});
}
private get _currentStatistics() {
return this.value || [];
}
-3
View File
@@ -43,7 +43,6 @@ class StateInfo extends LitElement {
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
@@ -55,7 +54,6 @@ class StateInfo extends LitElement {
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_updated}
capitalize
></ha-relative-time>
@@ -63,7 +61,6 @@ class StateInfo extends LitElement {
</ha-tooltip>
<ha-relative-time
id="relative-time"
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
+27 -7
View File
@@ -1,18 +1,34 @@
import { consume } from "@lit/context";
import { addDays, differenceInMilliseconds, startOfDay } from "date-fns";
import type { HassConfig } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { transform } from "../common/decorators/transform";
import { absoluteTime } from "../common/datetime/absolute_time";
import type { HomeAssistant } from "../types";
import { configContext, internationalizationContext } from "../data/context";
import type {
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../types";
const SAFE_MARGIN = 5 * 1000;
@customElement("ha-absolute-time")
class HaAbsoluteTime extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public datetime?: string | Date;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config?: HassConfig;
private _timeout?: number;
public disconnectedCallback(): void {
@@ -62,13 +78,17 @@ class HaAbsoluteTime extends ReactiveElement {
}
private _updateAbsolute(): void {
if (!this._i18n || !this._config) {
return;
}
if (!this.datetime) {
this.innerHTML = this.hass.localize("ui.components.absolute_time.never");
this.innerHTML = this._i18n.localize("ui.components.absolute_time.never");
} else {
this.innerHTML = absoluteTime(
new Date(this.datetime),
this.hass.locale,
this.hass.config
this._i18n.locale,
this._config
);
}
}
+24 -1
View File
@@ -12,6 +12,7 @@ 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 { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box-item";
@@ -104,7 +105,29 @@ export class HaAreaPicker extends LitElement {
await this._picker?.open();
}
private _getAreasMemoized = memoizeOne(getAreas);
private _getAreasMemoized = memoizeOne(
(
haAreas: HomeAssistant["areas"],
haFloors: HomeAssistant["floors"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
haStates: HomeAssistant["states"],
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[]
) =>
getAreas(haAreas, haFloors, haDevices, haEntities, haStates, {
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeAreas,
})
);
// Recompute value renderer when the areas change
private _computeValueRenderer = memoizeOne(
+1
View File
@@ -20,6 +20,7 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+22 -7
View File
@@ -11,12 +11,15 @@ import {
mdiStateMachine,
mdiWeatherSunny,
} from "@mdi/js";
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HassConfig, Connection } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@@ -36,12 +39,24 @@ export const CONDITION_ICONS = {
@customElement("ha-condition-icon")
export class HaConditionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
@transform<{ config: HassConfig }, HassConfig>({
transformer: ({ config }) => config,
})
private _config?: HassConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<{ connection: Connection }, Connection>({
transformer: ({ connection }) => connection,
})
private _connection?: Connection;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -51,13 +66,13 @@ export class HaConditionIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._connection || !this._config) {
return this._renderFallback();
}
const icon = conditionIcon(
this.hass.connection,
this.hass.config,
this._connection,
this._config,
this.condition
).then((icn) => {
if (icn) {
+7 -1
View File
@@ -3,6 +3,8 @@ import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import type { Blueprints } from "../data/blueprint";
@@ -20,6 +22,10 @@ import "./ha-list";
export class HaFilterBlueprints extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public value?: string[];
@property() public type?: "automation" | "script";
@@ -54,7 +60,7 @@ export class HaFilterBlueprints extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize("ui.panel.config.blueprint.caption")}
${this._localize("ui.panel.config.blueprint.caption")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
+7 -3
View File
@@ -4,6 +4,8 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
@@ -22,6 +24,10 @@ import "../panels/config/voice-assistants/expose/expose-assistant-icon";
export class HaFilterVoiceAssistants extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
// the list of selected voiceAssistantIds
@property({ attribute: false }) public value: string[] = [];
@@ -44,9 +50,7 @@ export class HaFilterVoiceAssistants extends LitElement {
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
${this.hass.localize(
"ui.panel.config.dashboard.voice_assistants.main"
)}
${this._localize("ui.panel.config.dashboard.voice_assistants.main")}
${this.value?.length
? html`<div class="badge">${this.value?.length}</div>
<ha-icon-button
+1 -1
View File
@@ -77,7 +77,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
| "left-end" = "bottom";
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
-1
View File
@@ -121,7 +121,6 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
<slot name="start"></slot>
+30 -1
View File
@@ -50,7 +50,9 @@ class HaLabel extends LitElement {
<div class="container" .id=${this._elementId}>
<span class="content">
<slot name="icon"></slot>
<slot></slot>
<span class="label-content">
<slot></slot>
</span>
</span>
</div>
`;
@@ -113,6 +115,10 @@ class HaLabel extends LitElement {
display: inline-flex;
}
.label-content {
display: contents;
}
:host([dense]) {
height: 20px;
border-radius: var(--ha-border-radius-md);
@@ -126,6 +132,29 @@ class HaLabel extends LitElement {
margin-inline-start: -4px;
margin-inline-end: 4px;
}
:host(.text-ellipsis) {
max-width: 100%;
min-width: 0;
}
:host(.text-ellipsis) .container {
min-width: 0;
overflow: hidden;
}
:host(.text-ellipsis) span.content {
display: flex;
width: 100%;
min-width: 0;
}
:host(.text-ellipsis) .label-content {
display: block;
flex: 1;
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
-1
View File
@@ -152,7 +152,6 @@ export class HaLanguagePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
+14 -6
View File
@@ -1,19 +1,23 @@
import { consume } from "@lit/context";
import { parseISO } from "date-fns";
import type { PropertyValues } from "lit";
import { ReactiveElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { relativeTime } from "../common/datetime/relative_time";
import { capitalizeFirstLetter } from "../common/string/capitalize-first-letter";
import type { HomeAssistant } from "../types";
import { internationalizationContext } from "../data/context";
import type { HomeAssistantInternationalization } from "../types";
@customElement("ha-relative-time")
class HaRelativeTime extends ReactiveElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public datetime?: string | Date;
@property({ type: Boolean }) public capitalize = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
private _interval?: number;
public disconnectedCallback(): void {
@@ -57,15 +61,19 @@ class HaRelativeTime extends ReactiveElement {
}
private _updateRelative(): void {
if (!this._i18n) {
return;
}
if (!this.datetime) {
this.innerHTML = this.hass.localize("ui.components.relative_time.never");
this.innerHTML = this._i18n.localize("ui.components.relative_time.never");
} else {
const date =
typeof this.datetime === "string"
? parseISO(this.datetime)
: this.datetime;
const relTime = relativeTime(date, this.hass.locale);
const relTime = relativeTime(date, this._i18n.locale);
this.innerHTML = this.capitalize
? capitalizeFirstLetter(relTime)
: relTime;
@@ -1,11 +1,13 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { consumeEntityStates } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { AttributeSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../entity/ha-entity-attribute-picker";
import { ensureArray } from "../../common/array/ensure-array";
@customElement("ha-selector-attribute")
export class HaSelectorAttribute extends LitElement {
@@ -27,6 +29,10 @@ export class HaSelectorAttribute extends LitElement {
filter_entity?: string | string[];
};
@state()
@consumeEntityStates({ entityIdPath: ["context", "filter_entity"] })
private _filterEntityStates?: Record<string, HassEntity>;
protected render() {
return html`
<ha-entity-attribute-picker
@@ -73,7 +79,7 @@ export class HaSelectorAttribute extends LitElement {
const entityIds = ensureArray(this.context.filter_entity);
invalid = !entityIds.some((entityId) => {
const stateObj = this.hass.states[entityId];
const stateObj = this._filterEntityStates?.[entityId];
return (
stateObj &&
this.value in stateObj.attributes &&
@@ -1,15 +1,12 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
import "../ha-switch";
import "../ha-input-helper-text";
@customElement("ha-selector-boolean")
export class HaBooleanSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public value = false;
@property() public placeholder?: any;
@@ -1,14 +1,26 @@
import { consume } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { transform } from "../../common/decorators/transform";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { ButtonToggleSelector, SelectOption } from "../../data/selector";
import type { HomeAssistant, ToggleButton } from "../../types";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HomeAssistantInternationalization,
ToggleButton,
} from "../../types";
import "../ha-button-toggle-group";
@customElement("ha-selector-button_toggle")
export class HaButtonToggleSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public selector!: ButtonToggleSelector;
@@ -48,11 +60,7 @@ export class HaButtonToggleSelector extends LitElement {
if (this.selector.button_toggle?.sort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(
a.label,
b.label,
this.hass.locale.language
)
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
);
}
@@ -2,10 +2,12 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import { isTemplate } from "../../common/string/has-template";
import type { ChooseSelector, Selector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-button-toggle-group";
import "./ha-selector";
@@ -28,6 +30,9 @@ export class HaChooseSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
@state() public _activeChoice?: string;
protected willUpdate(changedProperties: PropertyValues<this>): void {
@@ -62,7 +67,7 @@ export class HaChooseSelector extends LitElement {
.buttons=${this._toggleButtons(
this.selector.choose.choices,
this.selector.choose.translation_key,
this.hass.localize
this._localize
)}
.active=${this._activeChoice}
@value-changed=${this._choiceChanged}
@@ -83,7 +88,7 @@ export class HaChooseSelector extends LitElement {
(
choices: ChooseSelector["choose"]["choices"],
translationKey?: string,
_localize?: HomeAssistant["localize"]
_localize?: LocalizeFunc
) =>
Object.keys(choices).map((choice) => ({
label:
+13 -4
View File
@@ -1,13 +1,22 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { transform } from "../../common/decorators/transform";
import type { DateSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import "../ha-date-input";
import type { HaDateInput } from "../ha-date-input";
@customElement("ha-selector-date")
export class HaDateSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public selector!: DateSelector;
@@ -31,7 +40,7 @@ export class HaDateSelector extends LitElement {
return html`
<ha-date-input
.label=${this.label}
.locale=${this.hass.locale}
.locale=${this._locale}
.disabled=${this.disabled}
.value=${typeof this.value === "string" ? this.value : undefined}
.required=${this.required}
@@ -1,8 +1,12 @@
import { consume } from "@lit/context";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { transform } from "../../common/decorators/transform";
import type { DateTimeSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import "../ha-date-input";
import type { HaDateInput } from "../ha-date-input";
import "../ha-time-input";
@@ -11,7 +15,12 @@ import type { HaTimeInput } from "../ha-time-input";
@customElement("ha-selector-datetime")
export class HaDateTimeSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public selector!: DateTimeSelector;
@@ -41,7 +50,7 @@ export class HaDateTimeSelector extends LitElement {
<div class="input">
<ha-date-input
.label=${this.label}
.locale=${this.hass.locale}
.locale=${this._locale}
.disabled=${this.disabled}
.required=${this.required}
.value=${values?.[0]}
@@ -51,7 +60,7 @@ export class HaDateTimeSelector extends LitElement {
<ha-time-input
enable-second
.value=${values?.[1] || "00:00:00"}
.locale=${this.hass.locale}
.locale=${this._locale}
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${this._valueChanged}
@@ -2,14 +2,11 @@ import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { DurationSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-duration-input";
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
@customElement("ha-selector-duration")
export class HaTimeDuration extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: DurationSelector;
@property({ attribute: false }) public value?:
@@ -3,10 +3,12 @@ import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { removeFile, uploadFile } from "../../data/file_upload";
import type { FileSelector } from "../../data/selector";
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import type { HomeAssistant } from "../../types";
import type { LocalizeFunc } from "../../common/translations/localize";
import "../ha-file-upload";
@customElement("ha-selector-file")
@@ -25,6 +27,9 @@ export class HaFileSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
@state() private _filename?: { fileId: string; name: string };
@state() private _busy = false;
@@ -42,7 +47,7 @@ export class HaFileSelector extends LitElement {
.uploading=${this._busy}
.value=${this.value
? this._filename?.name ||
this.hass.localize("ui.components.selectors.file.unknown_file")
this._localize!("ui.components.selectors.file.unknown_file")
: undefined}
@file-picked=${this._uploadFile}
@change=${this._removeFile}
@@ -72,7 +77,7 @@ export class HaFileSelector extends LitElement {
fireEvent(this, "value-changed", { value: fileId });
} catch (err: any) {
showAlertDialog(this, {
text: this.hass.localize("ui.components.selectors.file.upload_failed", {
text: this._localize!("ui.components.selectors.file.upload_failed", {
reason: err.message || err,
}),
});
@@ -1,6 +1,7 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { PeriodKey, PeriodSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
@@ -41,6 +42,9 @@ export class HaPeriodSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
private _schema = memoizeOne(
(
selectedPeriodKey: PeriodKey | undefined,
@@ -78,7 +82,7 @@ export class HaPeriodSelector extends LitElement {
const schema = this._schema(
typeof data.period === "string" ? (data.period as PeriodKey) : undefined,
this.selector,
this.hass.localize
this._localize!
);
return html`
@@ -1,13 +1,20 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consume } from "@lit/context";
import { ensureArray } from "../../common/array/ensure-array";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { internationalizationContext } from "../../data/context";
import type { SelectOption, SelectSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-checkbox";
@@ -25,6 +32,13 @@ import "../radio/ha-radio-option";
export class HaSelectSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public selector!: SelectSelector;
@property() public value?: string | string[];
@@ -75,11 +89,7 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.sort) {
options.sort((a, b) =>
caseInsensitiveStringCompare(
a.label,
b.label,
this.hass.locale.language
)
caseInsensitiveStringCompare(a.label, b.label, this._locale.language)
);
}
@@ -2,6 +2,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type {
LocalizeFunc,
@@ -168,6 +169,9 @@ export class HaSelectorSelector extends LitElement {
@property({ type: Boolean, reflect: true }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
private _yamlMode = false;
protected shouldUpdate(changedProps: PropertyValues<this>) {
@@ -236,7 +240,7 @@ export class HaSelectorSelector extends LitElement {
};
}
const schema = this._schema(type, this.hass.localize);
const schema = this._schema(type, this._localize!);
return html`<div>
<p>${this.label ? this.label : ""}</p>
@@ -290,7 +294,7 @@ export class HaSelectorSelector extends LitElement {
}
private _computeLabelCallback = (schema: any): string =>
this.hass.localize(
this._localize!(
`ui.components.selectors.selector.${schema.name}` as LocalizeKeys
) || schema.name;
+13 -4
View File
@@ -1,13 +1,22 @@
import { consume } from "@lit/context";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { transform } from "../../common/decorators/transform";
import type { TimeSelector } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import { internationalizationContext } from "../../data/context";
import type { FrontendLocaleData } from "../../data/translation";
import type { HomeAssistantInternationalization } from "../../types";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
@customElement("ha-selector-time")
export class HaTimeSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@property({ attribute: false }) public selector!: TimeSelector;
@@ -31,7 +40,7 @@ export class HaTimeSelector extends LitElement {
return html`
<ha-time-input
.value=${typeof this.value === "string" ? this.value : undefined}
.locale=${this.hass.locale}
.locale=${this._locale}
.disabled=${this.disabled}
.required=${this.required}
clearable
+27 -14
View File
@@ -1,24 +1,39 @@
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import {
DEFAULT_SERVICE_ICON,
FALLBACK_DOMAIN_ICONS,
serviceIcon,
} from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-service-icon")
export class HaServiceIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public service?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
@transform<{ config: HassConfig }, HassConfig>({
transformer: ({ config }) => config,
})
private _config?: HassConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<{ connection: Connection }, Connection>({
transformer: ({ connection }) => connection,
})
private _connection?: Connection;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -28,20 +43,18 @@ export class HaServiceIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._connection || !this._config) {
return this._renderFallback();
}
const icon = serviceIcon(
this.hass.connection,
this.hass.config,
this.service
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
const icon = serviceIcon(this._connection, this._config, this.service).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
return this._renderFallback();
});
);
return html`${until(icon)}`;
}
+23 -8
View File
@@ -1,21 +1,36 @@
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { HomeAssistant } from "../types";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { serviceSectionIcon } from "../data/icons";
import "./ha-icon";
import "./ha-svg-icon";
import { serviceSectionIcon } from "../data/icons";
@customElement("ha-service-section-icon")
export class HaServiceSectionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public service?: string;
@property() public section?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
@transform<{ config: HassConfig }, HassConfig>({
transformer: ({ config }) => config,
})
private _config?: HassConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<{ connection: Connection }, Connection>({
transformer: ({ connection }) => connection,
})
private _connection?: Connection;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -25,13 +40,13 @@ export class HaServiceSectionIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._connection || !this._config) {
return this._renderFallback();
}
const icon = serviceSectionIcon(
this.hass.connection,
this.hass.config,
this._connection,
this._config,
this.service,
this.section
).then((icn) => {
+1 -5
View File
@@ -539,11 +539,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
rtl: isRTL,
})}
>
<ha-user-badge
slot="start"
.user=${this.hass.user}
.hass=${this.hass}
></ha-user-badge>
<ha-user-badge slot="start" .user=${this.hass.user}></ha-user-badge>
<span class="item-text" slot="headline"
>${this.hass.user ? this.hass.user.name : ""}</span
>
+47 -3
View File
@@ -130,11 +130,56 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
private _newTarget?: TargetItem;
private _getDevicesMemoized = memoizeOne(getDevices);
private _getDevicesMemoized = memoizeOne(
(
hass: HomeAssistant,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string,
idPrefix?: string
) =>
getDevices(hass, configEntryLookup, {
includeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value,
idPrefix,
})
);
private _getLabelsMemoized = memoizeOne(getLabels);
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getEntitiesMemoized = memoizeOne(
(
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string,
idPrefix?: string
) =>
getEntities(hass, {
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
idPrefix,
})
);
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
@@ -919,7 +964,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this.hass,
configEntryLookup,
includeDomains,
undefined,
includeDeviceClasses,
deviceFilter,
entityFilter,
-1
View File
@@ -82,7 +82,6 @@ export class HaThemePicker extends LitElement {
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${this._changed}
popover-placement="bottom"
></ha-generic-picker>
`;
}
+1
View File
@@ -22,6 +22,7 @@ export const haTopAppBarFixedStyles = css`
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
box-shadow var(--ha-animation-duration-normal) ease,
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
+27 -14
View File
@@ -17,13 +17,16 @@ import {
mdiWeatherSunny,
mdiWebhook,
} from "@mdi/js";
import { consume } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { until } from "lit/directives/until";
import type { Connection, HassConfig } from "home-assistant-js-websocket";
import { computeDomain } from "../common/entity/compute_domain";
import { transform } from "../common/decorators/transform";
import { configContext, connectionContext } from "../data/context";
import { FALLBACK_DOMAIN_ICONS, triggerIcon } from "../data/icons";
import { mdiHomeAssistant } from "../resources/home-assistant-logo-svg";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@@ -50,12 +53,24 @@ export const TRIGGER_ICONS = {
@customElement("ha-trigger-icon")
export class HaTriggerIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public trigger?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
@transform<{ config: HassConfig }, HassConfig>({
transformer: ({ config }) => config,
})
private _config?: HassConfig;
@state()
@consume({ context: connectionContext, subscribe: true })
@transform<{ connection: Connection }, Connection>({
transformer: ({ connection }) => connection,
})
private _connection?: Connection;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -65,20 +80,18 @@ export class HaTriggerIcon extends LitElement {
return nothing;
}
if (!this.hass) {
if (!this._connection || !this._config) {
return this._renderFallback();
}
const icon = triggerIcon(
this.hass.connection,
this.hass.config,
this.trigger
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
const icon = triggerIcon(this._connection, this._config, this.trigger).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
return this._renderFallback();
});
);
return html`${until(icon)}`;
}
+2 -2
View File
@@ -99,8 +99,8 @@ export class HaRadioOption extends Radio {
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
color: var(--ha-color-fill-primary-loud-resting);
border-color: var(--ha-color-fill-primary-loud-resting);
color: var(--checked-icon-color);
border-color: var(--checked-icon-color);
}
[part~="label"] {
+7 -5
View File
@@ -1,6 +1,8 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { LogbookEntry } from "../../data/logbook";
import type { HomeAssistant } from "../../types";
import "./hat-logbook-note";
@@ -17,6 +19,9 @@ export class HaTraceLogbook extends LitElement {
@property({ attribute: false }) public logbookEntries!: LogbookEntry[];
@consumeLocalize()
private _localize!: LocalizeFunc;
protected render(): TemplateResult {
return this.logbookEntries.length
? html`
@@ -26,13 +31,10 @@ export class HaTraceLogbook extends LitElement {
.entries=${this.logbookEntries}
.narrow=${this.narrow}
></ha-logbook-renderer>
<hat-logbook-note
.hass=${this.hass}
.domain=${this.trace.domain}
></hat-logbook-note>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
: html`<div class="padded-box">
${this.hass.localize(
${this._localize(
"ui.panel.config.automation.trace.path.no_logbook_entries"
)}
</div>`;
@@ -374,10 +374,7 @@ export class HaTracePathDetails extends LitElement {
.entries=${entries}
.narrow=${this.narrow}
></ha-logbook-renderer>
<hat-logbook-note
.hass=${this.hass}
.domain=${this.trace.domain}
></hat-logbook-note>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`
: html`<div class="padded-box">
${this.hass!.localize(
+1 -4
View File
@@ -28,10 +28,7 @@ export class HaTraceTimeline extends LitElement {
allow-pick
>
</hat-trace-timeline>
<hat-logbook-note
.hass=${this.hass}
.domain=${this.trace.domain}
></hat-logbook-note>
<hat-logbook-note .domain=${this.trace.domain}></hat-logbook-note>
`;
}
+7 -5
View File
@@ -1,20 +1,22 @@
import { css, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../../types";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
@customElement("hat-logbook-note")
class HatLogbookNote extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public domain: "automation" | "script" = "automation";
@consumeLocalize()
private _localize!: LocalizeFunc;
render() {
if (this.domain === "script") {
return this.hass.localize(
return this._localize(
"ui.panel.config.automation.trace.messages.not_all_entries_are_related_script_note"
);
}
return this.hass.localize(
return this._localize(
"ui.panel.config.automation.trace.messages.not_all_entries_are_related_automation_note"
);
}
+9 -6
View File
@@ -1,17 +1,20 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import type { BasePerson } from "../../data/person";
import { computeUserInitials } from "../../data/user";
import type { HomeAssistant } from "../../types";
import { connectionContext } from "../../data/context";
@customElement("ha-person-badge")
class PersonBadge extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public person?: BasePerson;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
protected render() {
if (!this.person) {
return nothing;
@@ -19,10 +22,10 @@ class PersonBadge extends LitElement {
const picture = this.person.picture;
if (picture) {
if (picture && this._connection) {
return html`<div
style=${styleMap({
backgroundImage: `url(${this.hass.hassUrl(picture)})`,
backgroundImage: `url(${this._connection.hassUrl(picture)})`,
})}
class="picture"
></div>`;
+36 -33
View File
@@ -1,57 +1,62 @@
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import type { User } from "../../data/user";
import { computeUserInitials } from "../../data/user";
import type { CurrentUser, HomeAssistant } from "../../types";
import { connectionContext, statesContext } from "../../data/context";
import type { CurrentUser } from "../../types";
@customElement("ha-user-badge")
class UserBadge extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public user?: User | CurrentUser;
@state() private _personPicture?: string;
@state()
@consume({ context: connectionContext, subscribe: true })
private _connection?: ContextType<typeof connectionContext>;
private _personEntityId?: string;
@state()
@consume({ context: statesContext, subscribe: true })
private _states?: HassEntities;
public willUpdate(changedProps: PropertyValues<this>) {
@state() private _personEntityId?: string;
@state()
@consumeEntityState({ entityIdPath: ["_personEntityId"] })
private _personState?: HassEntity;
public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
if (changedProps.has("user")) {
this._getPersonPicture();
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
// Re-scan for the user's person entity when the user changes, or when the
// states change while we don't have a (still-present) person entity. Once
// resolved, `_personState` keeps the picture up to date via
// `consumeEntityState`, so there's no need to rescan on every state update.
if (
this._personEntityId &&
oldHass &&
this.hass.states[this._personEntityId] !==
oldHass.states[this._personEntityId]
changedProps.has("user") ||
(changedProps.has("_states") &&
(!this._personEntityId || !this._states?.[this._personEntityId]))
) {
const entityState = this.hass.states[this._personEntityId];
if (entityState) {
this._personPicture = entityState.attributes.entity_picture;
} else {
this._getPersonPicture();
}
} else if (!this._personEntityId && oldHass) {
this._getPersonPicture();
this._updatePersonEntityId();
}
}
protected render() {
if (!this.hass || !this.user) {
if (!this.user) {
return nothing;
}
const picture = this._personPicture;
const picture =
this._personEntityId &&
(this._personState?.attributes.entity_picture as string | undefined);
if (picture) {
if (picture && this._connection) {
return html`<div
style=${styleMap({
backgroundImage: `url(${this.hass.hassUrl(picture)})`,
backgroundImage: `url(${this._connection.hassUrl(picture)})`,
})}
class="picture"
></div>`;
@@ -64,20 +69,18 @@ class UserBadge extends LitElement {
</div>`;
}
private _getPersonPicture() {
private _updatePersonEntityId() {
this._personEntityId = undefined;
this._personPicture = undefined;
if (!this.hass || !this.user) {
if (!this.user || !this._states) {
return;
}
for (const entity of Object.values(this.hass.states)) {
for (const entity of Object.values(this._states)) {
if (
entity.attributes.user_id === this.user.id &&
computeStateDomain(entity) === "person"
) {
this._personEntityId = entity.entity_id;
this._personPicture = entity.attributes.entity_picture;
break;
return;
}
}
}
+2 -10
View File
@@ -64,11 +64,7 @@ class HaUserPicker extends LitElement {
}
return html`
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${user}
></ha-user-badge>
<ha-user-badge slot="start" .user=${user}></ha-user-badge>
<span slot="headline">${user.name}</span>
`;
};
@@ -94,11 +90,7 @@ class HaUserPicker extends LitElement {
return html`
<ha-combo-box-item type="button" compact>
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${item.user}
></ha-user-badge>
<ha-user-badge slot="start" .user=${item.user}></ha-user-badge>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
+20 -7
View File
@@ -15,20 +15,33 @@ import {
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
import type { EntityRegistryDisplayEntry } from "../entity/entity_registry";
export interface GetAreasOptions {
includeDomains?: string[];
excludeDomains?: string[];
includeDeviceClasses?: string[];
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
excludeAreas?: string[];
idPrefix?: string;
}
export const getAreas = (
haAreas: HomeAssistant["areas"],
haFloors: HomeAssistant["floors"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
haStates: HomeAssistant["states"],
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeAreas?: string[],
idPrefix = ""
options?: GetAreasOptions
): PickerComboBoxItem[] => {
const {
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeAreas,
idPrefix = "",
} = options ?? {};
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
+1
View File
@@ -256,6 +256,7 @@ export const normalizeSubscriptionEventData = (
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
location: eventData.location ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
+21
View File
@@ -0,0 +1,21 @@
import { createContext } from "@lit/context";
export interface DirtyStateContext<State = unknown> {
/** Whether current state differs from the initial snapshot */
isDirty: boolean;
/** Current tracked state */
state: State;
/** Update the tracked state — triggers dirty comparison */
setState: (state: State) => void;
/** Reset initial snapshot to current state (marks clean) */
markClean: () => void;
}
/**
* Singleton context key for dirty-state tracking.
*
* Because Lit context keys are singletons, the value type is
* `DirtyStateContext<unknown>`. The provider mixin and consumer controller
* supply type-safe APIs on top of this boundary.
*/
export const dirtyStateContext = createContext<DirtyStateContext>("dirtyState");
+35
View File
@@ -1,5 +1,6 @@
import { createContext } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import type {
HomeAssistant,
HomeAssistantApi,
@@ -10,10 +11,12 @@ import type {
HomeAssistantRegistries,
HomeAssistantUI,
} from "../../types";
import type { RelatedIdSets } from "../../common/search/related-context";
import type { ConfigEntry } from "../config_entries";
import type { EntityRegistryEntry } from "../entity/entity_registry";
import type { DomainManifestLookup } from "../integration";
import type { LabelRegistryEntry } from "../label/label_registry";
import type { ItemType } from "../search";
/**
* Entity, device, area, and floor registries
@@ -94,6 +97,11 @@ export const areasContext = createContext<HomeAssistant["areas"]>("areas");
*/
export const floorsContext = createContext<HomeAssistant["floors"]>("floors");
/**
* Whether the main Home Assistant viewport is using the narrow layout.
*/
export const narrowViewportContext = createContext<boolean>("narrowViewport");
// #region lazy-contexts
/**
@@ -162,3 +170,30 @@ export const panelsContext = createContext<HomeAssistant["panels"]>("panels");
export const authContext = createContext<HomeAssistant["auth"]>("auth");
// #endregion deprecated-contexts
// #region related-context
export interface RelatedContextItem {
itemType: ItemType;
itemId: string;
}
/**
* Resolved related entities/devices/areas for the current page context.
* Set by `RelatedContextProvider` when a page fires `hass-related-context`.
* Cleared on navigation.
*/
export const relatedContext = createContext<RelatedIdSets | undefined>(
"related"
);
declare global {
interface HASSDomEvents {
"hass-related-context": RelatedContextItem | undefined;
}
interface HTMLElementEventMap {
"hass-related-context": HASSDomEvent<RelatedContextItem | undefined>;
}
}
// #endregion related-context
+23 -8
View File
@@ -32,6 +32,17 @@ export interface DeviceAreaLabel {
viaDeviceAreaName?: string;
}
export interface GetDevicesOptions {
includeDomains?: string[];
excludeDomains?: string[];
includeDeviceClasses?: string[];
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
excludeDevices?: string[];
value?: string;
idPrefix?: string;
}
export const computeDeviceAreaLabel = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"],
@@ -96,15 +107,19 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
export const getDevices = (
hass: HomeAssistant,
configEntryLookup: Record<string, ConfigEntry>,
includeDomains?: string[],
excludeDomains?: string[],
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
value?: string,
idPrefix = ""
options?: GetDevicesOptions
): DevicePickerItem[] => {
const {
includeDomains,
excludeDomains,
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDevices,
value,
idPrefix = "",
} = options ?? {};
const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities);
+16 -1
View File
@@ -222,6 +222,12 @@ export interface EnergyPreferences {
device_consumption_water: DeviceConsumptionEnergyPreference[];
}
export const EMPTY_PREFERENCES: EnergyPreferences = {
energy_sources: [],
device_consumption: [],
device_consumption_water: [],
};
export interface EnergyInfo {
cost_sensors: Record<string, string>;
solar_forecast_domains: string[];
@@ -802,7 +808,16 @@ export const getEnergyDataCollection = (
if (!collection.prefs) {
// This will raise if not found.
// Detect by checking `e.code === "not_found"
collection.prefs = await getEnergyPreferences(hass);
try {
collection.prefs = await getEnergyPreferences(hass);
} catch (err: any) {
if (err.code === "not_found") {
return {
prefs: EMPTY_PREFERENCES,
} as EnergyData;
}
throw err;
}
}
scheduleHourlyRefresh(collection);
+25 -9
View File
@@ -41,18 +41,34 @@ export const entityComboBoxKeys: FuseWeightedKey[] = [
},
];
export interface GetEntitiesOptions {
includeDomains?: string[];
excludeDomains?: string[];
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDeviceClasses?: string[];
includeUnitOfMeasurement?: string[];
includeEntities?: string[];
excludeEntities?: string[];
value?: string;
idPrefix?: string;
}
export const getEntities = (
hass: HomeAssistant,
includeDomains?: string[],
excludeDomains?: string[],
entityFilter?: HaEntityPickerEntityFilterFunc,
includeDeviceClasses?: string[],
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
value?: string,
idPrefix = ""
options?: GetEntitiesOptions
): EntityComboBoxItem[] => {
const {
includeDomains,
excludeDomains,
entityFilter,
includeDeviceClasses,
includeUnitOfMeasurement,
includeEntities,
excludeEntities,
value,
idPrefix = "",
} = options ?? {};
let items: EntityComboBoxItem[];
let entityIds = Object.keys(hass.states);
+2 -2
View File
@@ -36,11 +36,11 @@ export type ItemType =
| "script_blueprint";
export const findRelated = (
hass: HomeAssistant,
hass: Pick<HomeAssistant, "callWS">,
itemType: ItemType,
itemId: string
): Promise<RelatedResult> =>
hass.callWS({
hass.callWS<RelatedResult>({
type: "search/related",
item_type: itemType,
item_id: itemId,
+18
View File
@@ -87,6 +87,19 @@ const HOME_ASSISTANT_CORE_TITLE = "Home Assistant Core";
const HOME_ASSISTANT_SUPERVISOR_TITLE = "Home Assistant Supervisor";
const HOME_ASSISTANT_OS_TITLE = "Home Assistant Operating System";
// The hassio integration sets these as hard-coded `_attr_title` on the Core,
// Operating System, and Supervisor update entities. They are not translated,
// so a title comparison is the reliable way to identify them without depending
// on the (lazily-fetched) entity sources.
export const isSystemUpdate = (entity: UpdateEntity): boolean => {
const title = entity.attributes.title || "";
return (
title === HOME_ASSISTANT_CORE_TITLE ||
title === HOME_ASSISTANT_OS_TITLE ||
title === HOME_ASSISTANT_SUPERVISOR_TITLE
);
};
export const filterUpdateEntities = (
entities: HassEntities,
language?: string
@@ -133,6 +146,11 @@ export const filterUpdateEntitiesParameterized = (
return updateCanInstall(entity, showSkipped);
});
export const installUpdates = (hass: HomeAssistant, entityIds: string[]) =>
hass.callService("update", "install", {
entity_id: entityIds,
});
export const checkForEntityUpdates = async (
element: HTMLElement,
hass: HomeAssistant
@@ -1,26 +1,37 @@
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { LocalizeKeys } from "../../common/translations/localize";
import { createSearchParam } from "../../common/url/search-params";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { SCENE_IGNORED_DOMAINS, type SceneEntities } from "../../data/scene";
import type { SingleHassServiceTarget } from "../../data/target";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
} from "../../panels/config/automation/show-add-automation-element-dialog";
import type { HomeAssistant, TranslationDict } from "../../types";
/** Add to action keys are the keys of the translation dictionary for the add to actions. */
export type AddToActionKey =
TranslationDict["ui"]["dialogs"]["more_info_control"]["add_to"]["actions"] extends infer Actions
? keyof Actions
: never;
/** Add to action keys are the keys of the translation dictionary for the add to action options. */
type AddToActionOptions =
TranslationDict["ui"]["dialogs"]["more_info_control"]["add_to"]["action_options"];
export type AddToActionKey = Extract<keyof AddToActionOptions, string>;
export type AddToAutomationScriptActionKey = Exclude<AddToActionKey, "scene">;
/** Fully-qualified localize key for an add to action option label. */
type AddToActionOptionLabelKey = LocalizeKeys &
`ui.dialogs.more_info_control.add_to.action_options.${AddToActionKey}`;
interface BaseEntityAddToAction {
/** Whether the action is enabled and can be selected. */
enabled: boolean;
/** Translated name of the action */
name: string;
/** Translated label of the action option */
name?: string;
/** Fully-qualified localize key for the action option label */
nameKey?: AddToActionOptionLabelKey;
/** Optional translated description of the action */
description?: string;
/** MDI icon name (e.g., "mdi:car") */
@@ -31,7 +42,7 @@ export interface DefaultEntityAddToAction extends BaseEntityAddToAction {
/** Type of action handled in the frontend */
type: "default";
/** Stable key used to resolve the action handler */
key: AddToActionKey;
key: AddToAutomationScriptActionKey;
}
export interface ExternalEntityAddToAction extends BaseEntityAddToAction {
@@ -48,11 +59,11 @@ export type EntityAddToAction =
export type EntityAddToActions = EntityAddToAction[];
interface ActionDefinition {
translation_key: AddToActionKey;
translation_key: AddToAutomationScriptActionKey;
icon: string;
}
export const DEFAULT_ACTION_DEFS: ActionDefinition[] = [
const DEFAULT_ACTION_DEFS: ActionDefinition[] = [
{
translation_key: "automation_trigger",
icon: "mdi:robot-outline",
@@ -71,33 +82,49 @@ export const DEFAULT_ACTION_DEFS: ActionDefinition[] = [
},
];
export const getDefaultAddToActions = (
states: HomeAssistant["states"],
localize: LocalizeFunc,
formatEntityName: HomeAssistant["formatEntityName"],
entityId: string
): EntityAddToActions =>
export const getDefaultAddToActions = (): EntityAddToActions =>
DEFAULT_ACTION_DEFS.map(
(def: ActionDefinition): EntityAddToAction => ({
type: "default",
key: def.translation_key,
enabled: true,
name: localize(
`ui.dialogs.more_info_control.add_to.actions.${def.translation_key}`,
{
target:
states[entityId] !== undefined
? formatEntityName(states[entityId], undefined)
: entityId,
}
),
nameKey: `ui.dialogs.more_info_control.add_to.action_options.${def.translation_key}`,
icon: def.icon,
})
);
export const createAddToSceneEntities = (
entityIds: string[]
): SceneEntities => {
const entities: SceneEntities = {};
for (const entityId of entityIds) {
entities[entityId] = "";
}
return entities;
};
export const filterAddToSceneEntityIds = (
entityIds: string[],
entityRegistry: readonly EntityRegistryEntry[],
states: HomeAssistant["states"]
): string[] => {
const entityIdSet = new Set(entityIds);
return entityRegistry
.filter((entry) => entityIdSet.has(entry.entity_id))
.filter(
(entry) =>
!entry.entity_category &&
!entry.hidden_by &&
!SCENE_IGNORED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
states[entry.entity_id]
)
.map((entry) => entry.entity_id);
};
/** Handler for adding a target to an automation/script. */
export function addToActionHandler(
key: AddToActionKey,
key: AddToAutomationScriptActionKey,
target: SingleHassServiceTarget
): Promise<boolean> {
const searchParams: Record<string, string> = {};
+211
View File
@@ -0,0 +1,211 @@
import { mdiPlus } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import "../../components/ha-icon";
import "../../components/ha-svg-icon";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
export interface AddToActionListItem {
name?: string;
nameKey?: LocalizeKeys;
description?: string;
descriptionKey?: LocalizeKeys;
icon?: string;
iconPath?: string;
enabled?: boolean;
}
export interface AddToActionListSection<
Item extends AddToActionListItem = AddToActionListItem,
> {
title?: string;
titleKey?: LocalizeKeys;
actions: readonly Item[];
empty?: string;
emptyKey?: LocalizeKeys;
}
export interface AddToActionListActionSelectedDetail<
Item extends AddToActionListItem = AddToActionListItem,
> {
action: Item;
}
export type AddToActionListActionSelectedEvent<
Item extends AddToActionListItem = AddToActionListItem,
> = HASSDomEvent<AddToActionListActionSelectedDetail<Item>>;
@customElement("ha-add-to-action-list")
class HaAddToActionList extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false })
public sections: readonly AddToActionListSection[] = [];
protected render(): TemplateResult | typeof nothing {
if (!this.sections.length) {
return nothing;
}
return html`${this.sections.map((section, sectionIndex) =>
this._renderSection(section, sectionIndex)
)}`;
}
private _renderSection(
section: AddToActionListSection,
sectionIndex: number
): TemplateResult | typeof nothing {
if (!section.actions.length && !section.empty && !section.emptyKey) {
return nothing;
}
return html`
<h3 class="section-header">
${this._localizeValue(section.title, section.titleKey)}
</h3>
${section.actions.length
? html`<ha-list-base>
${section.actions.map((action, actionIndex) =>
this._renderActionItem(action, sectionIndex, actionIndex)
)}
</ha-list-base>`
: html`<h4 class="empty">
${this._localizeValue(section.empty, section.emptyKey)}
</h4>`}
`;
}
private _renderActionItem(
action: AddToActionListItem,
sectionIndex: number,
actionIndex: number
): TemplateResult {
return html`
<ha-list-item-button
.disabled=${action.enabled === false}
data-section-index=${sectionIndex}
data-action-index=${actionIndex}
.headline=${this._localizeValue(action.name, action.nameKey)}
.supportingText=${this._localizeValue(
action.description,
action.descriptionKey
)}
@click=${this._actionSelected}
>
${action.icon
? html`<ha-icon
class="start-icon"
slot="start"
.icon=${action.icon}
></ha-icon>`
: action.iconPath
? html`<ha-svg-icon
class="start-icon"
slot="start"
.path=${action.iconPath}
></ha-svg-icon>`
: nothing}
<ha-svg-icon class="plus" slot="end" .path=${mdiPlus}></ha-svg-icon>
</ha-list-item-button>
`;
}
private _localizeValue(
value?: string,
localizeKey?: LocalizeKeys
): string | undefined {
return value || (localizeKey ? this._localize(localizeKey) : undefined);
}
private _actionSelected(
ev: HASSDomCurrentTargetEvent<HaListItemButton>
): void {
const action =
this.sections[Number(ev.currentTarget.dataset.sectionIndex)]?.actions[
Number(ev.currentTarget.dataset.actionIndex)
];
if (!action) {
return;
}
if (action.enabled === false) {
return;
}
fireEvent(this, "add-to-list-action-selected", {
action,
});
}
static styles: CSSResultGroup = css`
:host {
display: block;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-1);
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.empty {
padding: var(--ha-space-2) var(--ha-space-6) var(--ha-space-1);
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
color: var(--secondary-text-color);
}
ha-list-item-button {
--ha-row-item-padding-inline: var(--ha-space-5);
}
ha-icon,
ha-svg-icon {
display: flex;
align-items: center;
}
.start-icon {
color: var(--ha-color-text-secondary);
}
.plus {
color: var(--primary-color);
}
ha-list-item-button[disabled] .start-icon,
ha-list-item-button[disabled] .plus {
color: var(--disabled-text-color);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-add-to-action-list": HaAddToActionList;
}
interface HASSDomEvents {
"add-to-list-action-selected": AddToActionListActionSelectedDetail;
}
}
@@ -54,14 +54,12 @@ export class HaMoreInfoStateHeader extends LitElement {
${this._absoluteTime
? html`
<ha-absolute-time
.hass=${this.hass}
.datetime=${this.changedOverride ??
this.stateObj.last_changed}
></ha-absolute-time>
`
: html`
<ha-relative-time
.hass=${this.hass}
.datetime=${this.changedOverride ??
this.stateObj.last_changed}
capitalize
@@ -23,7 +23,6 @@ class MoreInfoAutomation extends LitElement {
<div class="flex">
<div>${this.hass.localize("ui.card.automation.last_triggered")}:</div>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.attributes.last_triggered}
capitalize
></ha-relative-time>
@@ -36,7 +36,6 @@ class MoreInfoSun extends LitElement {
)}</span
>
<ha-relative-time
.hass=${this.hass}
.datetime=${item === "ris" ? risingDate : settingDate}
></ha-relative-time>
</div>
@@ -201,7 +201,6 @@ class MoreInfoWeather extends LitElement {
<div class="time-ago">
<ha-relative-time
id="relative-time"
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
@@ -213,7 +212,6 @@ class MoreInfoWeather extends LitElement {
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_changed}
capitalize
></ha-relative-time>
@@ -225,7 +223,6 @@ class MoreInfoWeather extends LitElement {
)}:
</span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.stateObj.last_updated}
capitalize
></ha-relative-time>
+55 -75
View File
@@ -1,26 +1,35 @@
import { LitElement, css, html, nothing } from "lit";
import { consume, type ContextType } from "@lit/context";
import { LitElement, css, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert";
import "../../components/ha-icon";
import "../../components/ha-spinner";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-base";
import type { HaListItemButton } from "../../components/item/ha-list-item-button";
import { showToast } from "../../util/toast";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
import { configContext } from "../../data/context";
import "../add-to/ha-add-to-action-list";
import type {
AddToActionListActionSelectedEvent,
AddToActionListSection,
} from "../add-to/ha-add-to-action-list";
import {
type EntityAddToAction,
type EntityAddToActions,
addToActionHandler,
getDefaultAddToActions,
} from "./add-to";
} from "../add-to/add-to";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
@customElement("ha-more-info-add-to")
export class HaMoreInfoAddTo extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public entityId!: string;
@@ -31,18 +40,13 @@ export class HaMoreInfoAddTo extends LitElement {
@state() private _loading = true;
private async _loadActions() {
this._defaultActions = getDefaultAddToActions(
this.hass.states,
this.hass.localize,
this.hass.formatEntityName,
this.entityId
);
this._defaultActions = getDefaultAddToActions();
this._externalActions = [];
if (this.hass.auth.external?.config.hasEntityAddTo) {
if (this._config?.auth.external?.config.hasEntityAddTo) {
try {
const response =
await this.hass.auth.external?.sendMessage<"entity/add_to/get_actions">(
await this._config.auth.external.sendMessage<"entity/add_to/get_actions">(
{
type: "entity/add_to/get_actions",
payload: { entity_id: this.entityId },
@@ -66,13 +70,9 @@ export class HaMoreInfoAddTo extends LitElement {
}
private async _actionSelected(
ev: HASSDomCurrentTargetEvent<
HaListItemButton & {
action: EntityAddToAction;
}
>
ev: AddToActionListActionSelectedEvent<EntityAddToAction>
) {
const action = ev.currentTarget.action;
const { action } = ev.detail;
if (!action.enabled) {
return;
}
@@ -82,7 +82,10 @@ export class HaMoreInfoAddTo extends LitElement {
if (!action.payload) {
throw new Error("Missing external action payload");
}
this.hass.auth.external!.fireMessage({
if (!this._config?.auth.external) {
throw new Error("Missing external app connection");
}
this._config.auth.external.fireMessage({
type: "entity/add_to",
payload: {
entity_id: this.entityId,
@@ -92,7 +95,7 @@ export class HaMoreInfoAddTo extends LitElement {
fireEvent(this, "add-to-action-selected");
} catch (err: unknown) {
showToast(this, {
message: this.hass.localize(
message: this._localize(
"ui.dialogs.more_info_control.add_to.action_failed",
{
error: err instanceof Error ? err.message : String(err),
@@ -110,24 +113,6 @@ export class HaMoreInfoAddTo extends LitElement {
addToActionHandler(action.key, { entity_id: this.entityId });
}
private _renderActionItems(actions: EntityAddToActions) {
return actions.map(
(action) => html`
<ha-list-item-button
.disabled=${!action.enabled}
.action=${action}
@click=${this._actionSelected}
>
<ha-icon slot="start" .icon=${action.icon}></ha-icon>
<span slot="headline">${action.name}</span>
${action.description
? html`<span slot="supporting-text">${action.description}</span>`
: nothing}
</ha-list-item-button>
`
);
}
protected async firstUpdated() {
await this._loadActions();
this._loading = false;
@@ -145,29 +130,38 @@ export class HaMoreInfoAddTo extends LitElement {
if (!this._defaultActions.length && !this._externalActions.length) {
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.no_actions"
)}
${this._localize("ui.dialogs.more_info_control.add_to.no_actions")}
</ha-alert>
`;
}
const automationActions = this._defaultActions.filter(
(action) => action.type === "default" && action.key !== "script_action"
);
const scriptActions = this._defaultActions.filter(
(action) => action.type === "default" && action.key === "script_action"
);
const sections: AddToActionListSection<EntityAddToAction>[] = [
{
titleKey: "ui.dialogs.more_info_control.add_to.automations_heading",
actions: automationActions,
},
{
titleKey: "ui.dialogs.more_info_control.add_to.scripts_heading",
actions: scriptActions,
},
{
titleKey: "ui.dialogs.more_info_control.add_to.app_actions",
actions: this._externalActions,
},
];
return html`
<ha-list-base>
${this._renderActionItems(this._defaultActions)}
</ha-list-base>
${this._externalActions.length
? html`
<h2 class="section-title">
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.app_actions"
)}
</h2>
<ha-list-base>
${this._renderActionItems(this._externalActions)}
</ha-list-base>
`
: nothing}
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._actionSelected}
></ha-add-to-action-list>
`;
}
@@ -183,20 +177,6 @@ export class HaMoreInfoAddTo extends LitElement {
align-items: center;
padding: var(--ha-space-8);
}
.section-title {
padding: 0 var(--ha-space-6);
margin: var(--ha-space-4) 0 var(--ha-space-1);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
color: var(--secondary-text-color);
}
ha-icon {
display: flex;
align-items: center;
}
`;
}
+25 -12
View File
@@ -23,6 +23,7 @@ import { customElement, property, query, state } from "lit/decorators";
import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map";
import { keyed } from "lit/directives/keyed";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
@@ -62,6 +63,7 @@ import { subscribeLabFeature } from "../../data/labs";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { ScrollableFadeMixin } from "../../mixins/scrollable-fade-mixin";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
@@ -120,8 +122,8 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends SubscribeMixin(
ScrollableFadeMixin(LitElement)
export class MoreInfoDialog extends DirtyStateProviderMixin()(
SubscribeMixin(ScrollableFadeMixin(LitElement))
) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -517,7 +519,7 @@ export class MoreInfoDialog extends SubscribeMixin(
await favoritesHandler.copy(favoritesContext);
}
private _goToAddEntityTo(ev) {
private _goToAddEntityTo(ev: CustomEvent<RequestSelectedDetail>) {
// Only check for request-selected events (from menu items), not regular clicks (from icon button)
if (
ev.type === "request-selected" &&
@@ -590,10 +592,19 @@ export class MoreInfoDialog extends SubscribeMixin(
(v): v is string => Boolean(v)
);
const defaultTitle = breadcrumb.pop() || entityId;
const addToTitle = this.hass.localize(
"ui.dialogs.more_info_control.add_to.title",
{ target: defaultTitle }
);
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
const title =
this._currView === "details"
? this.hass.localize("ui.dialogs.more_info_control.details")
: this._childView?.viewTitle || defaultTitle;
: this._currView === "add_to"
? addToTitle
: this._childView?.viewTitle || defaultTitle;
const favoritesContext =
this._entry && stateObj
@@ -630,7 +641,8 @@ export class MoreInfoDialog extends SubscribeMixin(
@closed=${this._dialogClosed}
@opened=${this._handleOpened}
@show-child-view=${this._showChildView}
.preventScrimClose=${this._currView === "settings" ||
.preventScrimClose=${(this._currView === "settings" &&
this.isDirtyState) ||
!this._isEscapeEnabled}
flexcontent
>
@@ -711,9 +723,7 @@ export class MoreInfoDialog extends SubscribeMixin(
slot="icon"
.path=${mdiPlusBoxMultipleOutline}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
${addToMenuItem}
</ha-dropdown-item>
<wa-divider></wa-divider>
@@ -814,9 +824,7 @@ export class MoreInfoDialog extends SubscribeMixin(
? html`
<ha-icon-button
slot="headerActionItems"
.label=${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
.label=${addToMenuItem}
.path=${mdiPlusBoxMultipleOutline}
@click=${this._goToAddEntityTo}
></ha-icon-button>
@@ -906,7 +914,6 @@ export class MoreInfoDialog extends SubscribeMixin(
: this._currView === "add_to"
? html`
<ha-more-info-add-to
.hass=${this.hass}
.entityId=${entityId}
@add-to-action-selected=${this._goBack}
></ha-more-info-add-to>
@@ -952,6 +959,12 @@ export class MoreInfoDialog extends SubscribeMixin(
}
}
if (changedProps.has("_currView") || changedProps.has("_entry")) {
if (this._currView === "settings" && this._entry) {
this._initDirtyTracking({ type: "deep" });
}
}
if (changedProps.has("_currView")) {
this._infoEditMode = false;
this._detailsYamlMode = false;
@@ -30,7 +30,6 @@ export class HuiPersistentNotificationItem extends LitElement {
<span>
<ha-relative-time
id="relative-time"
.hass=${this.hass}
.datetime=${this.notification.created_at}
capitalize
></ha-relative-time>
+27 -64
View File
@@ -1,4 +1,5 @@
import { mdiDevices } from "@mdi/js";
import { consume } from "@lit/context";
import Fuse from "fuse.js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
@@ -47,7 +48,9 @@ import {
type ActionCommandComboBoxItem,
type NavigationComboBoxItem,
} from "../../data/quick_bar";
import type { RelatedResult } from "../../data/search";
import type { RelatedIdSets } from "../../common/search/related-context";
import { sortRelatedFirst } from "../../common/search/related-context";
import { relatedContext } from "../../data/context";
import {
multiTermSortedSearch,
type FuseWeightedKey,
@@ -70,6 +73,10 @@ const SEPARATOR = "________";
export class QuickBar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state()
@consume({ context: relatedContext, subscribe: true })
private _relatedIdSets?: RelatedIdSets;
@state() private _open = false;
@state() private _loading = true;
@@ -80,8 +87,6 @@ export class QuickBar extends LitElement {
@state() private _opened = false;
@state() private _relatedResult?: RelatedResult;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
private get _showEntityId() {
@@ -108,8 +113,6 @@ export class QuickBar extends LitElement {
this._selectedSection = effectiveQuickBarMode(this.hass.user, params.mode);
this._showHint = params.showHint ?? false;
this._relatedResult = params.contextItem ? params.related : undefined;
this._open = true;
}
@@ -432,7 +435,7 @@ export class QuickBar extends LitElement {
this._selectedSection = section as QuickBarSection | undefined;
return this._getItemsMemoized(
this._configEntryLookup,
this._relatedResult,
this._relatedIdSets,
searchString,
this._selectedSection
);
@@ -441,12 +444,11 @@ export class QuickBar extends LitElement {
private _getItemsMemoized = memoizeOne(
(
configEntryLookup: Record<string, ConfigEntry>,
relatedResult: RelatedResult | undefined,
relatedIdSets: RelatedIdSets | undefined,
filter?: string,
section?: QuickBarSection
) => {
const items: (string | PickerComboBoxItem)[] = [];
const relatedIdSets = this._getRelatedIdSets(relatedResult);
if (!section || section === "navigate") {
let navigateItems = this._generateNavigationCommandsMemoized(
@@ -498,7 +500,7 @@ export class QuickBar extends LitElement {
let entityItems = this._getEntitiesMemoized(this.hass);
// Mark related items
if (relatedIdSets.entities.size > 0) {
if (relatedIdSets?.entities.size) {
entityItems = entityItems.map((item) => ({
...item,
isRelated: relatedIdSets.entities.has(
@@ -508,7 +510,7 @@ export class QuickBar extends LitElement {
}
if (filter) {
entityItems = this._sortRelatedFirst(
entityItems = sortRelatedFirst(
this._filterGroup(
"entity",
entityItems,
@@ -537,7 +539,7 @@ export class QuickBar extends LitElement {
);
// Mark related items
if (relatedIdSets.devices.size > 0) {
if (relatedIdSets?.devices.size) {
deviceItems = deviceItems.map((item) => {
const deviceId = item.id.split(SEPARATOR)[1];
return {
@@ -548,7 +550,7 @@ export class QuickBar extends LitElement {
}
if (filter) {
deviceItems = this._sortRelatedFirst(
deviceItems = sortRelatedFirst(
this._filterGroup("device", deviceItems, filter, deviceComboBoxKeys)
);
} else {
@@ -569,7 +571,7 @@ export class QuickBar extends LitElement {
let areaItems = this._getAreasMemoized(this.hass);
// Mark related items
if (relatedIdSets.areas.size > 0) {
if (relatedIdSets?.areas.size) {
areaItems = areaItems.map((item) => {
const areaId = item.id.split(SEPARATOR)[1];
return {
@@ -580,7 +582,7 @@ export class QuickBar extends LitElement {
}
if (filter) {
areaItems = this._sortRelatedFirst(
areaItems = sortRelatedFirst(
this._filterGroup("area", areaItems, filter, areaComboBoxKeys)
);
} else {
@@ -601,41 +603,13 @@ export class QuickBar extends LitElement {
}
);
private _getRelatedIdSets = memoizeOne((related?: RelatedResult) => ({
entities: new Set(related?.entity || []),
devices: new Set(related?.device || []),
areas: new Set(related?.area || []),
}));
private _getEntitiesMemoized = memoizeOne((hass: HomeAssistant) =>
getEntities(
hass,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`entity${SEPARATOR}`
)
getEntities(hass, { idPrefix: `entity${SEPARATOR}` })
);
private _getDevicesMemoized = memoizeOne(
(hass: HomeAssistant, configEntryLookup: Record<string, ConfigEntry>) =>
getDevices(
hass,
configEntryLookup,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`device${SEPARATOR}`
)
getDevices(hass, configEntryLookup, { idPrefix: `device${SEPARATOR}` })
);
private _getAreasMemoized = memoizeOne((hass: HomeAssistant) =>
@@ -645,13 +619,9 @@ export class QuickBar extends LitElement {
hass.devices,
hass.entities,
hass.states,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
`area${SEPARATOR}`
{
idPrefix: `area${SEPARATOR}`,
}
)
);
@@ -705,10 +675,13 @@ export class QuickBar extends LitElement {
);
}
private _sortBySortingLabel = (entityA, entityB) =>
private _sortBySortingLabel = (
entityA: PickerComboBoxItem,
entityB: PickerComboBoxItem
) =>
caseInsensitiveStringCompare(
(entityA as PickerComboBoxItem).sorting_label!,
(entityB as PickerComboBoxItem).sorting_label!,
entityA.sorting_label!,
entityB.sorting_label!,
this.hass.locale.language
);
@@ -719,16 +692,6 @@ export class QuickBar extends LitElement {
return this._sortBySortingLabel(a, b);
});
private _sortRelatedFirst = (items: PickerComboBoxItem[]) =>
[...items].sort((a, b) => {
const aRelated = Boolean(a.isRelated);
const bRelated = Boolean(b.isRelated);
if (aRelated === bRelated) {
return 0;
}
return aRelated ? -1 : 1;
});
// #endregion data
// #region interaction
@@ -1,5 +1,4 @@
import { fireEvent } from "../../common/dom/fire_event";
import type { ItemType, RelatedResult } from "../../data/search";
import type { HomeAssistant } from "../../types";
import { closeDialog } from "../make-dialog-manager";
@@ -10,17 +9,10 @@ export type QuickBarSection =
| "navigate"
| "command";
export interface QuickBarContextItem {
itemType: ItemType;
itemId: string;
}
export interface QuickBarParams {
entityFilter?: string;
mode?: QuickBarSection;
showHint?: boolean;
contextItem?: QuickBarContextItem;
related?: RelatedResult;
}
/** Non-admin users cannot scope the bar to command, device, or area (those sections are admin-only). */
@@ -391,7 +391,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<hass-tabs-subpage
.hass=${this.hass}
.localizeFunc=${this.localizeFunc}
.narrow=${this.narrow}
.isWide=${this.isWide}
.backPath=${this.backPath}
.backCallback=${this.backCallback}
+12 -8
View File
@@ -1,3 +1,4 @@
import { consume } from "@lit/context";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
@@ -18,6 +19,7 @@ import "../components/ha-icon-button-arrow-prev";
import "../components/ha-menu-button";
import "../components/ha-svg-icon";
import "../components/ha-tab";
import { narrowViewportContext } from "../data/context";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, Route } from "../types";
@@ -59,7 +61,9 @@ export class HassTabsSubpage extends LitElement {
@property({ attribute: false }) public tabs!: PageNavigation[];
@property({ type: Boolean, reflect: true }) public narrow = false;
@state()
@consume({ context: narrowViewportContext, subscribe: true })
private _narrow = false;
@property({ type: Boolean, reflect: true, attribute: "is-wide" })
public isWide = false;
@@ -116,7 +120,7 @@ export class HassTabsSubpage extends LitElement {
<a href=${page.path} @click=${this._tabClicked}>
<ha-tab
.active=${page.path === activeTab?.path}
.narrow=${this.narrow}
.narrow=${this._narrow}
.name=${page.translationKey
? localizeFunc(page.translationKey)
: page.name}
@@ -151,18 +155,18 @@ export class HassTabsSubpage extends LitElement {
this.hass.config.components,
this.hass.language,
this.hass.userData,
this.narrow,
this._narrow,
this.localizeFunc || this.hass.localize
);
return html`
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar ${classMap({ narrow: this._narrow })}">
<slot name="toolbar">
<div class="toolbar-content">
${this.mainPage || (!this.backPath && history.state?.root)
? html`
<ha-menu-button
.hass=${this.hass}
.narrow=${this.narrow}
.narrow=${this._narrow}
></ha-menu-button>
`
: this.backPath
@@ -178,12 +182,12 @@ export class HassTabsSubpage extends LitElement {
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`}
${this.narrow || !this.showTabs
${this._narrow || !this.showTabs
? html`<div class="main-title">
<slot name="header">${!this.showTabs ? tabs[0] : ""}</slot>
</div>`
: ""}
${this.showTabs && !this.narrow
${this.showTabs && !this._narrow
? html`<div id="tabbar">${tabs}</div>`
: ""}
<div id="toolbar-icon">
@@ -191,7 +195,7 @@ export class HassTabsSubpage extends LitElement {
</div>
</div>
</slot>
${this.showTabs && this.narrow
${this.showTabs && this._narrow
? html`<div id="tabbar" class="bottom-bar">${tabs}</div>`
: ""}
</div>
+11 -1
View File
@@ -1,3 +1,4 @@
import { ContextProvider } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -7,6 +8,7 @@ import { listenMediaQuery } from "../common/dom/media_query";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { computeRTLDirection } from "../common/util/compute_rtl";
import "../components/ha-drawer";
import { narrowViewportContext } from "../data/context";
import { showNotificationDrawer } from "../dialogs/notifications/show-notification-drawer";
import type { HomeAssistant, Route } from "../types";
import "./partial-panel-resolver";
@@ -36,6 +38,11 @@ export class HomeAssistantMain extends LitElement {
@state() private _drawerOpen = false;
private _narrowViewportProvider = new ContextProvider(this, {
context: narrowViewportContext,
initialValue: this.narrow,
});
constructor() {
super();
listenMediaQuery("(max-width: 870px)", (matches) => {
@@ -66,7 +73,6 @@ export class HomeAssistantMain extends LitElement {
></ha-sidebar>
${isPanelReady
? html`<partial-panel-resolver
.narrow=${this.narrow}
.hass=${this.hass}
.route=${this.route}
slot="appContent"
@@ -121,6 +127,10 @@ export class HomeAssistantMain extends LitElement {
}
public willUpdate(changedProps: PropertyValues<this>) {
if (changedProps.has("narrow")) {
this._narrowViewportProvider.setValue(this.narrow);
}
if (changedProps.has("route") && this._sidebarNarrow) {
this._drawerOpen = false;
}
+8 -4
View File
@@ -1,12 +1,14 @@
import { consume } from "@lit/context";
import {
STATE_NOT_RUNNING,
STATE_RUNNING,
STATE_STARTING,
} from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { deepActiveElement } from "../common/dom/deep-active-element";
import { deepEqual } from "../common/util/deep-equal";
import { narrowViewportContext } from "../data/context";
import { getDefaultPanel } from "../data/panel";
import type { CustomPanelInfo } from "../data/panel_custom";
import type { HomeAssistant, Panels } from "../types";
@@ -43,7 +45,9 @@ const COMPONENTS = {
class PartialPanelResolver extends HassRouterPage {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@state()
@consume({ context: narrowViewportContext, subscribe: true })
private _narrow = false;
private _waitForStart = false;
@@ -92,7 +96,7 @@ class PartialPanelResolver extends HassRouterPage {
const el = super.createLoadingScreen();
el.rootnav = true;
el.hass = this.hass;
el.narrow = this.narrow;
el.narrow = this._narrow;
return el;
}
@@ -100,7 +104,7 @@ class PartialPanelResolver extends HassRouterPage {
const hass = this.hass;
el.hass = hass;
el.narrow = this.narrow;
el.narrow = this._narrow;
el.route = this.routeTail;
el.panel = hass.panels[this._currentPage];
}
+193
View File
@@ -0,0 +1,193 @@
import { provide } from "@lit/context";
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { deepEqual } from "../common/util/deep-equal";
import { shallowEqual } from "../common/util/shallow-equal";
import {
dirtyStateContext,
type DirtyStateContext,
} from "../data/context/dirty-state";
import type { Constructor } from "../types";
export type CompareStrategy<State> =
| { type: "deep" }
| { type: "shallow" }
| { type: "custom"; compare: (a: State, b: State) => boolean };
function resolveCompare<State>(
strategy: CompareStrategy<State>
): (a: State, b: State) => boolean {
switch (strategy.type) {
case "deep":
return (a, b) => deepEqual(a, b);
case "shallow":
return (a, b) => shallowEqual(a, b);
default:
return strategy.compare;
}
}
/**
* Mixin that provides dirty-state tracking via Lit context.
*
* Uses the `@provide` decorator so any descendant component can consume
* dirty-state with `@consume({ context: dirtyStateContext, subscribe: true })`.
*
* Curried generic pattern: `State` is explicitly provided while `Base` is
* inferred from the superclass argument.
*
* @example Eager init (state known upfront, e.g. dialog open):
* ```ts
* interface MyDialogState { name: string; icon: string }
*
* class MyDialog extends DirtyStateProviderMixin<MyDialogState>()(LitElement) {
* open() {
* this._initDirtyTracking({ type: "shallow" }, { name: "", icon: "" });
* }
* }
* ```
*
* @example Deferred init (child consumer reports initial state):
* ```ts
* class MyPage extends DirtyStateProviderMixin<FormState>()(LitElement) {
* connectedCallback() {
* super.connectedCallback();
* this._initDirtyTracking({ type: "deep" });
* // First setState from a child consumer sets the baseline
* }
* }
* ```
*
* Child consumers:
* ```ts
* @consume({ context: dirtyStateContext, subscribe: true })
* @state()
* private _dirtyState?: DirtyStateContext;
*
* // Read: this._dirtyState?.isDirty
* // Write: this._dirtyState?.setState(newState)
* ```
*/
export const DirtyStateProviderMixin =
<State = unknown>() =>
<Base extends Constructor<LitElement>>(superClass: Base) => {
class DirtyStateProviderMixinClass extends superClass {
private _dirtyInitialState: State | undefined;
private _dirtyCurrentState: State | undefined;
private _dirtyCompareFn: (a: State, b: State) => boolean = deepEqual;
@provide({ context: dirtyStateContext })
@state()
private _dirtyStateContext: DirtyStateContext = this._buildContextValue(
undefined,
false
);
/**
* Build the context value object for the provider.
*
* The returned type is `DirtyStateContext` (i.e. `DirtyStateContext<unknown>`)
* because the singleton context key is typed at `unknown`. The single
* `unknown → State` narrowing cast in `setState` is the only unsafe boundary
* and is confined here.
*/
private _buildContextValue(
currentState: State | undefined,
isDirty: boolean
): DirtyStateContext {
return {
isDirty,
state: currentState,
setState: (incoming: unknown) => {
this._updateDirtyState(incoming as State);
},
markClean: () => {
this._markDirtyStateClean();
},
};
}
/**
* Initialize dirty state tracking.
*
* When `initialState` is provided, tracking starts immediately.
* When omitted (deferred mode), the first `_updateDirtyState` /
* `setState` call from a consumer becomes the baseline snapshot.
*
* Call again to reset (e.g. when the underlying entity changes).
*/
protected _initDirtyTracking(
strategy: CompareStrategy<State>,
initialState?: State
): void {
this._dirtyCompareFn = resolveCompare(strategy);
if (initialState !== undefined) {
this._dirtyInitialState = initialState;
this._dirtyCurrentState = initialState;
this._dirtyStateContext = this._buildContextValue(
initialState,
false
);
} else {
this._dirtyInitialState = undefined;
this._dirtyCurrentState = undefined;
this._dirtyStateContext = this._buildContextValue(undefined, false);
}
}
/**
* Update the tracked state. Triggers dirty comparison against initial snapshot.
*
* If called before `_initDirtyTracking` provided an initial state (deferred
* mode), the first call sets the baseline and reports clean.
*
* Guarded: no-ops if the computed dirty status and state reference are
* unchanged, preventing render loops when called from `updated()`.
*/
protected _updateDirtyState(newState: State): void {
// Deferred init: first state becomes the baseline
if (this._dirtyInitialState === undefined) {
this._dirtyInitialState = newState;
this._dirtyCurrentState = newState;
this._dirtyStateContext = this._buildContextValue(newState, false);
return;
}
const isDirty = !this._dirtyCompareFn(
this._dirtyInitialState,
newState
);
if (
this._dirtyCurrentState !== undefined &&
this._dirtyCompareFn(this._dirtyCurrentState, newState) &&
this._dirtyStateContext.isDirty === isDirty
) {
return;
}
this._dirtyCurrentState = newState;
this._dirtyStateContext = this._buildContextValue(newState, isDirty);
}
/**
* Reset the initial snapshot to the current state, marking the state as clean.
* Call this after a successful save.
*/
protected _markDirtyStateClean(): void {
this._dirtyInitialState = this._dirtyCurrentState;
this._dirtyStateContext = this._buildContextValue(
this._dirtyCurrentState,
false
);
}
/**
* Whether the current state differs from the initial snapshot.
*/
public get isDirtyState(): boolean {
return this._dirtyStateContext.isDirty;
}
}
return DirtyStateProviderMixinClass;
};
+29 -98
View File
@@ -1,7 +1,6 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { goBack } from "../../common/navigate";
import { debounce } from "../../common/util/debounce";
import { deepEqual } from "../../common/util/deep-equal";
@@ -15,6 +14,7 @@ import type { Lovelace } from "../lovelace/types";
import "../lovelace/views/hui-view";
import "../lovelace/views/hui-view-background";
import "../lovelace/views/hui-view-container";
import "../../components/ha-top-app-bar-fixed";
const CLIMATE_LOVELACE_VIEW_CONFIG: LovelaceStrategyViewConfig = {
strategy: {
@@ -97,38 +97,36 @@ class PanelClimate extends LitElement {
protected render() {
return html`
<div class="header ${classMap({ narrow: this.narrow })}">
<div class="toolbar">
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
<div class="main-title">${this.hass.localize("panel.climate")}</div>
</div>
</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
<ha-top-app-bar-fixed .narrow=${this.narrow}>
${this._searchParams.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
></ha-menu-button>
`}
<div slot="title">${this.hass.localize("panel.climate")}</div>
${this._lovelace
? html`
<hui-view-container .hass=${this.hass}>
<hui-view-background .hass=${this.hass}> </hui-view-background>
<hui-view
.hass=${this.hass}
.narrow=${this.narrow}
.lovelace=${this._lovelace}
.index=${this._viewIndex}
></hui-view
></hui-view-container>
`
: nothing}
</ha-top-app-bar-fixed>
`;
}
@@ -169,78 +167,11 @@ class PanelClimate extends LitElement {
-webkit-user-select: none;
-moz-user-select: none;
}
.header {
background-color: var(--app-header-background-color);
color: var(--app-header-text-color, white);
position: fixed;
top: 0;
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
);
padding-top: var(--safe-area-inset-top);
z-index: 4;
display: flex;
flex-direction: row;
-webkit-backdrop-filter: var(--app-header-backdrop-filter, none);
backdrop-filter: var(--app-header-backdrop-filter, none);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .header {
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
);
padding-left: var(--safe-area-inset-left);
}
:host([scrolled]) .header {
box-shadow: var(
--bar-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.toolbar {
height: var(--header-height);
display: flex;
flex: 1;
align-items: center;
font-size: var(--ha-font-size-xl);
padding: 0px 12px;
font-weight: var(--ha-font-weight-normal);
box-sizing: border-box;
border-bottom: var(--app-header-border-bottom, none);
}
:host([narrow]) .toolbar {
padding: 0 4px;
}
.main-title {
margin-inline-start: var(--ha-space-6);
line-height: var(--ha-line-height-normal);
flex-grow: 1;
}
.narrow .main-title {
margin-inline-start: var(--ha-space-2);
}
hui-view-container {
position: relative;
display: flex;
min-height: 100vh;
box-sizing: border-box;
padding-top: calc(var(--header-height) + var(--safe-area-inset-top));
padding-right: var(--safe-area-inset-right);
padding-inline-end: var(--safe-area-inset-right);
padding-bottom: var(--safe-area-inset-bottom);
}
:host([narrow]) hui-view-container {
padding-left: var(--safe-area-inset-left);
padding-inline-start: var(--safe-area-inset-left);
}
hui-view {
flex: 1 1 100%;
@@ -185,23 +185,25 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private _renderInfoCard() {
const systemManaged = this._isSystemManaged(this._currentAddon);
return html`<ha-card outlined>
return html` <ha-card outlined>
<div class="card-content">
<div class="addon-header">
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
<div class="title">
${getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)}
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
${!this.narrow
? getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)
: nothing}
<div class="description">
${this._currentAddon.version
? html`
@@ -239,17 +241,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<supervisor-apps-state
.state=${this._currentAddon.state}
></supervisor-apps-state>`
: html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`}
: nothing}
</div>
<ha-chip-set class="capabilities">
@@ -513,7 +505,8 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</div>
${(this._currentAddon.update_available && this._updateEntityId) ||
this._computeShowWebUI ||
this._computeShowIngressUI
this._computeShowIngressUI ||
!this._currentAddon.version
? html`
<div class="card-actions">
${this._currentAddon.update_available && this._updateEntityId
@@ -549,6 +542,19 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</ha-button>
`
: nothing}
${!this._currentAddon.version
? html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`
: nothing}
</div>
`
: nothing}
@@ -1497,16 +1503,17 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
}
.addon-header {
display: flex;
padding-inline-start: var(--ha-space-2);
padding-inline-end: initial;
font-size: var(--ha-font-size-2xl);
color: var(--ha-card-header-color, var(--primary-text-color));
align-items: center;
gap: var(--ha-space-2);
flex-wrap: wrap;
margin-bottom: var(--ha-space-4);
}
.addon-header .title {
flex: 1;
margin-inline-end: var(--ha-space-4);
}
.addon-header .title .description {
@@ -1525,17 +1532,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--error-color);
margin-bottom: var(--ha-space-4);
}
.description {
margin-bottom: var(--ha-space-4);
}
.description a {
color: var(--primary-color);
}
img.logo {
max-width: 100%;
max-height: 60px;
max-height: 40px;
display: block;
margin-bottom: var(--ha-space-2);
}
ha-assist-chip {
--md-sys-color-primary: var(--text-primary-color);
@@ -135,7 +135,6 @@ class HaConfigAppDashboard extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${route}
.tabs=${addonTabs}
back-path=${this._fromStore ? "/config/apps/available" : "/config/apps"}
+101 -108
View File
@@ -12,22 +12,32 @@ import {
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-adaptive-dialog";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
areasContext,
internationalizationContext,
} from "../../../data/context";
import type { SceneEntities } from "../../../data/scene";
import { showSceneEditor } from "../../../data/scene";
import "../../../dialogs/add-to/ha-add-to-action-list";
import type {
AddToActionListActionSelectedEvent,
AddToActionListItem,
AddToActionListSection,
} from "../../../dialogs/add-to/ha-add-to-action-list";
import {
addToActionHandler,
type AddToActionKey,
} from "../../../dialogs/more-info/add-to";
createAddToSceneEntities,
type AddToAutomationScriptActionKey,
} from "../../../dialogs/add-to/add-to";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { AreaAddToDialogParams } from "./show-dialog-area-add-to";
type AreaAddToAction =
| (AddToActionListItem & {
type: "automation";
key: AddToAutomationScriptActionKey;
})
| (AddToActionListItem & { type: "scene" });
@customElement("dialog-area-add-to")
class DialogAreaAddTo extends LitElement {
@state()
@@ -65,7 +75,12 @@ class DialogAreaAddTo extends LitElement {
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.title"
"ui.dialogs.more_info_control.add_to.title",
{
target:
computeAreaName(this._areas[this._params.areaId]) ||
this._params.areaId,
}
)}
@closed=${this._dialogClosed}
>
@@ -79,108 +94,96 @@ class DialogAreaAddTo extends LitElement {
return nothing;
}
const area = this._areas[this._params.areaId];
const areaName = computeAreaName(area) || this._params.areaId;
return html`
<h3 class="section-header">
${this._i18n.localize(
const sections: AddToActionListSection<AreaAddToAction>[] = [
{
title: this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list>
${this._renderActionItem(
"automation_trigger",
mdiRobotOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
areaName
)}
${this._renderActionItem(
"automation_condition",
mdiPlaylistCheck,
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
areaName
)}
${this._renderActionItem(
"automation_action",
mdiPlayCircleOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_action",
areaName
)}
</ha-list>
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
<ha-list>
${this._renderActionItem(
"script_action",
mdiScriptTextOutline,
"ui.dialogs.more_info_control.add_to.actions.script_action",
areaName
)}
</ha-list>
${this._renderSceneSection(areaName)}
`;
}
),
actions: [
{
type: "automation",
key: "automation_trigger",
iconPath: mdiRobotOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_trigger"
),
},
{
type: "automation",
key: "automation_condition",
iconPath: mdiPlaylistCheck,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_condition"
),
},
{
type: "automation",
key: "automation_action",
iconPath: mdiPlayCircleOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.automation_action"
),
},
],
},
{
title: this._i18n.localize(
"ui.panel.config.devices.script.scripts_heading"
),
actions: [
{
type: "automation",
key: "script_action",
iconPath: mdiScriptTextOutline,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.script_action"
),
},
],
},
];
private _renderSceneSection(areaName: string) {
if (!this._params?.entityIds.length) {
return nothing;
if (this._params.canCreateScene && this._params.entityIds.length) {
sections.push({
title: this._i18n.localize(
"ui.panel.config.devices.scene.scenes_heading"
),
actions: [
{
type: "scene",
iconPath: mdiPalette,
name: this._i18n.localize(
"ui.dialogs.more_info_control.add_to.action_options.scene"
),
},
],
});
}
return html`
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.scene.scenes_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
@click=${this._handleCreateScene}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPalette}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.scene",
{ target: areaName }
)}
</ha-list-item>
</ha-list>
<ha-add-to-action-list
.sections=${sections}
@add-to-list-action-selected=${this._handleActionSelected}
></ha-add-to-action-list>
`;
}
private _renderActionItem(
key: AddToActionKey,
path: string,
translationKey:
| "ui.dialogs.more_info_control.add_to.actions.automation_trigger"
| "ui.dialogs.more_info_control.add_to.actions.automation_condition"
| "ui.dialogs.more_info_control.add_to.actions.automation_action"
| "ui.dialogs.more_info_control.add_to.actions.script_action",
areaName: string
private _handleActionSelected(
ev: AddToActionListActionSelectedEvent<AreaAddToAction>
) {
return html`
<ha-list-item
graphic="icon"
data-type=${key}
@click=${this._handleAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${path}></ha-svg-icon>
${this._i18n.localize(translationKey, { target: areaName })}
</ha-list-item>
`;
}
private _handleAction(ev: Event) {
if (!this._params) {
return;
}
const key = (ev.currentTarget as HTMLElement).dataset
.type as AddToActionKey;
const { action } = ev.detail;
if (action.type === "scene") {
this._handleCreateScene();
return;
}
this.closeDialog();
addToActionHandler(key, { area_id: this._params.areaId });
addToActionHandler(action.key, { area_id: this._params.areaId });
}
private _handleCreateScene() {
@@ -188,13 +191,11 @@ class DialogAreaAddTo extends LitElement {
return;
}
const entities: SceneEntities = {};
for (const entityId of this._params.entityIds) {
entities[entityId] = "";
}
this.closeDialog();
showSceneEditor({ entities }, this._params.areaId);
showSceneEditor(
{ entities: createAddToSceneEntities(this._params.entityIds) },
this._params.areaId
);
}
static get styles(): CSSResultGroup {
@@ -205,14 +206,6 @@ class DialogAreaAddTo extends LitElement {
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-4) 0;
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
`,
];
}
@@ -48,8 +48,6 @@ import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry
const cropOptions: CropOptions = {
round: false,
type: "image/jpeg",
quality: 0.75,
};
const SENSOR_DOMAINS = ["sensor"];
+11 -2
View File
@@ -60,6 +60,7 @@ import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
import { findRelated } from "../../../data/search";
import { filterAddToSceneEntityIds } from "../../../dialogs/add-to/add-to";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
@@ -439,7 +440,7 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
"ui.dialogs.more_info_control.add_to.item"
)}
</ha-button>`
: nothing}
@@ -781,9 +782,17 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
if (!area) {
return;
}
const sceneEntityIds = filterAddToSceneEntityIds(
this._areaEntityIds,
this._entityReg,
this.hass.states
);
showAreaAddToDialog(this, {
areaId: area.area_id,
entityIds: this._areaEntityIds,
entityIds: sceneEntityIds,
canCreateScene:
isComponentLoaded(this.hass.config, "scene") &&
sceneEntityIds.length > 0,
});
}
@@ -82,8 +82,6 @@ export class HaConfigAreasDashboard extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@state() private _hierarchy?: AreasFloorHierarchy;
@@ -169,7 +167,6 @@ export class HaConfigAreasDashboard extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.isWide=${this.isWide}
.backPath=${this._searchParms.has("historyBack")
? undefined
@@ -3,6 +3,7 @@ import { fireEvent } from "../../../common/dom/fire_event";
export interface AreaAddToDialogParams {
areaId: string;
entityIds: string[];
canCreateScene: boolean;
}
export const loadAreaAddToDialog = () => import("./dialog-area-add-to");
@@ -334,13 +334,15 @@ export default class HaAutomationActionRow extends LitElement {
? this._renderTargets(
target,
actionHasTarget && !this._isNew,
serviceTargetSpec
serviceTargetSpec,
type !== "device_id"
)
: nothing}
${noteTooltipText
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -721,13 +723,14 @@ export default class HaAutomationActionRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);

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