Compare commits

...

79 Commits

Author SHA1 Message Date
Paulus Schoutsen 854e56947f Update IFRAME_SANDBOX to remove 'allow-same-origin'
Removed 'allow-same-origin' from the IFRAME_SANDBOX settings.
2026-05-18 12:12:21 -04:00
Marcin Bauer 4728eb7231 Remove arrow icon from continue on error indicator (#52092)
The arrow-right icon next to the alert icon was decorative noise.
With automation comments (#52090) adding yet another icon, simplify
to a single mdiAlertCircleCheck indicator.

Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-18 17:28:14 +03:00
renovate[bot] d02b92bd32 Update dependency @tsparticles/engine to v4 (#52091)
* Update dependency @tsparticles/engine to v4

* Bump @tsparticles/preset-links to v4 to match engine

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-18 14:15:54 +00:00
Wendelin 98525d23e6 Lovelace condition live test (#52027)
* Add lovelace condition live test

* Add live card status

* Add empty text
2026-05-18 15:01:56 +02:00
Petar Petrov ec98b21276 Highlight problematic devices in Energy Dashboard list (#52088) 2026-05-18 10:18:27 +01:00
Paulus Schoutsen defad3beca Treat media player unknown state like off instead of unavailable (#52080)
* Show both power buttons for assumed-state media players when unknown

Media players with assumed state report an unknown state when their
actual power state can't be determined. In that case the entity row and
more info should still expose both turn on and turn off controls so the
user can operate the device.

https://claude.ai/code/session_01JyZojNPCCY65HmRVQaASkG

* Treat media player unknown state like off instead of unavailable

The media player controls lumped the "unknown" state in with
"unavailable" and hid all controls. An unknown state is closer to "off":
the device exists but its power state isn't reported, which is common
for assumed-state players. Only "unavailable" should hide the controls,
so an unknown-state player now shows the turn on button (and both power
buttons when it has an assumed state) in the entity row and more info.

https://claude.ai/code/session_01JyZojNPCCY65HmRVQaASkG

* Adjust comments and variable placement for media player state check

https://claude.ai/code/session_01JyZojNPCCY65HmRVQaASkG

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-18 09:58:01 +03:00
Petar Petrov 635d61256b Fix Y-axis label precision in statistics and history charts (#52038) 2026-05-18 08:17:43 +02:00
renovate[bot] 60c5bea6e0 Update formatjs monorepo (#52085)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:03:08 +02:00
renovate[bot] aed83ccc07 Update dependency @codemirror/view to v6.43.0 (#52081)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-18 07:02:53 +02:00
pcan08 85b2ca377a Add sound mode filtering to media-player-sound-mode feature (#52058)
* feat(tile-card): add sound mode filtering to media-player-sound-mode card feature

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

* Attempt to fix CI lint error

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:41:46 +03:00
Simon Lamon e194247f50 Add percentage of battery state of charge (#52065)
Add percentage of battery soc
2026-05-17 15:40:42 +03:00
karwosts c79956b893 Dynamically compute overflow for ha-data-table-labels (#52069)
* Dynamically compute overflow for ha-data-table-labels

* update
2026-05-17 15:39:49 +03:00
renovate[bot] abb4cbc263 Update dependency lit-html to v3.3.3 (#52073)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 07:01:11 +00:00
pcan08 811397f740 Entities page: forget filter from url (#51988)
* fix(entities): clear URL-injected filters on leaving entities dashboard

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

* fix(entities): restore previous filters after URL-injected navigation

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

* refactor: use separate storage and display filters

Apply the same pattern as devices page: split _filters into a display-only
@state and a _storageFilters persisted to sessionStorage. _storageFilters
is only updated when not in URL mode (_fromUrl flag), so URL-injected
filters never persist to storage.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 08:55:18 +02:00
dependabot[bot] c7c78bd587 Bump actions/labeler from 6.0.1 to 6.1.0 (#52077)
Bumps [actions/labeler](https://github.com/actions/labeler) from 6.0.1 to 6.1.0.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/634933edcd8ababfe52f92936142cc22ac488b1b...f27b608878404679385c85cfa523b85ccb86e213)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-17 08:53:19 +02:00
renovate[bot] bf67a3ec1d Update formatjs monorepo (#52078)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 08:53:15 +02:00
dependabot[bot] b0772d6701 Bump relative-ci/agent-action from 3.2.2 to 3.2.3 (#52076)
Bumps [relative-ci/agent-action](https://github.com/relative-ci/agent-action) from 3.2.2 to 3.2.3.
- [Release notes](https://github.com/relative-ci/agent-action/releases)
- [Commits](https://github.com/relative-ci/agent-action/compare/3c681926017930047fc03acaa35cd6a44efcbfc3...fcf45416581928e8dd62eded78ce98c78e5149f8)

---
updated-dependencies:
- dependency-name: relative-ci/agent-action
  dependency-version: 3.2.3
  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-17 08:53:09 +02:00
dependabot[bot] d484ef3f2f Bump release-drafter/release-drafter from 7.2.1 to 7.3.0 (#52075)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.2.1 to 7.3.0.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/563bf132657a13ded0b01fcb723c5a58cdd824e2...c2e2804cc59f45f57076a99af580d0fedb697927)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.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-17 08:53:06 +02:00
dependabot[bot] 6f8fffccbd Bump github/codeql-action from 4.35.3 to 4.35.4 (#52074)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.3 to 4.35.4.
- [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/e46ed2cbd01164d986452f91f178727624ae40d7...68bde559dea0fdcac2102bfdf6230c5f70eb485e)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.4
  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-17 08:53:03 +02:00
renovate[bot] df03a0dfd9 Update dependency lit to v3.3.3 (#52072)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-17 08:52:57 +02:00
renovate[bot] bb12cb19b5 Update dependency @rspack/core to v2.0.3 (#52059)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 15:53:06 +02:00
renovate[bot] 7baf7f4701 Update formatjs monorepo (#52060)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-15 15:52:44 +02:00
pcan08 0dfb801ff6 Devices page: forget filter from url (#51986)
* fix(devices): clear URL-injected filters on leaving devices dashboard

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

* fix(devices): restore previous filters after URL-injected navigation

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

* refactor(devices): use separate storage and display filters

Replace the disconnect-callback approach with two distinct filter states:
- _storageFilters: persisted to sessionStorage, updated only when not in
  URL mode (manual filter changes and clear)
- _filters: display-only state, initialized from _storageFilters on first
  render, overwritten by URL params without touching storage

_storageFilters is frozen while _fromUrl is true, preserving the user's
previous manual filters for the next normal visit.

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:45:51 +02:00
renovate[bot] d94dcf50fb Update dependency eslint-plugin-lit to v2.3.1 (#52057)
* Update dependency eslint-plugin-lit to v2.3.1

* Fix lit/prefer-query-decorators violations

eslint-plugin-lit 2.3.0 introduced this rule. Replace querySelector
calls with @query/@queryAll decorators where the selector is static.
Use per-line disables for dynamic selectors that can't use decorators.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-15 09:37:14 +03:00
Petar Petrov fb1f5ef722 Dev tools -> Templates: observe tip height with ResizeObserver (#52048)
Replaces the per-render scrollHeight read from #52012 with a
ResizeObserver started in firstUpdated, writing --tip-height directly to
the host style. Removes the need for a manual refresh when the viewport
crosses a wrap threshold, drops the unreachable isNaN check, and lets
the @query decorator stand in for the editor-tip id.
2026-05-15 08:37:04 +02:00
pcan08 e5d5797d91 Add mute to media player volume slider feature (#52050)
* feat: add mute button to media-player-volume-slider card feature

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

* refactor(tile-card): extract mute button logic into shared utility

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:31:45 +03:00
pcan08 adee24f745 fix filter badge count (increment) on panel re-open (#52054)
fix(filter): prevent badge count from incrementing on panel re-open

Integrations and domains filter panels use lazy rendering: the list is
destroyed on close and recreated on open. On recreation, MWC fires a
`selected` event with a diff for each pre-selected item, which the
diff-based handler interpreted as a new user selection, appending
duplicates to `this.value` on every expansion.

Switch both handlers to the full-set approach (`SelectedDetail<Set<number>>`)
already used by labels, states, and voice-assistants, rebuilding the value
from the complete index set. Add the `preserved` pattern to retain
selections hidden by the search filter. Also add `_value` to the `_domains`
memoize signature to ensure cache invalidation when the selection changes.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:38:13 +03:00
karwosts 1b695e24d0 Ensure statistics-graph-card uses correct external stat names (#52055) 2026-05-15 08:36:33 +03:00
pcan08 7f9259edf9 Add shuffle and repeat controls to media-player-playback feature (#52052)
feat(tile-card): add shuffle and repeat controls to media-player-playback card feature

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:55:14 +02:00
renovate[bot] 6954dc1a54 Update dependency typescript-eslint to v8.59.3 (#52056)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 18:37:14 +00:00
renovate[bot] 032d0fb332 Update vitest monorepo to v4.1.6 (#52053)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-14 20:28:13 +02:00
Jan-Philipp Benecke 43ed97da43 Migrate gallery drawer to ha-drawer and drop mwc-drawer dependency (#52031)
* Migrate gallery drawer to ha-drawer and drop md-drawer dependency

* Trigger Build

* Fix scrolling
2026-05-14 15:52:24 +02:00
Aidan Timson 9f4d35bc05 Remove advanced mode navigation gating (#52045) 2026-05-14 15:37:28 +03:00
ildar170975 11afde6b5f Dev tools -> Templates: fix editor height (#52012)
* fix editor height

* get a height of ha-tip by `Element.scrollHeight`

* minor cleanup
2026-05-14 15:34:23 +03:00
pcan08 1b0dcb33b1 Add source filtering to media-player-source card feature (#52046)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Aidan Timson <aidan@timmo.dev>
2026-05-14 12:26:36 +00:00
Aidan Timson 67eecbc51d Remove "advanced" service controls (#52041)
Remove "Advanced" service controls
2026-05-14 15:19:52 +03:00
renovate[bot] 969ccf85d2 Update dependency @rsdoctor/rspack-plugin to v1.5.11 (#52040) 2026-05-14 08:51:19 +00:00
pcan08 500ce18ae5 Add volume mute button to media player playback card feature (#52029)
feat: add volume mute button to media player playback card feature

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 09:04:15 +03:00
pcan08 b413a7742c Add mute button to media player volume buttons card f… (#52028)
* feat(lovelace): add mute button to media player volume buttons card feature

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

* feat: add show_mute_button config option to volume buttons feature

* feat: disable show_mute_button option when entity does not support mute

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

* Apply suggestions from code review

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

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-14 05:27:21 +00:00
karwosts e84373fdbd Fix energy device name dialog placeholders (#52032)
* Fix energy device placeholders

* array indexes
2026-05-14 08:17:55 +03:00
Simon Lamon caaee14856 Sync selected index in ha-list-base after initialization (#52033)
* Fix selection when they register after selectedIndices has been initialized

* Better fix
2026-05-14 08:16:35 +03:00
karwosts 28f04df81d Improve statistic picker handling of external stats (#52037) 2026-05-14 08:15:54 +03:00
Wendelin 48a8c5b2d5 Migrate ha-md-list to ha-list-base 1 (#52019)
* Migrate ha-md-list to ha-list-base

* Migrate ha-md-list to ha-list-base

* Next batch

* review
2026-05-13 19:51:31 +02:00
Petar Petrov 45312ba7fd Fix water sankey untracked consumption with nested sub-trackers (#51998) 2026-05-13 18:26:41 +02:00
Jan-Philipp Benecke b5dad80e19 Migrate ha-drawer to Web Awesome drawer (#51990)
* Migrate ha-drawer to Web Awesome drawer

* Make CI happy

* Implement swipe gesture support for ha-drawer and fix RTL support

* Fix CI

* Fix CI

* Readd border

* Fix sidebar

* Layout fix

* Fix sluggish scroll on mobile

* Fix CI

* Add transition
2026-05-13 17:01:39 +02:00
Aidan Timson ae85263d91 Add context for hass.format*, replace hass with lazy context on yaml/code editor (#52021)
* Add context for `hass.format*`

* Remove hass and use lazy context for ha-code-editor

* Remove hass and use context where hass isnt needed extensively

* Finish context switch for code editor

* Remove hass from yaml-editor calls

* Cleanup unused

* Update src/util/documentation-url.ts

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

* Fix

---------

Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-13 16:57:06 +02:00
ildar170975 c5000bcdde hui-history-graph-card-editor: add more options (#51749)
* add more options for history-graph-card

* add more options for history-graph-card

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-13 14:13:40 +00:00
karwosts 5e085c70b0 Make gas & water sources nameable (#52011)
* Make gas & water sources nameable

* Add placeholders
2026-05-13 17:11:20 +03:00
Wendelin 71fc44284c Add tags to installed apps (#51987)
* Add supervisor app state tags and update translations

* Update styles

* Review

* Review

* unknown icon, lighter running green
2026-05-13 16:02:33 +02:00
Petar Petrov b7e1e23eaa Position chart tooltip beside cursor instead of over data point (#51904) 2026-05-13 15:47:15 +02:00
Aidan Timson 2ee7c6fc2a Add context to statistics panel (#52003)
* Add context to statistics panel

* Lazy context

* Cleanup

* Types

* Use api context, use registries, update helpers to only need api

* Infer type

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

* Remove —

* Format

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-13 15:38:56 +03:00
Wendelin 7d069c4f5e Fix automation row event chip styles (#52022) 2026-05-13 13:08:16 +03:00
Aidan Timson 20bf8181dd Add context to states panel (#52007)
* Add device and area to states panel. Use lazy context

* Format

* Add filters for devices and areas

* Format

* Registries, api context
2026-05-13 13:03:03 +03:00
Bram Kragten 1884a06f98 Create third party license file during production build, add CI (#30360)
* Generate third party license file during production build

* Add license check CI step

* Address review comments: use license-checker-rseidelsohn, add version validation for LICENSE_OVERRIDES

* Fix license-checker-rseidelsohn import (CJS module, use require)
2026-05-13 11:28:22 +02:00
Wendelin 0c63078923 Reactivate iOS focus element (#52020) 2026-05-13 09:53:12 +01:00
Wendelin c6ae47f1c8 Add automation live condition tests (#52004)
* add automation live condition

* Review

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-13 10:19:12 +02:00
renovate[bot] 0a9fe0e0c7 Update dependency lint-staged to v17.0.4 (#52014)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-13 06:50:36 +02:00
ildar170975 c3480bc319 hui-statistics-graph-card-editor: remove unneeded </div> (#52015)
remove div
2026-05-13 06:50:22 +02:00
Wendelin 8af5908682 Fix add T/C/A floor auto open; Target details adaptive dialog. (#52001)
* Auto open single floor

* Use adaptive dialog for target details

* review
2026-05-12 19:28:24 +03:00
George Caliment 60e95b886c Fixed how ha-entity-toggle sets ha-switch styles var (#51984) 2026-05-12 16:46:01 +02:00
Wendelin 0385ca8076 Add link to single integration entry warning (#51977)
* Add link to single integration entry warning

* Refactor single config entry warning: move function to dedicated file and update imports

* Implement single config entry warning dialog and update related functions

* Apply suggestions from code review

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

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-12 10:33:02 +00:00
Tom Carpenter 02c65fc8cb Position bars on statistics charts at centre of data point time range (#51957)
* Position statistics chart bars at centre of time range

When displaying 5minute or hourly data periods, position each bar at the midpoint of its start/end time. This mimics the behaviour in the various energy cards for consistency.

* Move limit comparison into pushData
Results in clearer function argument usage.

* Add time range for statistics-chart bar tooltip

When using hour/5minute periods the bars are recentred. Update the tooltips to show time range they cover.

* Omit time from tooltip for bars with periods of day or longer

Don't clutter the tooltip with unnecessary times of 0:00 when using day/month/year timescales on bar charts, just show the date range.

For week/month/year, we now also include the range of dates of the bar rather than just the start date.
2026-05-12 12:33:39 +03:00
Wendelin 49290d5c83 Add macOS version mapping for Safari 26 support (#51999) 2026-05-12 09:26:04 +02:00
Jan-Philipp Benecke 08aff3bfd7 Replace variable display in trace view with ha-code-editor (#51997)
* Replace variable display in trace view with ha-code-editor

* Replace variable display in trace view with ha-code-editor
2026-05-12 09:13:52 +03:00
Petar Petrov 455fa45b9c Show battery state of charge on the energy distribution card (#51812)
* Show battery state of charge on the energy distribution card

* css tweak

* Only show SOC-based battery icon when the period includes now
2026-05-12 08:38:04 +03:00
karwosts 2e56a4ec4c fix spurious timeline-chart exceptions (#51996) 2026-05-12 08:13:07 +03:00
Copilot 76131ff09e Hide standalone helpers and entities from the Home “Other devices” view (#51853)
* Initial plan

* Hide standalone helpers and entities from other devices view

Agent-Logs-Url: https://github.com/home-assistant/frontend/sessions/ecad0a9d-6983-4c5c-9728-6ef04be88e42

* Simplify other devices strategy test assertions

Agent-Logs-Url: https://github.com/home-assistant/frontend/sessions/ecad0a9d-6983-4c5c-9728-6ef04be88e42

* Clean up other devices strategy test helpers

Agent-Logs-Url: https://github.com/home-assistant/frontend/sessions/ecad0a9d-6983-4c5c-9728-6ef04be88e42

* Polish other devices strategy test fixtures

Agent-Logs-Url: https://github.com/home-assistant/frontend/sessions/ecad0a9d-6983-4c5c-9728-6ef04be88e42

* Remove other devices strategy test file

Agent-Logs-Url: https://github.com/home-assistant/frontend/sessions/2a54dac8-a7fc-42e5-a309-e0af02ca4303

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-11 20:27:00 +02:00
Wendelin 89d8723c5a Fix dialog expose entity in firefox (#51974)
* Migrate dialog-expose-entity to new dialog and migrate everything thats needed for this.

* Load virtualizer after dialog show is ready

* Use entities context instead of registries in ha-state-icon

* fix types

* Update src/panels/config/voice-assistants/dialog-expose-entity.ts

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2026-05-11 18:13:45 +00:00
renovate[bot] 7bdb63a6fe Update dependency terser-webpack-plugin to v5.6.0 (#51992)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 17:58:16 +00:00
Jan-Philipp Benecke eed79f1797 Use ha-tab-group for in automation/script trace page (#51991) 2026-05-11 19:50:10 +02:00
Joakim Plate 76665009da Let input entities date and number be active when unknown (#29306)
Let input of date and number be active when unknown
2026-05-11 17:54:40 +02:00
renovate[bot] 6d7d08fddc Update dependency lint-staged to v17.0.3 (#51985)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 17:52:39 +02:00
Alex van den Hoogen 77d4e6dc43 Fixes tile card misalignment (#25745) (#51964)
* Fixes tile card misalignment (#25745)

Removes an unnecessary vertical padding on the tile card content that causes a misalignment within the Android Companion app. This padding isn't needed because the contents are already vertically aligned with flexbox anyway.

* Added a min-height to tile container

As requested in the review, added a minimal height to the content of the
tile container to support non-section layouts.
2026-05-11 17:33:47 +02:00
Wendelin 7345256b30 Fix ha list ha sidebar (#51979)
* Fix ha-list in ha-sidebar

* Fix ha-row-item start/end slots
2026-05-11 16:34:37 +02:00
renovate[bot] e0d98e95fa Update dependency @lokalise/node-api to v16 (#51983)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 15:57:40 +03:00
renovate[bot] 17041044cf Update dependency @rsdoctor/rspack-plugin to v1.5.10 (#51982)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 15:14:55 +03:00
Marcin Bauer 9a10cd7fa8 Fix automation sidebar top padding (#51978)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 11:12:15 +01:00
Aidan Timson fa354aed2a Remove hass from dialog, bottom-sheet and callers (#51976)
* Remove hass prop from adaptive and bottom sheet

* Remove hass

* Remove hass prop from callers

* Prepare commented code for context

* Pass object

* Restore

* Restore

* Remove hass

* Remove hass
2026-05-11 12:01:11 +02:00
Wendelin c044d96712 Automation editor: Add click actions to row targets (#51909)
* Add click actions to automation row targets

* Review
2026-05-11 11:28:36 +03:00
439 changed files with 7759 additions and 3703 deletions
+2
View File
@@ -58,6 +58,8 @@ jobs:
run: yarn run lint:lit --quiet
- name: Run prettier
run: yarn run lint:prettier
- name: Check dependency licenses
run: yarn run lint:licenses
test:
name: Run tests
runs-on: ubuntu-latest
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
# ️ 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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
sync-labels: true
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
token: ${{ github.token }}
@@ -31,7 +31,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -2
View File
@@ -1,11 +1,16 @@
compressionLevel: mixed
approvedGitRepositories:
- "**"
npmMinimalAgeGate: "3d"
compressionLevel: mixed
defaultSemverRangePrefix: ""
enableGlobalCache: false
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.14.1.cjs
+7 -1
View File
@@ -5,6 +5,7 @@ import "./compress.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./licenses.js";
import "./locale-data.js";
import "./service-worker.js";
import "./translations.js";
@@ -36,7 +37,12 @@ gulp.task(
process.env.NODE_ENV = "production";
},
"clean",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
gulp.parallel(
"gen-icons-json",
"build-translations",
"build-locale-data",
"gen-licenses"
),
"copy-static-app",
"rspack-prod-app",
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
+2
View File
@@ -1,3 +1,4 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -25,6 +26,7 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [26, 0, 0],
};
const getCommonTemplateVars = () => {
+81
View File
@@ -0,0 +1,81 @@
// Gulp task to generate third-party license notices.
import { readFile, access } from "fs/promises";
import { generateLicenseFile } from "generate-license-file";
import gulp from "gulp";
import path from "path";
import paths from "../paths.cjs";
const OUTPUT_FILE = path.join(
paths.app_output_static,
"third-party-licenses.txt"
);
// The echarts package ships an Apache-2.0 NOTICE file that must be
// redistributed alongside the compiled output per Apache License §4(d).
const NOTICE_FILES = [
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
];
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
//
// Each entry is pinned to a specific version. If a package is updated,
// this list must be reviewed and the version updated after verifying
// that the new version's license still matches. The build will fail
// if the installed version does not match the pinned version.
const LICENSE_OVERRIDES = [
{
// type-fest ships two license files (MIT for code, CC0 for types).
// We use the MIT license since that covers the bundled code.
packageName: "type-fest",
version: "5.6.0",
licensePath: path.resolve(
paths.root_dir,
"node_modules/type-fest/license-mit"
),
},
];
gulp.task("gen-licenses", async () => {
const licenseOverrides = {};
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
const pkgJsonPath = path.resolve(
paths.root_dir,
`node_modules/${packageName}/package.json`
);
let packageJSON;
try {
// eslint-disable-next-line no-await-in-loop
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
} catch {
throw new Error(
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
);
}
if (packageJSON.version !== version) {
throw new Error(
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
);
}
try {
// eslint-disable-next-line no-await-in-loop
await access(licensePath);
} catch {
throw new Error(`License file not found or unreadable: ${licensePath}`);
}
licenseOverrides[`${packageName}@${version}`] = licensePath;
}
await generateLicenseFile(
path.resolve(paths.root_dir, "package.json"),
OUTPUT_FILE,
{ append: NOTICE_FILES, replace: licenseOverrides }
);
});
-1
View File
@@ -1,4 +1,3 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
+25 -10
View File
@@ -1,4 +1,3 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
@@ -7,6 +6,8 @@ import { customElement, query, state } from "lit/decorators";
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
import "../../src/components/ha-button";
import "../../src/components/ha-drawer";
import type { HaDrawer } from "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-svg-icon";
@@ -39,8 +40,8 @@ class HaGallery extends LitElement {
@query("notification-manager")
private _notifications!: HTMLElementTagNameMap["notification-manager"];
@query("mwc-drawer")
private _drawer!: HTMLElementTagNameMap["mwc-drawer"];
@query("ha-drawer")
private _drawer!: HaDrawer;
private _narrow = window.matchMedia("(max-width: 600px)").matches;
@@ -75,15 +76,14 @@ class HaGallery extends LitElement {
}
return html`
<mwc-drawer
hasHeader
<ha-drawer
.direction=${this._rtl ? "rtl" : "ltr"}
.open=${!this._narrow}
.type=${this._narrow ? "modal" : "dismissible"}
>
<span slot="title">Home Assistant Design</span>
<!-- <span slot="subtitle">subtitle</span> -->
<div class="drawer-title">Home Assistant Design</div>
<div class="sidebar">${sidebar}</div>
<div slot="appContent">
<div slot="appContent" class="app-content">
<mwc-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@@ -144,7 +144,7 @@ class HaGallery extends LitElement {
</div>
</div>
</div>
</mwc-drawer>
</ha-drawer>
<notification-manager
.hass=${FAKE_HASS}
id="notifications"
@@ -226,12 +226,27 @@ class HaGallery extends LitElement {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
}
.sidebar {
box-sizing: border-box;
max-height: calc(100vh - 64px);
overflow-y: auto;
padding: 4px;
}
.drawer-title {
align-items: center;
box-sizing: border-box;
color: var(--primary-text-color);
display: flex;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
min-height: 64px;
padding: 0 16px;
}
.sidebar a {
color: var(--primary-text-color);
display: block;
@@ -255,7 +270,7 @@ class HaGallery extends LitElement {
opacity: 0.12;
}
div[slot="appContent"] {
.app-content {
display: flex;
flex-direction: column;
min-height: 100vh;
+29 -27
View File
@@ -14,6 +14,7 @@
"format:prettier": "prettier . --cache --write",
"lint:types": "tsc",
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
"lint:licenses": "node --no-deprecation script/check-licenses",
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
"format": "yarn run format:eslint && yarn run format:prettier",
"postinstall": "husky",
@@ -36,18 +37,18 @@
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.42.1",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.2",
"@formatjs/intl-displaynames": "7.3.5",
"@formatjs/intl-durationformat": "0.10.8",
"@formatjs/intl-getcanonicallocales": "3.2.6",
"@formatjs/intl-listformat": "8.3.5",
"@formatjs/intl-locale": "5.3.5",
"@formatjs/intl-numberformat": "9.3.5",
"@formatjs/intl-pluralrules": "6.3.5",
"@formatjs/intl-relativetimeformat": "12.3.5",
"@formatjs/intl-datetimeformat": "7.4.5",
"@formatjs/intl-displaynames": "7.3.7",
"@formatjs/intl-durationformat": "0.10.11",
"@formatjs/intl-getcanonicallocales": "3.2.8",
"@formatjs/intl-listformat": "8.3.7",
"@formatjs/intl-locale": "5.3.7",
"@formatjs/intl-numberformat": "9.3.8",
"@formatjs/intl-pluralrules": "6.3.7",
"@formatjs/intl-relativetimeformat": "12.3.7",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -63,7 +64,6 @@
"@lit/reactive-element": "2.1.2",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@material/mwc-drawer": "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/mwc-top-app-bar": "0.27.0",
@@ -75,8 +75,8 @@
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@tsparticles/engine": "4.0.0",
"@tsparticles/preset-links": "4.0.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -99,13 +99,13 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.4",
"intl-messageformat": "11.2.6",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "3.3.2",
"lit-html": "3.3.2",
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.3",
"memoize-one": "6.0.0",
@@ -137,12 +137,12 @@
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.60.0",
"@lokalise/node-api": "15.7.1",
"@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.9",
"@rspack/core": "2.0.2",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.3",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -161,7 +161,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.5",
"@vitest/coverage-v8": "4.1.6",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
@@ -170,12 +170,13 @@
"eslint-config-prettier": "10.1.8",
"eslint-import-resolver-webpack": "0.13.11",
"eslint-plugin-import-x": "4.16.2",
"eslint-plugin-lit": "2.2.1",
"eslint-plugin-lit": "2.3.1",
"eslint-plugin-lit-a11y": "5.1.1",
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"generate-license-file": "4.1.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -186,7 +187,8 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"lint-staged": "17.0.2",
"license-checker-rseidelsohn": "4.4.2",
"lint-staged": "17.0.4",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -197,19 +199,19 @@
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.15",
"terser-webpack-plugin": "5.5.0",
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.2",
"typescript-eslint": "8.59.3",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.5",
"vitest": "4.1.6",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
},
"resolutions": {
"lit": "3.3.2",
"lit-html": "3.3.2",
"lit": "3.3.3",
"lit-html": "3.3.3",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
+91
View File
@@ -0,0 +1,91 @@
#!/usr/bin/env node
// Checks that all production dependencies use approved open-source licenses.
//
// To allow a new license type, add its SPDX identifier to ALLOWED_LICENSES.
// To allow a specific package that cannot be relicensed (e.g. a dual-license
// package where the reported identifier is non-standard), add it to
// ALLOWED_PACKAGES with a comment explaining why.
import { createRequire } from "module";
import { fileURLToPath } from "url";
import path from "path";
const require = createRequire(import.meta.url);
const checker = require("license-checker-rseidelsohn");
const root = path.resolve(fileURLToPath(import.meta.url), "../../");
// Permissive licenses that are compatible with distribution in a compiled wheel.
// Copyleft licenses (GPL, LGPL, AGPL, EUPL, etc.) must NOT be added here.
const ALLOWED_LICENSES = new Set([
"MIT",
"MIT*",
"ISC",
"BSD-2-Clause",
"BSD-3-Clause",
"BSD*",
"Apache-2.0",
"0BSD",
"CC0-1.0",
"(MIT OR CC0-1.0)",
"(MIT AND Zlib)",
"Python-2.0", // argparse - Python Software Foundation License (permissive)
"Public Domain",
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
"Unlicense",
"CC-BY-4.0",
]);
// Packages whose license identifier is ambiguous or non-standard but have been
// manually verified as permissive. Add only when strictly necessary.
const ALLOWED_PACKAGES = {
// No entries currently needed.
};
checker.init(
{
start: root,
production: true,
excludePrivatePackages: true,
},
(err, packages) => {
if (err) {
console.error("license-checker failed:", err);
process.exit(1);
}
const violations = [];
for (const [nameAtVersion, info] of Object.entries(packages)) {
if (nameAtVersion in ALLOWED_PACKAGES) {
continue;
}
const license = info.licenses;
if (!ALLOWED_LICENSES.has(license)) {
violations.push({ package: nameAtVersion, license });
}
}
if (violations.length > 0) {
console.error(
"The following packages have licenses that are not on the allowlist:"
);
for (const { package: pkg, license } of violations) {
console.error(` ${pkg}: ${license}`);
}
console.error(`
If the license is permissive and appropriate for distribution, add it
to ALLOWED_LICENSES in script/check-licenses. If it is a specific
package with an ambiguous identifier, add it to ALLOWED_PACKAGES.
Do NOT add copyleft licenses (GPL, LGPL, AGPL, etc.) to the allowlist.`);
process.exit(1);
}
const count = Object.keys(packages).length;
console.log(
`License check passed: all ${count} production dependencies use approved licenses.`
);
}
);
+4 -3
View File
@@ -54,6 +54,8 @@ export class HaAuthFlow extends LitElement {
@query("ha-auth-form") private _form?: HaAuthForm;
@query("ha-form") private _haForm?: HTMLElement;
createRenderRoot() {
return this;
}
@@ -160,9 +162,8 @@ export class HaAuthFlow extends LitElement {
// 100ms to give all the form elements time to initialize.
setTimeout(() => {
const form = this.renderRoot.querySelector("ha-form");
if (form) {
(form as any).focus();
if (this._haForm) {
(this._haForm as any).focus();
}
}, 100);
}
-6
View File
@@ -5,7 +5,6 @@ import { isComponentLoaded } from "./is_component_loaded";
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
(isCore(page) || isLoadedIntegration(hass, page)) &&
!hideAdvancedPage(hass, page) &&
isNotLoadedIntegration(hass, page);
export const isLoadedIntegration = (
@@ -27,8 +26,3 @@ export const isNotLoadedIntegration = (
);
export const isCore = (page: PageNavigation) => page.core;
export const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
export const userWantsAdvanced = (hass: HomeAssistant) =>
hass.userData?.showAdvanced;
export const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
isAdvancedPage(page) && !userWantsAdvanced(hass);
@@ -0,0 +1,59 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type {
Condition,
ConditionContext,
} from "../../panels/lovelace/common/validate-condition";
import type { HomeAssistant } from "../../types";
import { setupConditionListeners } from "../condition/listeners";
/**
* Reactive controller that manages the media-query and time-based listeners
* needed to keep a set of lovelace visibility conditions evaluated live.
*
* The host is responsible for the actual evaluation (e.g. computing visible /
* hidden / invalid state); the controller only triggers it via the supplied
* `onUpdate` callback when something the conditions depend on changes. Call
* `setup()` whenever the conditions change; the controller clears previous
* listeners and re-subscribes. Listeners are automatically released when the
* host disconnects.
*/
export class ConditionListenersController implements ReactiveController {
private _unsubs: (() => void)[] = [];
constructor(host: ReactiveControllerHost) {
host.addController(this);
}
public hostDisconnected(): void {
this.clear();
}
public setup(
conditions: Condition[],
hass: HomeAssistant,
onUpdate: () => void,
getContext?: () => ConditionContext
): void {
this.clear();
if (!conditions.length) {
return;
}
setupConditionListeners(
conditions,
hass,
(unsub) => this._unsubs.push(unsub),
() => onUpdate(),
getContext
);
}
public clear(): void {
for (const unsub of this._unsubs) {
unsub();
}
this._unsubs = [];
}
}
+38
View File
@@ -1,3 +1,17 @@
import {
mdiBattery,
mdiBattery10,
mdiBattery20,
mdiBattery30,
mdiBattery40,
mdiBattery50,
mdiBattery60,
mdiBattery70,
mdiBattery80,
mdiBattery90,
mdiBatteryAlertVariantOutline,
mdiBatteryUnknown,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
const BATTERY_ICONS = {
@@ -12,6 +26,18 @@ const BATTERY_ICONS = {
90: "mdi:battery-90",
100: "mdi:battery",
};
const BATTERY_ICON_PATHS = {
10: mdiBattery10,
20: mdiBattery20,
30: mdiBattery30,
40: mdiBattery40,
50: mdiBattery50,
60: mdiBattery60,
70: mdiBattery70,
80: mdiBattery80,
90: mdiBattery90,
100: mdiBattery,
};
const BATTERY_CHARGING_ICONS = {
10: "mdi:battery-charging-10",
20: "mdi:battery-charging-20",
@@ -57,3 +83,15 @@ export const batteryLevelIcon = (
}
return BATTERY_ICONS[batteryRound];
};
export const batteryLevelIconPath = (batteryLevel: number | string): string => {
const batteryValue = Number(batteryLevel);
if (isNaN(batteryValue)) {
return mdiBatteryUnknown;
}
if (batteryValue <= 5) {
return mdiBatteryAlertVariantOutline;
}
const batteryRound = Math.round(batteryValue / 10) * 10;
return BATTERY_ICON_PATHS[batteryRound];
};
@@ -137,7 +137,10 @@ export const computeEntityPickerDisplay = (
hass.floors
);
const isRTL = computeRTL(hass);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
+10 -6
View File
@@ -1,16 +1,20 @@
import type { LitElement } from "lit";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, Translation } from "../../types";
export function computeRTL(hass: HomeAssistant) {
const lang = hass.language || "en";
if (hass.translationMetadata.translations[lang]) {
return hass.translationMetadata.translations[lang].isRTL || false;
export function computeRTL(
language = "en",
translations: Record<string, Translation>
) {
if (translations[language]) {
return translations[language].isRTL || false;
}
return false;
}
export function computeRTLDirection(hass: HomeAssistant) {
return emitRTLDirection(computeRTL(hass));
return emitRTLDirection(
computeRTL(hass.language, hass.translationMetadata.translations)
);
}
export function emitRTLDirection(rtl: boolean) {
@@ -121,6 +121,7 @@ export class HaAutomationRowEventChip extends LitElement {
align-items: center;
--mdc-icon-size: 16px;
line-height: 1;
box-shadow: var(--ha-box-shadow-s);
}
button {
@@ -0,0 +1,91 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
/**
* @element ha-automation-row-live-test
*
* @summary
* Small status indicator dot used in automation/condition rows to surface the
* live evaluation result. Renders an optional tooltip with details on hover.
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@property({ reflect: true }) public state: LiveTestState = "unknown";
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
static styles = css`
:host {
position: absolute;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 12px;
height: 12px;
border-radius: var(--ha-border-radius-circle);
border: 3px solid;
box-sizing: border-box;
background-color: var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
}
:host([state="pass"]) #indicator:hover {
background-color: var(--ha-color-fill-success-loud-hover);
border-color: var(--ha-color-fill-success-loud-hover);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-fill-warning-loud-resting);
}
:host([state="fail"]) #indicator:hover {
background-color: var(--ha-color-fill-warning-loud-hover);
border-color: var(--ha-color-fill-warning-loud-hover);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-fill-danger-loud-resting);
}
:host([state="invalid"]) #indicator:hover {
background-color: var(--ha-color-fill-danger-loud-hover);
border-color: var(--ha-color-fill-danger-loud-hover);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-fill-neutral-loud-resting);
}
:host([state="unknown"]) #indicator:hover {
background-color: var(--ha-color-fill-neutral-loud-hover);
border-color: var(--ha-color-fill-neutral-loud-hover);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-live-test": HaAutomationRowLiveTest;
}
}
@@ -124,6 +124,7 @@ export class HaAutomationRow extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
.row {
display: flex;
@@ -194,7 +195,6 @@ export class HaAutomationRow extends LitElement {
}
::slotted([slot="event"]) {
position: absolute;
top: 13px;
inset-inline-end: 0;
}
.icons {
@@ -0,0 +1,40 @@
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
export const TOOLTIP_GAP_PX = 12;
export const TOOLTIP_TOP_OFFSET_PX = 10;
/**
* Pins the tooltip near the top of the chart and offsets it horizontally
* from the cursor so it never covers the data point being inspected.
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
* with the displayed content.
*/
export const sideTooltipPosition: TooltipPositionCallback = (
point,
_params,
dom,
_rect,
size
) => {
const [cursorX] = point;
const [viewW, viewH] = size.viewSize;
const [tipW, tipH] = size.contentSize;
const rtl =
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
let x = rtl ? leftOfCursor : rightOfCursor;
const overflowsRight = x + tipW > viewW;
const overflowsLeft = x < 0;
if (overflowsRight || overflowsLeft) {
x = rtl ? rightOfCursor : leftOfCursor;
}
x = Math.max(0, Math.min(x, viewW - tipW));
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
return [x, y];
};
+4 -3
View File
@@ -19,7 +19,7 @@ import type {
} from "echarts/types/dist/shared";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
@@ -102,6 +102,8 @@ export class HaChartBase extends LitElement {
@state() private _hiddenDatasets = new Set<string>();
@query(".chart") private _chartContainer?: HTMLDivElement;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -469,7 +471,6 @@ export class HaChartBase extends LitElement {
private async _setupChart() {
if (this._loading) return;
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
this._loading = true;
try {
if (this.chart) {
@@ -484,7 +485,7 @@ export class HaChartBase extends LitElement {
const style = getComputedStyle(this);
echarts.registerTheme("custom", this._createTheme(style));
this.chart = echarts.init(container, "custom");
this.chart = echarts.init(this._chartContainer!, "custom");
this.chart.on("datazoom", (e: any) => {
this._handleDataZoomEvent(e);
});
@@ -11,6 +11,8 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -116,9 +118,7 @@ export class StateHistoryChartLine extends LitElement {
private _chartTime: Date = new Date();
private _previousYAxisLabelValue = 0;
private _yAxisMaximumFractionDigits = 0;
private _yAxisFractionDigits = 1;
protected render() {
return html`
@@ -293,7 +293,10 @@ export class StateHistoryChartLine extends LitElement {
(changedProps.has("hass") &&
this._hasEntityStatesChanged(changedProps.get("hass")))
) {
const rtl = computeRTL(this.hass);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
@@ -410,8 +413,7 @@ export class StateHistoryChartLine extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -433,6 +435,14 @@ export class StateHistoryChartLine extends LitElement {
const datasets: LineSeriesOption[] = [];
const entityIds: string[] = [];
const datasetToDataIndex: number[] = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
if (entityStates.length === 0) {
return;
}
@@ -468,6 +478,7 @@ export class StateHistoryChartLine extends LitElement {
d.data!.push([timestamp, prevValues[i]]);
}
d.data!.push([timestamp, datavalues[i]]);
trackY(datavalues[i]);
});
prevValues = datavalues;
};
@@ -818,6 +829,7 @@ export class StateHistoryChartLine extends LitElement {
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
@@ -825,6 +837,7 @@ export class StateHistoryChartLine extends LitElement {
Array.prototype.push.apply(datasets, data);
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = datasets;
this._entityIds = entityIds;
this._datasetToDataIndex = datasetToDataIndex;
@@ -858,20 +871,8 @@ export class StateHistoryChartLine extends LitElement {
}
private _formatYAxisLabel = (value: number) => {
// show the first significant digit for tiny values
const maximumFractionDigits = Math.max(
1,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
);
this._yAxisMaximumFractionDigits = Math.max(
this._yAxisMaximumFractionDigits,
maximumFractionDigits
);
const label = formatNumber(value, this.hass.locale, {
maximumFractionDigits: this._yAxisMaximumFractionDigits,
maximumFractionDigits: this._yAxisFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
@@ -881,7 +882,6 @@ export class StateHistoryChartLine extends LitElement {
chartIndex: this.chartIndex,
});
}
this._previousYAxisLabelValue = value;
return label;
};
@@ -14,6 +14,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
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 { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
@@ -144,7 +145,10 @@ export class StateHistoryChartTimeline extends LitElement {
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const markerLocalized = !computeRTL(this.hass)
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>`;
@@ -167,11 +171,12 @@ export class StateHistoryChartTimeline extends LitElement {
public willUpdate(changedProps: PropertyValues) {
if (
changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("data") ||
this._chartTime <
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES)
this.isConnected &&
(changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("data") ||
this._chartTime <
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES))
) {
// If the line is more than 5 minutes old, re-gen it
// so the X axis grows even if there is no new data
@@ -198,7 +203,10 @@ export class StateHistoryChartTimeline extends LitElement {
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(this.hass);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
this._chartOptions = {
xAxis: {
type: "time",
@@ -256,8 +264,7 @@ export class StateHistoryChartTimeline extends LitElement {
},
tooltip: {
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
+14 -11
View File
@@ -2,7 +2,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, eventOptions, property, state } from "lit/decorators";
import {
customElement,
eventOptions,
property,
queryAll,
state,
} from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@@ -104,6 +110,11 @@ export class StateHistoryCharts extends LitElement {
@state() private _hasZoomedCharts = false;
@queryAll("state-history-chart-line, state-history-chart-timeline")
private _chartComponents!: NodeListOf<
StateHistoryChartLine | StateHistoryChartTimeline
>;
private _isSyncing = false;
// @ts-ignore
@@ -327,11 +338,7 @@ export class StateHistoryCharts extends LitElement {
this._isSyncing = true;
requestAnimationFrame(() => {
const chartComponents = this.renderRoot.querySelectorAll(
"state-history-chart-line, state-history-chart-timeline"
) as unknown as (StateHistoryChartLine | StateHistoryChartTimeline)[];
chartComponents.forEach((chartComponent, index) => {
this._chartComponents.forEach((chartComponent, index) => {
if (index === sourceChartIndex) {
return;
}
@@ -350,11 +357,7 @@ export class StateHistoryCharts extends LitElement {
this._isSyncing = true;
requestAnimationFrame(() => {
const chartComponents = this.renderRoot.querySelectorAll(
"state-history-chart-line, state-history-chart-timeline"
);
chartComponents.forEach((chartComponent: any) => {
this._chartComponents.forEach((chartComponent: any) => {
const chartBase =
chartComponent.renderRoot?.querySelector("ha-chart-base");
+131 -49
View File
@@ -13,7 +13,9 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { formatDate } from "../../common/datetime/format_date";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
import {
formatNumber,
getNumberFormatOptions,
@@ -37,7 +39,9 @@ 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 { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -128,7 +132,7 @@ export class StatisticsChart extends LitElement {
private _computedStyle?: CSSStyleDeclaration;
private _previousYAxisLabelValue = 0;
private _yAxisFractionDigits = 1;
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return changedProps.size > 1 || !changedProps.has("hass");
@@ -140,7 +144,8 @@ export class StatisticsChart extends LitElement {
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats")
changedProps.has("_hiddenStats") ||
changedProps.has("names")
) {
this._generateData();
}
@@ -241,6 +246,8 @@ export class StatisticsChart extends LitElement {
private _renderTooltip = (params: any) => {
const rendered: Record<string, boolean> = {};
const chartIsBar = this.chartType.startsWith("bar");
const period = this.period;
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
@@ -252,8 +259,67 @@ export class StatisticsChart extends LitElement {
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
// max series can have 3 values, as the second value is the max-min to form a band
const rawValue = String(param.value[2] ?? param.value[1]);
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>";
}
} 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>`;
}
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
@@ -265,14 +331,7 @@ export class StatisticsChart extends LitElement {
options
)}${unit}`;
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
@@ -368,7 +427,12 @@ export class StatisticsChart extends LitElement {
nameTextStyle: {
align: "left",
},
position: computeRTL(this.hass) ? "right" : "left",
position: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? "right"
: "left",
scale:
this.chartType.startsWith("line") ||
this.logarithmicScale ||
@@ -398,8 +462,7 @@ export class StatisticsChart extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: "bottom",
align: "center",
position: sideTooltipPosition,
confine: true,
formatter: this._renderTooltip,
},
@@ -434,6 +497,14 @@ export class StatisticsChart extends LitElement {
const chartStacked = this.chartType.endsWith("stack");
const statisticsData = Object.entries(this.statisticsData);
const totalDataSets: typeof this._chartData = [];
let yMin = Infinity;
let yMax = -Infinity;
const trackY = (v: number | null | undefined) => {
if (typeof v === "number" && Number.isFinite(v)) {
if (v < yMin) yMin = v;
if (v > yMax) yMax = v;
}
};
const legendData: {
id: string;
name: string;
@@ -506,33 +577,57 @@ export class StatisticsChart extends LitElement {
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
const statLegendData: typeof legendData = [];
// Place bars at centre of their specified time range if this is a bar chart
// and the period is 5minute or hour.
const centerBars =
chartType === "bar" &&
(this.period === "5minute" || this.period === "hour");
const pushData = (
start: Date,
end: Date,
start: Date, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
if (start > end) {
// Limit for time range is lesser of overall limit and data point end
limit = end.getTime() < limit.getTime() ? end : limit;
if (start.getTime() > limit.getTime()) {
// Drop data points that are after the requested endTime. This could happen if
// endTime is "now" and client time is not in sync with server time.
return;
}
statDataSets.forEach((d, i) => {
if (
chartType === "line" &&
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
if (chartType === "line") {
if (
prevEndTime &&
prevValues &&
prevEndTime.getTime() !== start.getTime()
) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime, ...prevValues[i]!]);
d.data!.push([prevEndTime, null]);
}
d.data!.push([start, ...dataValues[i]!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValues[i][dataValues[i].length - 1]);
} else {
let time = start;
if (centerBars) {
// If centering bars, set the time to the midpoint between start and end instead
// of the start time.
time = new Date((start.getTime() + end.getTime()) / 2);
}
// Data value should always be a scalar for bar charts. Pass in
// real start time as extra value to allow formatting tooltip.
d.data!.push([time, dataValues[i][0]!, start, end]);
trackY(dataValues[i][0]);
}
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
prevEndTime = limit;
};
let color = colors[statistic_id];
@@ -692,11 +787,7 @@ export class StatisticsChart extends LitElement {
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(
startDate,
endDate.getTime() < endTime.getTime() ? endDate : endTime,
dataValues
);
pushData(startDate, endDate, endTime, dataValues);
}
});
@@ -745,6 +836,7 @@ export class StatisticsChart extends LitElement {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
@@ -778,6 +870,7 @@ export class StatisticsChart extends LitElement {
});
});
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
this._chartData = totalDataSets;
if (legendData.length !== this._legendData?.length) {
// only update the legend if it has changed or it will trigger options update
@@ -811,21 +904,10 @@ export class StatisticsChart extends LitElement {
return Math.abs(value) < 1 ? value : roundingFn(value);
}
private _formatYAxisLabel = (value: number) => {
// show the first significant digit for tiny values
const maximumFractionDigits = Math.max(
1,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
);
const label = formatNumber(value, this.hass.locale, {
maximumFractionDigits,
private _formatYAxisLabel = (value: number) =>
formatNumber(value, this.hass.locale, {
maximumFractionDigits: this._yAxisFractionDigits,
});
this._previousYAxisLabelValue = value;
return label;
};
static styles = css`
:host {
@@ -0,0 +1,9 @@
// Derive the number of decimal digits to use for Y-axis labels from the
// observed data range. We estimate the tick interval as `range / 10` (twice
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
// intervals), then derive `ceil(-log10(interval))`.
export function computeYAxisFractionDigits(min: number, max: number): number {
const range = max - min;
if (!Number.isFinite(range) || range <= 0) return 1;
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
}
@@ -127,7 +127,6 @@ export class DialogDataTableSettings extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${localize("ui.components.data-table.settings.header")}
@closed=${this._dialogClosed}
+185 -31
View File
@@ -1,7 +1,8 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { stringCompare } from "../../common/string/compare";
@@ -17,40 +18,163 @@ import "../ha-label";
class HaDataTableLabels extends LitElement {
@property({ attribute: false }) public labels!: LabelRegistryEntry[];
@state() private _visibleCount = 0;
@query(".viewport") private _viewport?: HTMLDivElement;
@query(".measure") private _measure?: HTMLDivElement;
private _sortedLabels: LabelRegistryEntry[] = [];
private _chipWidths: number[] = [];
private _plusWidth = 0;
private _gap = 8;
private _resizeController = new ResizeController(this, {
target: null,
skipInitial: true,
callback: (entries) => {
const entry = entries[0];
const width = entry?.contentRect.width ?? 0;
this._recomputeVisibleCount(width);
return width;
},
});
protected willUpdate(changedProps: Map<string, unknown>) {
if (changedProps.has("labels")) {
this._sortedLabels = [...this.labels].sort((a, b) =>
stringCompare(a.name, b.name)
);
}
}
protected render(): TemplateResult {
const labels = this.labels.sort((a, b) => stringCompare(a.name, b.name));
const labels = this._sortedLabels;
const visible = labels.slice(0, this._visibleCount);
const hidden = labels.length - this._visibleCount;
return html`
<ha-chip-set>
${repeat(
labels.slice(0, 2),
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${labels.length > 2
? html`<ha-dropdown
role="button"
tabindex="0"
@click=${stopPropagation}
@wa-select=${this._handleDropdownSelect}
>
<ha-label slot="trigger" class="plus" dense>
+${labels.length - 2}
</ha-label>
${repeat(
labels.slice(2),
(label) => label.label_id,
(label) => html`
<ha-dropdown-item .value=${label.label_id} .item=${label}>
${this._renderLabel(label, false)}
</ha-dropdown-item>
`
)}
</ha-dropdown>`
: nothing}
</ha-chip-set>
<div class="viewport">
<ha-chip-set>
${repeat(
visible,
(label) => label.label_id,
(label) => this._renderLabel(label, true)
)}
${hidden > 0
? html`
<ha-dropdown
role="button"
tabindex="0"
@click=${stopPropagation}
@wa-select=${this._handleDropdownSelect}
>
<ha-label slot="trigger" class="plus" dense>
+${hidden}
</ha-label>
${repeat(
labels.slice(this._visibleCount),
(label) => label.label_id,
(label) => html`
<ha-dropdown-item .value=${label.label_id} .item=${label}>
${this._renderLabel(label, false)}
</ha-dropdown-item>
`
)}
</ha-dropdown>
`
: nothing}
</ha-chip-set>
</div>
<div class="measure" aria-hidden="true">
<ha-chip-set>
${repeat(
labels,
(label) => label.label_id,
(label) => html`
<div class="measure-chip" data-chip>
${this._renderLabel(label, false)}
</div>
`
)}
<div class="measure-chip" data-plus>
<ha-label class="plus" dense>+99</ha-label>
</div>
</ha-chip-set>
</div>
`;
}
protected async firstUpdated() {
await this.updateComplete;
if (this._viewport) {
this._resizeController.observe(this._viewport);
}
await this._measureWidths();
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
}
protected async updated(changedProps: Map<string, unknown>) {
if (changedProps.has("labels")) {
await this.updateComplete;
await this._measureWidths();
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
}
}
private async _measureWidths() {
await this.updateComplete;
const measureRoot = this._measure;
if (!measureRoot) {
return;
}
const measureChipSet = measureRoot.querySelector("ha-chip-set");
if (measureChipSet) {
const styles = getComputedStyle(measureChipSet);
const raw = styles.columnGap || styles.gap;
this._gap = raw ? parseFloat(raw) : 0;
}
const chipEls = Array.from(
measureRoot.querySelectorAll<HTMLElement>("[data-chip]")
);
const plusEl = measureRoot.querySelector<HTMLElement>("[data-plus]");
this._chipWidths = chipEls.map((el) => el.offsetWidth);
this._plusWidth = plusEl?.offsetWidth ?? 0;
}
private _recomputeVisibleCount(containerWidth: number) {
if (!containerWidth || !this.labels?.length) {
this._visibleCount = 0;
return;
}
const total = this._sortedLabels.length;
let used = 0;
let visibleCount = 0;
for (let i = 0; i < total; i++) {
const chipWidth = this._chipWidths[i] ?? 0;
const nextUsed =
visibleCount === 0 ? chipWidth : used + this._gap + chipWidth;
const remaining = total - (i + 1);
const reserve = remaining > 0 ? this._gap + this._plusWidth : 0;
if (nextUsed + reserve <= containerWidth) {
used = nextUsed;
visibleCount++;
} else {
break;
}
}
this._visibleCount = visibleCount;
}
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
return html`
<ha-label
@@ -93,13 +217,43 @@ class HaDataTableLabels extends LitElement {
:host {
display: block;
flex-grow: 1;
min-width: 0;
margin-top: 4px;
height: 22px;
position: relative;
}
.viewport {
min-width: 0;
width: 100%;
overflow: hidden;
}
ha-chip-set {
position: fixed;
display: flex;
flex-wrap: nowrap;
align-items: center;
overflow: hidden;
min-width: 0;
}
.measure {
position: absolute;
inset: 0 auto auto 0;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
}
.measure ha-chip-set {
width: max-content;
overflow: visible;
}
.measure-chip {
display: inline-flex;
}
.plus {
--ha-label-background-color: transparent;
border: 1px solid var(--divider-color);
@@ -24,6 +24,7 @@ import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-textarea";
import type { HaTextArea } from "../ha-textarea";
import "./date-range-picker";
export type DateRangePickerRanges = Record<string, [Date, Date]>;
@@ -98,6 +99,8 @@ export class HaDateRangePicker extends LitElement {
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-textarea") private _textareaElement?: HaTextArea;
private _narrow = false;
private _unsubscribeTinyKeys?: () => void;
@@ -335,9 +338,8 @@ export class HaDateRangePicker extends LitElement {
};
private _setTextareaFocusStyle(focused: boolean) {
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
textarea.setFocused(focused);
if (this._textareaElement) {
this._textareaElement.setFocused(focused);
}
}
+11 -3
View File
@@ -22,6 +22,14 @@ const isOn = (stateObj?: HassEntity) =>
!STATES_OFF.includes(stateObj.state) &&
!isUnavailableState(stateObj.state);
/**
* @element ha-entity-toggle
*
* @cssprop --ha-entity-toggle-switch-width - Width of the switch track. Defaults to `38px`.
* @cssprop --ha-entity-toggle-switch-size - Height of the switch track. Defaults to `20px`.
* @cssprop --ha-entity-toggle-switch-thumb-size - Size of the switch thumb. Defaults to `14px`.
*/
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
// hass is not a property so that we only re-render on stateObj changes
@@ -165,9 +173,9 @@ export class HaEntityToggle extends LitElement {
white-space: nowrap;
}
ha-switch {
--ha-switch-width: 38px;
--ha-switch-size: 20px;
--ha-switch-thumb-size: 14px;
--ha-switch-width: var(--ha-entity-toggle-switch-width, 38px);
--ha-switch-size: var(--ha-entity-toggle-switch-size, 20px);
--ha-switch-thumb-size: var(--ha-entity-toggle-switch-thumb-size, 14px);
}
ha-icon-button {
--ha-icon-button-size: 40px;
@@ -130,7 +130,6 @@ export class HaStateLabelBadge extends LitElement {
? html`<ha-state-icon
.icon=${this.icon}
.stateObj=${entityState}
.hass=${this.hass}
></ha-state-icon>`
: ""}
${value && !image && !showIcon
+17 -4
View File
@@ -142,6 +142,7 @@ export class HaStatisticPicker extends LitElement {
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
this._picker?.requestUpdate();
this._valueRenderer = this._makeValueRenderer();
}
private _getItems = () =>
@@ -210,7 +211,10 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(hass);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const output: StatisticComboBoxItem[] = [];
@@ -314,7 +318,7 @@ export class HaStatisticPicker extends LitElement {
}
);
private _valueRenderer: PickerValueRenderer = (value) => {
private _renderValue(value: string) {
const statisticId = value;
const item = this._computeItem(statisticId);
@@ -338,7 +342,13 @@ export class HaStatisticPicker extends LitElement {
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
`;
};
}
private _makeValueRenderer(): PickerValueRenderer {
return (value) => this._renderValue(value);
}
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId];
@@ -353,7 +363,10 @@ export class HaStatisticPicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(this.hass);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const primary = entityName || deviceName || statisticId;
const secondary = [areaName, entityName ? deviceName : undefined]
-1
View File
@@ -98,7 +98,6 @@ export class StateBadge extends LitElement {
const domain = stateObj ? computeStateDomain(stateObj) : undefined;
return html`<ha-state-icon
.hass=${this.hass}
style=${styleMap(this._iconStyle)}
data-domain=${ifDefined(domain)}
data-state=${ifDefined(stateObj?.state)}
-5
View File
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { listenMediaQuery } from "../common/dom/media_query";
import { internationalizationContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-bottom-sheet";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -82,8 +81,6 @@ export const ADAPTIVE_DIALOG_MEDIA_QUERY =
*/
@customElement("ha-adaptive-dialog")
export class HaAdaptiveDialog extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@@ -202,7 +199,6 @@ export class HaAdaptiveDialog extends LitElement {
.ariaLabelledBy=${this._defaultAriaLabelledBy}
.ariaDescribedBy=${this.ariaDescribedBy}
.flexContent=${this.flexContent}
.hass=${this.hass}
.open=${this.open}
.preventScrimClose=${this.preventScrimClose}
>
@@ -221,7 +217,6 @@ export class HaAdaptiveDialog extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this.open}
.type=${this.type}
.width=${this.width}
+5 -3
View File
@@ -1,6 +1,6 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { ScrollLockMixin } from "../mixins/scroll-lock-mixin";
@@ -25,6 +25,8 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
@state() private _shouldRenderPopover = false;
@query("wa-popover") private _popoverElement?: HTMLElement;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
changedProperties.has("dialogAnchor") ||
@@ -188,7 +190,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
}
private _handlePopoverPointerDown(ev: PointerEvent) {
const popover = this.renderRoot.querySelector("wa-popover");
const popover = this._popoverElement;
const dialog = popover?.shadowRoot?.querySelector(
"dialog"
) as HTMLDialogElement | null;
@@ -215,7 +217,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
}
private _pulsePopover() {
const popover = this.renderRoot.querySelector("wa-popover");
const popover = this._popoverElement;
const popup = popover?.shadowRoot?.querySelector("wa-popup") as {
popup?: HTMLElement;
} | null;
+10 -12
View File
@@ -5,10 +5,10 @@ import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
import { haStyle } from "../resources/styles";
import "./ha-md-list-item";
import "./ha-switch";
import "./ha-tooltip";
import type { HaSwitch } from "./ha-switch";
import "./ha-tooltip";
import "./item/ha-row-item";
const ADDITIONAL_PREFERENCES = ["usage", "statistics"] as const;
@@ -33,7 +33,7 @@ export class HaAnalytics extends LitElement {
const baseEnabled = !loading && this.analytics!.preferences.base;
return html`
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
@@ -52,10 +52,10 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="base"
></ha-switch>
</ha-md-list-item>
</ha-row-item>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
@@ -81,10 +81,10 @@ export class HaAnalytics extends LitElement {
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</ha-tooltip>`}
</ha-md-list-item>
</ha-row-item>
`
)}
<ha-md-list-item>
<ha-row-item>
<span slot="headline"
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
@@ -103,7 +103,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="diagnostics"
></ha-switch>
</ha-md-list-item>
</ha-row-item>
`;
}
@@ -139,10 +139,8 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
ha-row-item {
--ha-row-item-padding-inline: 0;
}
`,
];
+4 -1
View File
@@ -9,6 +9,7 @@ import {
customElement,
property,
query,
queryAll,
state as litState,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -31,6 +32,8 @@ export class HaAnsiToHtml extends LitElement {
@query("pre") private _pre?: HTMLPreElement;
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
@litState() private _filter = "";
protected render(): TemplateResult {
@@ -320,7 +323,7 @@ export class HaAnsiToHtml extends LitElement {
*/
filterLines(filter: string): boolean {
this._filter = filter;
const lines = this.shadowRoot?.querySelectorAll("div") || [];
const lines = this._divs;
let numberOfFoundLines = 0;
if (!filter) {
lines.forEach((line) => {
+4 -2
View File
@@ -184,7 +184,10 @@ export class HaAreaControlsPicker extends LitElement {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(this.hass);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
@@ -261,7 +264,6 @@ export class HaAreaControlsPicker extends LitElement {
${item.type === "entity" && item.stateObj
? html`<ha-state-icon
slot="start"
.hass=${this.hass}
.stateObj=${item.stateObj}
></ha-state-icon>`
: item.domain
+5 -4
View File
@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -24,11 +24,12 @@ class HaBluePrintPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
@query("ha-select") private _select?: HTMLElement;
public open() {
const select = this.shadowRoot?.querySelector("ha-select");
if (select) {
if (this._select) {
// @ts-expect-error
select.menuOpen = true;
this._select.menuOpen = true;
}
}
+28 -22
View File
@@ -1,13 +1,15 @@
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import type WaDrawer from "@home-assistant/webawesome/dist/components/drawer/drawer";
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
import { configContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { isIosApp } from "../util/is_ios";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -47,8 +49,6 @@ const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
*/
@customElement("ha-bottom-sheet")
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: "aria-labelledby" })
public ariaLabelledBy?: string;
@@ -67,10 +67,16 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
@state() private _sliderInteractionActive = false;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@query("#drawer") private _drawer!: HTMLElement;
@query("#body") private _bodyElement!: HTMLDivElement;
@query("[autofocus]") private _autofocusElement?: HTMLElement;
protected get scrollableElement(): HTMLElement | null {
return this._bodyElement;
}
@@ -89,25 +95,25 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// if (this.hass && isIosApp(this.hass.auth.external)) {
// const element = this.renderRoot.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-bottom-sheet-autofocus";
// }
// this.hass.auth.external?.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
(
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
)?.focus();
const element = this._autofocusElement;
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
if (element) {
if (!element.id) {
element.id = "ha-bottom-sheet-autofocus";
}
this._hassConfig.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
element?.focus();
});
};
+99 -67
View File
@@ -27,6 +27,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, ReactiveElement, render } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { ContextType } from "@lit/context";
import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
@@ -43,7 +44,14 @@ import type {
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
import { labelsContext } from "../data/context";
import {
internationalizationContext,
registriesContext,
statesContext,
labelsContext,
configContext,
formattersContext,
} from "../data/context";
import type { LabelRegistryEntry } from "../data/label/label_registry";
import "./ha-code-editor-completion-items";
import type { CompletionItem } from "./ha-code-editor-completion-items";
@@ -78,8 +86,6 @@ export class HaCodeEditor extends ReactiveElement {
@property() public mode = "yaml";
public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -123,9 +129,29 @@ export class HaCodeEditor extends ReactiveElement {
@state() private _canCopy = false;
@consume({ context: labelsContext, subscribe: true })
@state()
private _labels?: LabelRegistryEntry[];
@consume({ context: configContext, subscribe: true })
private _config?: ContextType<typeof configContext>;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: labelsContext, subscribe: true })
private _labels?: ContextType<typeof labelsContext>;
@state()
@consume({ context: registriesContext, subscribe: true })
private _registries?: ContextType<typeof registriesContext>;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters?: ContextType<typeof formattersContext>;
@state()
@consume({ context: statesContext, subscribe: true })
private _states?: ContextType<typeof statesContext>;
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
@@ -162,6 +188,7 @@ export class HaCodeEditor extends ReactiveElement {
this.codemirror.state,
[this._loadedCodeMirror.tags.comment]
);
// eslint-disable-next-line lit/prefer-query-decorators
return !!this.renderRoot.querySelector(`span.${className}`);
}
@@ -189,9 +216,9 @@ export class HaCodeEditor extends ReactiveElement {
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this.hass?.localize("ui.components.yaml-editor.error") ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this.hass?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
}
this.codemirror.dispatch(
@@ -396,8 +423,8 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.haJinjaHoverSource(
view,
pos,
this.hass ? documentationUrl(this.hass, "") : undefined,
this.hass ? this._hassArgHoverContext() : undefined
this._config ? documentationUrl(this._config, "") : undefined,
this._hassArgHoverContext()
),
{ hoverTime: 300 }
),
@@ -408,7 +435,7 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.autocompleteEntities && this.hass) {
if (this.autocompleteEntities) {
completionSources.push(this._entityCompletions.bind(this));
}
if (this.autocompleteIcons) {
@@ -447,12 +474,12 @@ export class HaCodeEditor extends ReactiveElement {
private _fullscreenLabel(): string {
if (this._isFullscreen) {
return (
this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
this._i18n?.localize("ui.components.yaml-editor.exit_fullscreen") ||
"Exit fullscreen"
);
}
return (
this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
this._i18n?.localize("ui.components.yaml-editor.enter_fullscreen") ||
"Enter fullscreen"
);
}
@@ -507,7 +534,7 @@ export class HaCodeEditor extends ReactiveElement {
{
id: "test",
label:
this.hass?.localize(
this._i18n?.localize(
`ui.components.yaml-editor.test_${this.testing ? "off" : "on"}`
) || "Test",
path: this.testing ? mdiBugOutline : mdiBug,
@@ -518,14 +545,14 @@ export class HaCodeEditor extends ReactiveElement {
{
id: "undo",
disabled: !this._canUndo,
label: this.hass?.localize("ui.common.undo") || "Undo",
label: this._i18n?.localize("ui.common.undo") || "Undo",
path: mdiUndo,
action: (e: Event) => this._handleUndoClick(e),
},
{
id: "redo",
disabled: !this._canRedo,
label: this.hass?.localize("ui.common.redo") || "Redo",
label: this._i18n?.localize("ui.common.redo") || "Redo",
path: mdiRedo,
action: (e: Event) => this._handleRedoClick(e),
},
@@ -533,7 +560,7 @@ export class HaCodeEditor extends ReactiveElement {
id: "copy",
disabled: !this._canCopy,
label:
this.hass?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
this._i18n?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
"Copy to Clipboard",
path: mdiContentCopy,
action: (e: Event) => this._handleClipboardClick(e),
@@ -541,7 +568,7 @@ export class HaCodeEditor extends ReactiveElement {
{
id: "find-replace",
label:
this.hass?.localize("ui.components.yaml-editor.find_and_replace") ||
this._i18n?.localize("ui.components.yaml-editor.find_and_replace") ||
"Find and replace",
path: mdiFindReplace,
action: (e: Event) => this._handleFindReplaceClick(e),
@@ -583,7 +610,7 @@ export class HaCodeEditor extends ReactiveElement {
await copyToClipboard(this.value);
showToast(this, {
message:
this.hass?.localize("ui.common.copied_clipboard") ||
this._i18n?.localize("ui.common.copied_clipboard") ||
"Copied to clipboard",
});
}
@@ -651,12 +678,11 @@ export class HaCodeEditor extends ReactiveElement {
};
/**
* Builds a HassArgHoverContext from the current hass object so that
* Builds a HassArgHoverContext from the context objects so that
* haJinjaHoverSource can resolve entity / device / area friendly names
* without importing the full HomeAssistant type into the resource file.
*/
private _hassArgHoverContext(): HassArgHoverContext {
const hass = this.hass!;
const labelMap: Record<
string,
{ name: string; description?: string | null }
@@ -668,27 +694,33 @@ export class HaCodeEditor extends ReactiveElement {
};
}
return {
states: hass.states as HassArgHoverContext["states"],
devices: hass.devices as HassArgHoverContext["devices"],
areas: hass.areas as HassArgHoverContext["areas"],
floors: hass.floors as HassArgHoverContext["floors"],
entities: hass.entities as HassArgHoverContext["entities"],
states: this._states as HassArgHoverContext["states"],
devices: this._registries?.devices as HassArgHoverContext["devices"],
areas: this._registries?.areas as HassArgHoverContext["areas"],
floors: this._registries?.floors as HassArgHoverContext["floors"],
entities: this._registries?.entities as HassArgHoverContext["entities"],
labels: labelMap,
formatEntityState: (entityId) =>
hass.formatEntityState(hass.states[entityId]),
this._formatters!.formatEntityState(this._states![entityId]),
formatEntityName: (entityId) => {
const stateObj = hass.states[entityId];
const stateObj = this._states?.[entityId];
return (
(stateObj?.attributes.friendly_name as string | undefined) ??
hass.entities[entityId]?.name ??
this._registries?.entities?.[entityId]?.name ??
undefined
);
},
formatAttributeName: (entityId, attribute) =>
hass.formatEntityAttributeName(hass.states[entityId], attribute),
this._formatters!.formatEntityAttributeName(
this._states![entityId],
attribute
),
formatAttributeValue: (entityId, attribute) =>
hass.formatEntityAttributeValue(hass.states[entityId], attribute),
localize: (key) => hass.localize(key as never),
this._formatters!.formatEntityAttributeValue(
this._states![entityId],
attribute
),
localize: (key) => this._i18n!.localize(key as never),
};
}
@@ -698,49 +730,51 @@ export class HaCodeEditor extends ReactiveElement {
? completion.apply
: completion.label;
const context = getEntityContext(
this.hass!.states[key],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
this._states![key],
this._registries!.entities,
this._registries!.devices,
this._registries!.areas,
this._registries!.floors
);
const completionInfo = document.createElement("div");
completionInfo.classList.add("completion-info");
const formattedState = this.hass!.formatEntityState(this.hass!.states[key]);
const formattedState = this._formatters!.formatEntityState(
this._states![key]
);
const completionItems: CompletionItem[] = [
{
label: this.hass!.localize(
label: this._i18n!.localize(
"ui.components.entity.entity-state-picker.state"
),
value: formattedState,
subValue:
// If the state exactly matches the formatted state, don't show the raw state
this.hass!.states[key].state === formattedState
this._states![key].state === formattedState
? undefined
: this.hass!.states[key].state,
: this._states![key].state,
},
];
if (context.device && context.device.name) {
completionItems.push({
label: this.hass!.localize("ui.components.device-picker.device"),
label: this._i18n!.localize("ui.components.device-picker.device"),
value: context.device.name,
});
}
if (context.area && context.area.name) {
completionItems.push({
label: this.hass!.localize("ui.components.area-picker.area"),
label: this._i18n!.localize("ui.components.area-picker.area"),
value: context.area.name,
});
}
if (context.floor && context.floor.name) {
completionItems.push({
label: this.hass!.localize("ui.components.floor-picker.floor"),
label: this._i18n!.localize("ui.components.floor-picker.floor"),
value: context.floor.name,
});
}
@@ -761,15 +795,15 @@ export class HaCodeEditor extends ReactiveElement {
entityId: string,
attribute: string
): CompletionInfo | null => {
if (!this.hass) return null;
const stateObj = this.hass.states[entityId];
if (!this._states || !this._formatters) return null;
const stateObj = this._states[entityId];
if (!stateObj) return null;
const translatedName = this.hass.formatEntityAttributeName(
const translatedName = this._formatters.formatEntityAttributeName(
stateObj,
attribute
);
const formattedValue = this.hass.formatEntityAttributeValue(
const formattedValue = this._formatters.formatEntityAttributeValue(
stateObj,
attribute
);
@@ -809,9 +843,9 @@ export class HaCodeEditor extends ReactiveElement {
completion: Completion
): CompletionInfo | Promise<CompletionInfo> | null => {
if (
this.hass &&
this._states &&
typeof completion.apply === "string" &&
completion.apply in this.hass.states
completion.apply in this._states
) {
return this._renderInfo(completion);
}
@@ -1020,7 +1054,7 @@ export class HaCodeEditor extends ReactiveElement {
private _statesDotNotationCompletions(
context: CompletionContext
): CompletionResult | null | undefined {
if (!this.hass) return undefined;
if (!this._states) return undefined;
const { state: editorState, pos } = context;
const tree = this._loadedCodeMirror!.syntaxTree(editorState);
@@ -1129,9 +1163,7 @@ export class HaCodeEditor extends ReactiveElement {
case 0: {
// states. → offer all unique domains
const domains = [
...new Set(
Object.keys(this.hass.states).map((id) => id.split(".")[0])
),
...new Set(Object.keys(this._states).map((id) => id.split(".")[0])),
].sort();
return {
from: completionFrom,
@@ -1142,7 +1174,7 @@ export class HaCodeEditor extends ReactiveElement {
case 1: {
// states.<domain>. → offer entity object_ids for that domain
const [domain] = segments;
const entities = Object.keys(this.hass.states)
const entities = Object.keys(this._states)
.filter((id) => id.startsWith(`${domain}.`))
.map((id) => id.split(".").slice(1).join("."));
if (!entities.length) return { from: completionFrom, options: [] };
@@ -1172,7 +1204,7 @@ export class HaCodeEditor extends ReactiveElement {
}
// Offer attribute names from the entity's state object
const entityId = `${domain}.${entity}`;
const entityState = this.hass.states[entityId];
const entityState = this._states[entityId];
if (!entityState) return { from: completionFrom, options: [] };
const attrNames = Object.keys(entityState.attributes).sort();
return {
@@ -1342,8 +1374,8 @@ export class HaCodeEditor extends ReactiveElement {
): CompletionResult {
const from = stringNode.from + 1;
const empty: CompletionResult = { from, options: [] };
if (!entityId || !this.hass) return empty;
const entityState = this.hass.states[entityId];
if (!entityId || !this._states) return empty;
const entityState = this._states[entityId];
if (!entityState) return empty;
const attrs = Object.keys(entityState.attributes).sort();
if (!attrs.length) return empty;
@@ -1363,7 +1395,7 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
const states = this._getStates(this.hass!.states);
const states = this._getStates(this._states!);
if (!states?.length) return null;
// from is stringNode.from + 1 to skip the opening quote character.
const from = stringNode.from + 1;
@@ -1397,8 +1429,8 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
if (!this.hass?.devices) return null;
const devices = this._getDevices(this.hass.devices);
if (!this._registries?.devices) return null;
const devices = this._getDevices(this._registries.devices);
if (!devices.length) return null;
return {
from: stringNode.from + 1,
@@ -1426,8 +1458,8 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
if (!this.hass?.areas) return null;
const areas = this._getAreas(this.hass.areas);
if (!this._registries?.areas) return null;
const areas = this._getAreas(this._registries.areas);
if (!areas.length) return null;
return {
from: stringNode.from + 1,
@@ -1455,8 +1487,8 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
if (!this.hass?.floors) return null;
const floors = this._getFloors(this.hass.floors);
if (!this._registries?.floors) return null;
const floors = this._getFloors(this._registries.floors);
if (!floors.length) return null;
return {
from: stringNode.from + 1,
@@ -1556,7 +1588,7 @@ export class HaCodeEditor extends ReactiveElement {
// If cursor is after the entity field, show all entities
if (context.pos >= afterField) {
const states = this._getStates(this.hass!.states);
const states = this._getStates(this._states!);
if (!states || !states.length) {
return null;
@@ -1611,7 +1643,7 @@ export class HaCodeEditor extends ReactiveElement {
const afterListMarker = currentLine.from + listItemMatch[0].length;
if (context.pos >= afterListMarker) {
const states = this._getStates(this.hass!.states);
const states = this._getStates(this._states!);
if (!states || !states.length) {
return null;
@@ -1671,7 +1703,7 @@ export class HaCodeEditor extends ReactiveElement {
return null;
}
const states = this._getStates(this.hass!.states);
const states = this._getStates(this._states!);
if (!states || !states.length) {
return null;
+1
View File
@@ -54,6 +54,7 @@ export class HaControlSelect extends LitElement {
this._activeIndex = index;
this.requestUpdate();
this.updateComplete.then(() => {
// eslint-disable-next-line lit/prefer-query-decorators
const option = this.shadowRoot?.querySelector(
`#option-${this.options![index].value}`
) as HTMLElement;
+23 -21
View File
@@ -15,9 +15,10 @@ import { ifDefined } from "lit/directives/if-defined";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { fireEvent } from "../common/dom/fire_event";
import { withViewTransition } from "../common/util/view-transition";
import { internationalizationContext } from "../data/context";
import { configContext, internationalizationContext } from "../data/context";
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
import { haStyleScrollbar } from "../resources/styles";
import { isIosApp } from "../util/is_ios";
import "./ha-dialog-header";
import "./ha-icon-button";
@@ -127,10 +128,9 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: configContext, subscribe: true })
// private _hassConfig?: ContextType<typeof configContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state()
private _bodyScrolled = false;
@@ -221,22 +221,24 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
// disabled till iOS app fix the "focus_element" implementation
// if (this._hassConfig?.auth.external && isIosApp(this._hassConfig.auth.external)) {
// const element = this.querySelector("[autofocus]");
// if (element !== null) {
// if (!element.id) {
// element.id = "ha-dialog-autofocus";
// }
// this._hassConfig.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: element.id,
// },
// });
// }
// return;
// }
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
const element = this.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-dialog-autofocus";
}
this._hassConfig.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
};
+265 -125
View File
@@ -1,36 +1,115 @@
import { DrawerBase } from "@material/mwc-drawer/mwc-drawer-base";
import { styles } from "@material/mwc-drawer/mwc-drawer.css";
import type { PropertyValues } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import "@home-assistant/webawesome/dist/components/drawer/drawer";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { HASSDomEvent } from "../common/dom/fire_event";
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
declare global {
interface HASSDomEvents {
"hass-drawer-closed": undefined;
"hass-layout-transition": { active: boolean; reason?: string };
}
interface HTMLElementEventMap {
"hass-drawer-closed": HASSDomEvent<HASSDomEvents["hass-drawer-closed"]>;
"hass-layout-transition": HASSDomEvent<
HASSDomEvents["hass-layout-transition"]
>;
}
}
const blockingElements = (document as any).$blockingElements;
@customElement("ha-drawer")
export class HaDrawer extends DrawerBase {
@property() public direction: "ltr" | "rtl" = "ltr";
export class HaDrawer extends LitElement {
private static readonly _SWIPE_AXIS_TOLERANCE = 32;
private _mc?: HammerManager;
@property({ reflect: true }) public direction: "ltr" | "rtl" = "ltr";
private _rtlStyle?: HTMLElement;
@property({ reflect: true }) public type: "" | "dismissible" | "modal" = "";
@property({ type: Boolean, reflect: true }) public open = false;
@query("wa-drawer") private _modalDrawer?: HTMLElement;
@query(".sidebar-shell") private _sidebarShell?: HTMLElement;
private _sidebarTransitionActive = false;
private _transitionTarget?: HTMLElement;
private _gestureRecognizer = new SwipeGestureRecognizer({
velocitySwipeThreshold: 0.35,
});
private _touchStartY = 0;
private _touchDeltaY = 0;
private get _modal() {
return this.type === "modal";
}
protected render(): TemplateResult {
return this._modal
? html`
<slot name="appContent"></slot>
<wa-drawer
placement="start"
.open=${this.open}
light-dismiss
without-header
@touchstart=${this._handleTouchStart}
@wa-after-hide=${this._handleAfterHide}
>
<slot></slot>
</wa-drawer>
`
: html`
<div class="layout">
<div class="sidebar-shell">
<slot></slot>
</div>
<div class="app-content">
<slot name="appContent"></slot>
</div>
</div>
`;
}
protected updated(_: PropertyValues<this>) {
this._syncTransitionListeners();
if (!this.open) {
this._resetSwipeTracking();
}
}
protected firstUpdated() {
this._syncTransitionListeners();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._removeTransitionListeners();
this._unregisterSwipeHandlers();
}
private _handleAfterHide(ev: Event) {
ev.stopPropagation();
this.open = false;
fireEvent(this, "hass-drawer-closed");
}
private _closeModalDrawer() {
this.open = false;
}
private _handleDrawerTransitionStart = (ev: TransitionEvent) => {
if (ev.propertyName !== "width" || this._sidebarTransitionActive) {
if (
ev.propertyName !==
(this.type === "dismissible" ? "transform" : "width") ||
this._sidebarTransitionActive
) {
return;
}
this._sidebarTransitionActive = true;
@@ -41,7 +120,11 @@ export class HaDrawer extends DrawerBase {
};
private _handleDrawerTransitionEnd = (ev: TransitionEvent) => {
if (ev.propertyName !== "width" || !this._sidebarTransitionActive) {
if (
ev.propertyName !==
(this.type === "dismissible" ? "transform" : "width") ||
!this._sidebarTransitionActive
) {
return;
}
this._sidebarTransitionActive = false;
@@ -51,150 +134,207 @@ export class HaDrawer extends DrawerBase {
});
};
protected createAdapter() {
return {
...super.createAdapter(),
trapFocus: () => {
blockingElements.push(this);
this.appContent.inert = true;
document.body.style.overflow = "hidden";
},
releaseFocus: () => {
blockingElements.remove(this);
this.appContent.inert = false;
document.body.style.overflow = "";
},
};
private _handleTouchStart = (ev: TouchEvent) => {
if (!this._modal || !this.open) {
return;
}
const drawer = this._modalDrawer;
const dialog = drawer?.shadowRoot?.querySelector(
"dialog"
) as HTMLDialogElement | null;
if (!dialog) {
return;
}
const path = ev.composedPath();
if (!path.includes(dialog)) {
return;
}
ev.stopPropagation();
this._startSwipeTracking(ev.touches[0].clientX, ev.touches[0].clientY);
};
private _startSwipeTracking(clientX: number, clientY: number) {
document.addEventListener("touchmove", this._handleTouchMove, {
passive: true,
});
document.addEventListener("touchend", this._handleTouchEnd);
document.addEventListener("touchcancel", this._handleTouchEnd);
this._touchStartY = clientY;
this._touchDeltaY = 0;
this._gestureRecognizer.start(clientX);
}
protected updated(changedProps: PropertyValues<this>) {
super.updated(changedProps);
if (changedProps.has("direction")) {
this.mdcRoot.dir = this.direction;
if (this.direction === "rtl") {
this._rtlStyle = document.createElement("style");
this._rtlStyle.innerHTML = `
.mdc-drawer--animate {
transform: translateX(100%);
}
.mdc-drawer--opening {
transform: translateX(0);
}
.mdc-drawer--closing {
transform: translateX(100%);
}
`;
private _handleTouchMove = (ev: TouchEvent) => {
const currentX = ev.touches[0].clientX;
const currentY = ev.touches[0].clientY;
this._touchDeltaY = Math.abs(currentY - this._touchStartY);
this._gestureRecognizer.move(currentX);
};
this.shadowRoot!.appendChild(this._rtlStyle);
} else if (this._rtlStyle) {
this.shadowRoot!.removeChild(this._rtlStyle);
private _handleTouchEnd = () => {
this._unregisterSwipeHandlers();
const result = this._gestureRecognizer.end();
const isHorizontalGesture =
Math.abs(result.delta) >
this._touchDeltaY + HaDrawer._SWIPE_AXIS_TOLERANCE;
if (!isHorizontalGesture) {
this._resetSwipeTracking();
return;
}
const drawerDialog = this._modalDrawer?.shadowRoot?.querySelector(
'[part="dialog"]'
) as HTMLElement | null;
const drawerWidth = drawerDialog?.offsetWidth || 0;
if (result.isSwipe) {
const closeByVelocity =
this.direction === "rtl"
? result.isDownwardSwipe
: !result.isDownwardSwipe;
if (closeByVelocity) {
this._closeModalDrawer();
}
return;
}
if (changedProps.has("open") && this.open && this.type === "modal") {
this._setupSwipe();
} else if (this._mc) {
this._mc.destroy();
this._mc = undefined;
const closeByDistance =
drawerWidth > 0 &&
(this.direction === "rtl"
? result.delta > 0 && Math.abs(result.delta) > drawerWidth * 0.5
: result.delta < 0 && Math.abs(result.delta) > drawerWidth * 0.5);
if (closeByDistance) {
this._closeModalDrawer();
}
};
private _unregisterSwipeHandlers() {
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
}
protected firstUpdated() {
super.firstUpdated();
this.mdcRoot?.addEventListener(
private _resetSwipeTracking() {
this._unregisterSwipeHandlers();
this._gestureRecognizer.reset();
this._touchStartY = 0;
this._touchDeltaY = 0;
}
private _syncTransitionListeners() {
if (this._transitionTarget === this._sidebarShell) {
return;
}
this._removeTransitionListeners();
if (!this._sidebarShell) {
return;
}
this._transitionTarget = this._sidebarShell;
this._transitionTarget.addEventListener(
"transitionstart",
this._handleDrawerTransitionStart
);
this.mdcRoot?.addEventListener(
this._transitionTarget.addEventListener(
"transitionend",
this._handleDrawerTransitionEnd
);
this.mdcRoot?.addEventListener(
this._transitionTarget.addEventListener(
"transitioncancel",
this._handleDrawerTransitionEnd
);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.mdcRoot?.removeEventListener(
private _removeTransitionListeners() {
if (!this._transitionTarget) {
return;
}
this._transitionTarget.removeEventListener(
"transitionstart",
this._handleDrawerTransitionStart
);
this.mdcRoot?.removeEventListener(
this._transitionTarget.removeEventListener(
"transitionend",
this._handleDrawerTransitionEnd
);
this.mdcRoot?.removeEventListener(
this._transitionTarget.removeEventListener(
"transitioncancel",
this._handleDrawerTransitionEnd
);
this._transitionTarget = undefined;
}
private async _setupSwipe() {
const hammer = await import("../resources/hammer");
this._mc = new hammer.Manager(document, {
touchAction: "pan-y",
});
this._mc.add(
new hammer.Swipe({
direction:
this.direction === "rtl"
? hammer.DIRECTION_RIGHT
: hammer.DIRECTION_LEFT,
})
);
this._mc.on("swipeleft swiperight", () => {
fireEvent(this, "hass-toggle-menu", { open: false });
});
}
static styles = css`
:host {
display: block;
height: 100%;
}
static override styles = [
styles,
css`
.mdc-drawer {
position: fixed;
top: 0;
border-color: var(--divider-color, rgba(0, 0, 0, 0.12));
inset-inline-start: 0 !important;
inset-inline-end: initial !important;
transition-property: transform, width;
transition-duration:
var(--mdc-drawer-transition-duration, 0.2s),
var(--ha-animation-duration-normal);
transition-timing-function:
var(
--mdc-drawer-transition-timing-function,
cubic-bezier(0.4, 0, 0.2, 1)
),
ease;
}
.mdc-drawer.mdc-drawer--modal.mdc-drawer--open {
z-index: 200;
}
.mdc-drawer-app-content {
overflow: unset;
flex: none;
padding-left: var(--mdc-drawer-width);
padding-inline-start: var(--mdc-drawer-width);
padding-inline-end: initial;
direction: var(--direction);
width: 100%;
box-sizing: border-box;
transition:
padding-left var(--ha-animation-duration-normal) ease,
padding-inline-start var(--ha-animation-duration-normal) ease;
}
@media (prefers-reduced-motion: reduce) {
/* Use 1ms instead of "none" so the transitionend event still fires.
The MDC drawer foundation relies on it to complete the close cycle. */
.mdc-drawer,
.mdc-drawer-app-content {
transition: 1ms;
}
}
`,
];
.layout {
height: 100%;
}
.sidebar-shell {
position: fixed;
width: var(--ha-sidebar-width);
height: 100%;
border-inline-end: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
box-sizing: border-box;
transition: width var(--ha-animation-duration-normal) ease;
}
.app-content {
overflow: unset;
min-width: 0;
padding-inline-start: var(--ha-sidebar-width);
width: 100%;
height: 100%;
box-sizing: border-box;
transition:
padding-inline-start var(--ha-animation-duration-normal) ease,
width var(--ha-animation-duration-normal) ease;
}
:host([type="dismissible"]) .sidebar-shell {
transition: transform var(--ha-animation-duration-normal) ease;
}
:host([type="dismissible"]:not([open])) .sidebar-shell {
transform: translateX(-100%);
}
:host([type="dismissible"][direction="rtl"]:not([open])) .sidebar-shell {
transform: translateX(100%);
}
:host([type="dismissible"]:not([open])) .app-content {
padding-inline-start: 0;
}
wa-drawer {
--size: var(--ha-sidebar-width, 256px);
--show-duration: var(--ha-animation-duration-normal);
--hide-duration: var(--ha-animation-duration-normal);
}
wa-drawer::part(body) {
margin: 0;
padding: 0;
}
`;
}
declare global {
+6 -1
View File
@@ -39,7 +39,12 @@ export class HaEntitiesDisplayEditor extends LitElement {
const items: DisplayItem[] = entities.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
icon: entityIcon(this.hass, entity),
icon: entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
entity
),
}));
const value: DisplayValue = {
+4 -3
View File
@@ -2,7 +2,7 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { deepEqual } from "../common/util/deep-equal";
import type { Blueprints } from "../data/blueprint";
@@ -32,6 +32,8 @@ export class HaFilterBlueprints extends LitElement {
@state() private _blueprints?: Blueprints;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -96,8 +98,7 @@ export class HaFilterBlueprints extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
+4 -3
View File
@@ -10,7 +10,7 @@ import {
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { CategoryRegistryEntry } from "../data/category_registry";
@@ -49,6 +49,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@state() private _shouldRender = false;
@query("ha-list") private _list?: HTMLElement;
protected hassSubscribeRequiredHostProps = ["scope"];
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@@ -169,8 +171,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - (49 + 48)}px`;
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
+4 -3
View File
@@ -1,7 +1,7 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
@@ -34,6 +34,8 @@ export class HaFilterDevices extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -135,8 +137,7 @@ export class HaFilterDevices extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49 - 4 - 32}px`;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
+18 -16
View File
@@ -1,7 +1,8 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
@@ -31,6 +32,8 @@ export class HaFilterDomains extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
@@ -62,7 +65,7 @@ export class HaFilterDomains extends LitElement {
multi
>
${repeat(
this._domains(this.hass.states, this._filter),
this._domains(this.hass.states, this._filter, this.value),
(i) => i,
(domain) =>
html`<ha-check-list-item
@@ -84,7 +87,7 @@ export class HaFilterDomains extends LitElement {
`;
}
private _domains = memoizeOne((states, filter) => {
private _domains = memoizeOne((states, filter, _value) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
@@ -109,8 +112,7 @@ export class HaFilterDomains extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49 - 4 - 32}px`;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
@@ -126,19 +128,19 @@ export class HaFilterDomains extends LitElement {
this.expanded = ev.detail.expanded;
}
private _handleItemSelected(
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
) {
const domains = this._domains(this.hass.states, this._filter);
if (ev.detail.diff.added.length) {
this.value = [...(this.value || []), domains[ev.detail.diff.added[0]]];
} else if (ev.detail.diff.removed.length) {
const removedDomain = domains[ev.detail.diff.removed[0]];
this.value = this.value?.filter((value) => value !== removedDomain);
}
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const domains = this._domains(this.hass.states, this._filter, this.value);
const visibleDomains = new Set(domains);
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
const selected = [...ev.detail.index]
.map((i) => domains[i])
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value,
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
+5 -8
View File
@@ -1,7 +1,7 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
@@ -36,6 +36,8 @@ export class HaFilterEntities extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -102,8 +104,7 @@ export class HaFilterEntities extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49 - 4 - 32}px`;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
@@ -122,11 +123,7 @@ export class HaFilterEntities extends LitElement {
.selected=${this.value?.includes(entity.entity_id) ?? false}
graphic="icon"
>
<ha-state-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${entity}
></ha-state-icon>
<ha-state-icon slot="graphic" .stateObj=${entity}></ha-state-icon>
${computeStateName(entity)}
</ha-check-list-item>`;
+8 -4
View File
@@ -1,7 +1,7 @@
import { mdiFilterVariantRemove, mdiTextureBox } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
@@ -42,6 +42,8 @@ export class HaFilterFloorAreas extends LitElement {
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -137,7 +139,10 @@ export class HaFilterFloorAreas extends LitElement {
.selected=${this.value?.areas?.includes(area.area_id) || false}
.type=${"areas"}
class=${classMap({
rtl: computeRTL(this.hass),
rtl: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
),
floor: hasFloor,
})}
>
@@ -204,8 +209,7 @@ export class HaFilterFloorAreas extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
`${this.clientHeight - 49}px`;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
+14 -16
View File
@@ -1,7 +1,8 @@
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
@@ -34,6 +35,8 @@ export class HaFilterIntegrations extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
@@ -98,8 +101,7 @@ export class HaFilterIntegrations extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49 - 4 - 32}px`;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
@@ -147,9 +149,7 @@ export class HaFilterIntegrations extends LitElement {
)
);
private _itemSelected(
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
) {
private _itemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
const integrations = this._integrations(
this.hass.localize,
this._manifests!,
@@ -157,18 +157,16 @@ export class HaFilterIntegrations extends LitElement {
this.value
);
if (ev.detail.diff.added.length) {
this.value = [
...(this.value || []),
integrations[ev.detail.diff.added[0]].domain,
];
} else if (ev.detail.diff.removed.length) {
const removedDomain = integrations[ev.detail.diff.removed[0]].domain;
this.value = this.value?.filter((val) => val !== removedDomain);
}
const visibleDomains = new Set(integrations.map((i) => i.domain));
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
const selected = [...ev.detail.index]
.map((i) => integrations[i]?.domain)
.filter((d): d is string => !!d);
this.value = [...preserved, ...selected];
fireEvent(this, "data-table-filter-changed", {
value: this.value,
value: this.value.length ? this.value : undefined,
items: undefined,
});
}
+4 -3
View File
@@ -3,7 +3,7 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
@@ -41,6 +41,8 @@ export class HaFilterLabels extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
(labels: LabelRegistryEntry[], filter: string | undefined, _value) =>
@@ -137,8 +139,7 @@ export class HaFilterLabels extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - (49 + 48 + 32 + 4)}px`;
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
+4 -3
View File
@@ -2,7 +2,7 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
@@ -33,6 +33,8 @@ export class HaFilterVoiceAssistants extends LitElement {
@state() private _shouldRender = false;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
@@ -93,8 +95,7 @@ export class HaFilterVoiceAssistants extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
@@ -1,7 +1,7 @@
import { mdiPlus } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { stopPropagation } from "../../common/dom/stop_propagation";
import type { LocalizeFunc } from "../../common/translations/localize";
@@ -49,14 +49,15 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
@state() private _displayActions?: string[];
@query("ha-form") private _form?: HaForm;
public async focus() {
await this.updateComplete;
this.renderRoot.querySelector("ha-form")?.focus();
this._form?.focus();
}
public reportValidity(): boolean {
const form = this.renderRoot.querySelector<HaForm>("ha-form");
return form ? form.reportValidity() : true;
return this._form ? this._form.reportValidity() : true;
}
protected updated(changedProps: PropertyValues<this>): void {
+4 -2
View File
@@ -1,6 +1,6 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
@@ -83,8 +83,10 @@ export class HaForm extends LitElement implements HaFormElement {
delegatesFocus: true,
};
@query(".root") private _root?: HTMLElement;
public reportValidity(): boolean {
const root = this.renderRoot.querySelector(".root");
const root = this._root;
if (!root) {
return true;
}
+18 -14
View File
@@ -1,5 +1,6 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
@@ -13,8 +14,10 @@ import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
@@ -110,10 +113,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
@state() private _opened = false;
@@ -319,16 +321,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
// disabled till iOS app fix the "focus_element" implementation
// if (this.auth?.external && isIosApp(this.auth.external)) {
// this.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: "combo-box",
// },
// });
// return;
// }
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
this._hassConfig.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",
},
});
return;
}
this._comboBox?.focus();
});
@@ -314,6 +314,7 @@ export class HaItemDisplayEditor extends LitElement {
// refocus the item after the sort
setTimeout(async () => {
await this.updateComplete;
// eslint-disable-next-line lit/prefer-query-decorators
const selectedElement = this.shadowRoot?.querySelector(
`ha-md-list-item:nth-child(${this._dragIndex! + 1})`
) as HTMLElement | null;
-5
View File
@@ -166,7 +166,6 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${entity}
slot="graphic"
></ha-state-icon>
@@ -322,7 +321,6 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${group}
slot="graphic"
></ha-state-icon>
@@ -347,7 +345,6 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${scene}
slot="graphic"
></ha-state-icon>
@@ -400,7 +397,6 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${automation}
slot="graphic"
></ha-state-icon>
@@ -452,7 +448,6 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${script}
slot="graphic"
></ha-state-icon>
+6 -1
View File
@@ -63,7 +63,12 @@ export class HaSelectBox extends LitElement {
const selected = option.value === this.value;
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass ? computeRTL(this.hass) : false;
const isRTL = this.hass
? computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
: false;
const imageSrc =
typeof option.image === "object"
+10 -6
View File
@@ -36,7 +36,15 @@ export class HaIconSelector extends LitElement {
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj && until(entityIcon(this.hass, stateObj)));
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
return html`
<ha-icon-picker
@@ -51,11 +59,7 @@ export class HaIconSelector extends LitElement {
>
${!placeholder && stateObj
? html`
<ha-state-icon
slot="start"
.hass=${this.hass}
.stateObj=${stateObj}
></ha-state-icon>
<ha-state-icon slot="start" .stateObj=${stateObj}></ha-state-icon>
`
: nothing}
</ha-icon-picker>
@@ -188,7 +188,6 @@ export class HaObjectSelector extends LitElement {
}
return html`<ha-yaml-editor
.hass=${this.hass}
.readonly=${this.disabled}
.label=${this.label}
.required=${this.required}
@@ -101,7 +101,6 @@ export class HaTemplateSelector extends LitElement {
: nothing}
<ha-code-editor
mode="jinja2"
.hass=${this.hass}
.value=${this.value}
.readOnly=${this.disabled}
.placeholder=${this.placeholder || "{{ ... }}"}
+1 -8
View File
@@ -86,9 +86,6 @@ export class HaServiceControl extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "show-advanced", type: Boolean })
public showAdvanced = false;
@property({ attribute: "show-service-id", type: Boolean })
public showServiceId = false;
@@ -545,7 +542,6 @@ export class HaServiceControl extends LitElement {
: ""}
${shouldRenderServiceDataYaml
? html`<ha-yaml-editor
.hass=${this.hass}
.label=${this.hass.localize(
"ui.components.service-control.action_data"
)}
@@ -667,10 +663,7 @@ export class HaServiceControl extends LitElement {
? this.hass.services[domain][serviceName].description_placeholders
: undefined;
return dataField.selector &&
(!dataField.advanced ||
this.showAdvanced ||
(this._value?.data && this._value.data[dataField.key] !== undefined))
return dataField.selector
? html`<ha-settings-row .narrow=${this.narrow}>
${!showOptional
? hasOptional
+11 -16
View File
@@ -523,7 +523,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(this.hass);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isSelected = selectedPanel === "profile";
return html`
@@ -561,9 +564,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
id="sidebar-external-config"
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline"
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
>
<span class="item-text" slot="headline">
${this.hass.localize("ui.sidebar.external_app_configuration")}
</span>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
@@ -740,6 +743,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
border-radius: var(--ha-border-radius-sm);
--ha-row-item-min-height: var(--ha-space-10);
--ha-row-item-padding-block: 0;
--ha-row-item-padding-inline: var(--ha-space-3);
width: var(--ha-space-12);
position: relative;
transition: width var(--ha-animation-duration-normal) ease;
@@ -840,21 +844,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
ha-user-badge {
width: var(--ha-space-10);
height: var(--ha-space-10);
width: 40px;
height: 40px;
}
ha-list-item-button.user {
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
}
ha-list-item-button.user.rtl {
--ha-row-item-padding-inline: var(--ha-space-4) var(--ha-space-3);
}
ha-user-badge {
flex-shrink: 0;
margin-right: calc(var(--ha-space-2) * -1);
--ha-row-item-padding-inline: var(--ha-space-1) 0;
}
.spacer {
+32 -13
View File
@@ -1,31 +1,46 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
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 { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
entityIcon,
FALLBACK_DOMAIN_ICONS,
} from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-state-icon")
export class HaStateIcon extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public stateValue?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
protected render() {
const overrideIcon =
this.icon ||
(this.stateObj && this.hass?.entities[this.stateObj.entity_id]?.icon) ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
@@ -33,17 +48,21 @@ export class HaStateIcon extends LitElement {
if (!this.stateObj) {
return nothing;
}
if (!this.hass) {
if (!this._config || !this._connection || !this._entities) {
return this._renderFallback();
}
const icon = entityIcon(this.hass, this.stateObj, this.stateValue).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
);
return this._renderFallback();
});
return html`${until(icon)}`;
}
+7 -4
View File
@@ -2,7 +2,7 @@ import { mdiStarFourPoints } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { fireEvent } from "../common/dom/fire_event";
import type {
@@ -52,6 +52,10 @@ export class HaSuggestWithAIButton extends LitElement {
@state()
private _minWidth?: string;
@query("ha-assist-chip") private _chip?: HTMLElement & {
offsetWidth: number;
};
private _intervalId?: number;
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
@@ -109,9 +113,8 @@ export class HaSuggestWithAIButton extends LitElement {
}
// Capture current width before changing state
const chip = this.shadowRoot?.querySelector("ha-assist-chip");
if (chip) {
this._minWidth = `${chip.offsetWidth}px`;
if (this._chip) {
this._minWidth = `${this._chip.offsetWidth}px`;
}
// Reset to suggesting state
+5 -1
View File
@@ -486,6 +486,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
fireEvent(this, "value-changed", { value });
// eslint-disable-next-line lit/prefer-query-decorators
this.shadowRoot
?.querySelector(
`ha-target-picker-item-group[type='${this._newTarget?.type}']`
@@ -1136,7 +1137,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
rtl = computeRTL(this.hass);
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
}
+10 -7
View File
@@ -3,14 +3,16 @@ import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { ContextType } from "@lit/context";
import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-code-editor";
import type { HaCodeEditor } from "./ha-code-editor";
import { internationalizationContext } from "../data/context";
const isEmpty = (obj: Record<string, unknown>): boolean => {
if (typeof obj !== "object" || obj === null) {
@@ -26,8 +28,6 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
@customElement("ha-yaml-editor")
export class HaYamlEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value?: any;
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
@@ -59,6 +59,10 @@ export class HaYamlEditor extends LitElement {
@state() private _yaml = "";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@query("ha-code-editor") _codeEditor?: HaCodeEditor;
public setValue(value): void {
@@ -112,7 +116,6 @@ export class HaYamlEditor extends LitElement {
? html`<p>${this.label}${this.required ? " *" : ""}</p>`
: nothing}
<ha-code-editor
.hass=${this.hass}
.value=${this._yaml}
.readOnly=${this.readOnly}
.disableFullscreen=${this.disableFullscreen}
@@ -132,7 +135,7 @@ export class HaYamlEditor extends LitElement {
${this.copyClipboard
? html`
<ha-button appearance="plain" @click=${this._copyYaml}>
${this.hass.localize(
${this._i18n!.localize(
"ui.components.yaml-editor.copy_to_clipboard"
)}
</ha-button>
@@ -163,7 +166,7 @@ export class HaYamlEditor extends LitElement {
// Invalid YAML
isValid = false;
yamlError = err;
errorMsg = `${this.hass.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this.hass.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}
} else {
parsed = {};
@@ -201,7 +204,7 @@ export class HaYamlEditor extends LitElement {
if (this.yaml) {
await copyToClipboard(this.yaml);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
message: this._i18n!.localize("ui.common.copied_clipboard"),
});
}
}
+4 -5
View File
@@ -2,7 +2,7 @@ import { consume, type ContextType } from "@lit/context";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../../common/dom/fire_event";
import { internationalizationContext } from "../../data/context";
@@ -67,6 +67,8 @@ class HaInputMulti extends LitElement {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@query("ha-input[data-last]") private _lastInput?: HaInput;
protected render() {
return html`
<ha-sortable
@@ -163,10 +165,7 @@ class HaInputMulti extends LitElement {
const items = [...this._items, ""];
this._fireChanged(items);
await this.updateComplete;
const field = this.shadowRoot?.querySelector(`ha-input[data-last]`) as
| HaInput
| undefined;
field?.focus();
this._lastInput?.focus();
}
private async _editItem(ev: Event) {
+4 -2
View File
@@ -1,6 +1,6 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import "../ha-ripple";
import { HaListItemBase } from "./ha-list-item-base";
@@ -34,8 +34,10 @@ export class HaListItemButton extends HaListItemBase {
@property({ type: String }) public download?: string;
@query("#item") private _item?: HTMLElement;
public override activate(): void {
this.renderRoot.querySelector<HTMLElement>("#item")?.click();
this._item?.click();
}
protected _renderBase(inner: TemplateResult): TemplateResult {
+34 -17
View File
@@ -1,7 +1,7 @@
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
/**
* @element ha-row-item
@@ -46,13 +46,34 @@ export class HaRowItem extends LitElement {
protected readonly _slotController = new HasSlotController(
this,
"start",
"end",
"headline",
"supporting-text",
"content"
);
@state() private _hasStart = false;
@state() private _hasEnd = false;
private _onSlotChange(name: "start" | "end") {
return (ev: Event) => {
const slot = ev.target as HTMLSlotElement;
const hasContent = slot
.assignedNodes({ flatten: true })
.some(
(node) =>
node.nodeType === Node.ELEMENT_NODE ||
(node.nodeType === Node.TEXT_NODE &&
(node as Text).textContent?.trim() !== "")
);
if (name === "start") {
this._hasStart = hasContent;
} else {
this._hasEnd = hasContent;
}
};
}
protected render(): TemplateResult {
return this._renderBase(this._renderInner());
}
@@ -65,16 +86,16 @@ export class HaRowItem extends LitElement {
const hasContent = this._slotController.test("content");
return html`
<div part="start" class="start">
<slot name="start"></slot>
<div part="start" class="start" ?hidden=${!this._hasStart}>
<slot name="start" @slotchange=${this._onSlotChange("start")}></slot>
</div>
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
<div part="end" class="end">
<slot name="end"></slot>
<div part="end" class="end" ?hidden=${!this._hasEnd}>
<slot name="end" @slotchange=${this._onSlotChange("end")}></slot>
</div>
`;
}
@@ -109,10 +130,6 @@ export class HaRowItem extends LitElement {
color: var(--primary-text-color);
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
--ha-row-item-padding-block: var(--ha-space-3);
--ha-row-item-padding-inline: var(--ha-space-4);
--ha-row-item-gap: var(--ha-space-4);
--ha-row-item-min-height: 48px;
}
:host([disabled]) {
color: var(--disabled-text-color);
@@ -123,10 +140,10 @@ export class HaRowItem extends LitElement {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--ha-row-item-gap);
padding-block: var(--ha-row-item-padding-block);
padding-inline: var(--ha-row-item-padding-inline);
min-height: var(--ha-row-item-min-height);
gap: var(--ha-row-item-gap, var(--ha-space-4));
padding-block: var(--ha-row-item-padding-block, var(--ha-space-3));
padding-inline: var(--ha-row-item-padding-inline, var(--ha-space-4));
min-height: var(--ha-row-item-min-height, 48px);
box-sizing: border-box;
}
.content {
@@ -142,8 +159,8 @@ export class HaRowItem extends LitElement {
align-items: center;
flex: 0 0 auto;
}
:host(:not(:has([slot="start"]))) .start,
:host(:not(:has([slot="end"]))) .end {
.start[hidden],
.end[hidden] {
display: none;
}
.headline {
+2 -4
View File
@@ -292,14 +292,12 @@ export class HaListBase extends LitElement {
static styles: CSSResultGroup = css`
:host {
display: block;
--ha-list-gap: 0;
--ha-list-padding: 0;
}
.base {
display: flex;
flex-direction: column;
gap: var(--ha-list-gap);
padding: var(--ha-list-padding);
gap: var(--ha-list-gap, 0);
padding: var(--ha-list-padding, 0);
margin: 0;
list-style: none;
}
+3 -3
View File
@@ -121,15 +121,15 @@ export class HaListSelectable extends HaListBase {
public updateListItems() {
super.updateListItems();
this._syncItemSelectedState();
this._syncItemSelectedState(true);
}
private _sortedSelectedIndices(): number[] {
return [...this._selectedIndices!].sort((a, b) => a - b);
}
private _syncItemSelectedState() {
if (!this._selectedIndices) {
private _syncItemSelectedState(reset = false): void {
if (!this._selectedIndices || reset) {
this._selectedIndices = new Set<number>();
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
-1
View File
@@ -37,7 +37,6 @@ class HaEntityMarker extends LitElement {
></div>`
: this.showIcon && this.entityId
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${this.hass?.states[this.entityId]}
></ha-state-icon>`
: !this.entityUnit
+8 -6
View File
@@ -12,7 +12,7 @@ import type {
} from "leaflet";
import type { PropertyValues } from "lit";
import { css, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { formatDateTime } from "../../common/datetime/format_date_time";
import {
formatTimeWeekday,
@@ -105,6 +105,8 @@ export class HaMap extends ReactiveElement {
@state() private _loaded = false;
@query("#map") private _mapElement?: HTMLElement;
public leafletMap?: Map;
private Leaflet?: LeafletModuleType;
@@ -235,11 +237,11 @@ export class HaMap extends ReactiveElement {
}
private _updateMapStyle(): void {
const map = this.renderRoot.querySelector("#map");
map!.classList.toggle("clickable", this.clickable);
map!.classList.toggle("dark", this._darkMode);
map!.classList.toggle("forced-dark", this.themeMode === "dark");
map!.classList.toggle("forced-light", this.themeMode === "light");
const map = this._mapElement!;
map.classList.toggle("clickable", this.clickable);
map.classList.toggle("dark", this._darkMode);
map.classList.toggle("forced-dark", this.themeMode === "dark");
map.classList.toggle("forced-light", this.themeMode === "light");
}
private _loading = false;
@@ -76,12 +76,7 @@ class DialogJoinMediaPlayers extends LitElement {
const entityId = this._entityId;
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
@closed=${this._dialogClosed}
>
<ha-dialog .open=${this._open} flexcontent @closed=${this._dialogClosed}>
<ha-dialog-header show-border slot="header">
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
@@ -100,7 +100,6 @@ class DialogMediaManage extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
?prevent-scrim-close=${this._uploading || this._deleting}
@closed=${this._dialogClosed}
@@ -77,7 +77,6 @@ class DialogMediaPlayerBrowse extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
width="large"
flexcontent
@@ -59,7 +59,10 @@ class HaMediaPlayerToggle extends LitElement {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(this.hass);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
@@ -1,15 +1,29 @@
import { html, LitElement, nothing } from "lit";
import type { HassEntity } from "home-assistant-js-websocket";
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 { ensureArray } from "../../../common/array/ensure-array";
import { fireEvent } from "../../../common/dom/fire_event";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import type { TargetSelector } from "../../../data/selector";
import {
filterSelectorDevices,
filterSelectorEntities,
} from "../../../data/selector";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import "../../ha-dialog";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-adaptive-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../../list/ha-list-base";
import "../ha-target-picker-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@@ -21,6 +35,12 @@ class DialogTargetDetails extends LitElement implements HassDialog {
@state() private _opened = false;
@state() private _entitySources?: EntitySources;
@state() private _entitySourcesLoaded = false;
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
this._opened = true;
@@ -34,6 +54,72 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _dialogClosed() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._params = undefined;
this._entitySources = undefined;
this._entitySourcesLoaded = false;
}
private _hasIntegration(selector: TargetSelector) {
return (
(selector.target?.entity &&
ensureArray(selector.target.entity).some((e) => e.integration)) ||
(selector.target?.device &&
ensureArray(selector.target.device).some((d) => d.integration))
);
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!changedProperties.has("_params")) {
return;
}
if (
this._params?.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded
) {
this._loadEntitySources();
}
}
private async _loadEntitySources(): Promise<void> {
try {
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load entity sources for target details", err);
} finally {
this._entitySourcesLoaded = true;
}
}
private _filterEntities = (entity: HassEntity): boolean => {
const target = this._selectorTarget();
if (!target?.entity) {
return true;
}
return ensureArray(target.entity).some((e) =>
filterSelectorEntities(e, entity, this._entitySources)
);
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
const target = this._selectorTarget();
if (!target?.device) {
return true;
}
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
Object.values(this.hass.entities)
)
: undefined;
return ensureArray(target.device).some((d) =>
filterSelectorDevices(d, device, deviceIntegrations)
);
};
private _selectorTarget() {
return this._params?.selector?.target || null;
}
protected render() {
@@ -41,33 +127,86 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
let includeDeviceClasses: string[] | undefined;
let primaryEntitiesOnly: boolean | undefined;
if (this._params.selector) {
deviceFilter = this._filterDevices;
entityFilter = this._filterEntities;
primaryEntitiesOnly = this._params.selector.target?.primary_entities_only;
} else {
deviceFilter = this._params.deviceFilter;
entityFilter = this._params.entityFilter;
includeDomains = this._params.includeDomains;
includeDeviceClasses = this._params.includeDeviceClasses;
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded;
return html`
<ha-dialog
.hass=${this.hass}
<ha-adaptive-dialog
.open=${this._opened}
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
)}
header-subtitle=${`${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}`}
@closed=${this._dialogClosed}
>
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
.primaryEntitiesOnly=${this._params.primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
</ha-dialog>
<div class="type-wrapper">
<div class="type-label">
${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}
</div>
<ha-list-base
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
wrap-focus
>
${waitingForSources
? nothing
: html`
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${deviceFilter}
.entityFilter=${entityFilter}
.includeDomains=${includeDomains}
.includeDeviceClasses=${includeDeviceClasses}
.primaryEntitiesOnly=${primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
`}
</ha-list-base>
</div>
</ha-adaptive-dialog>
`;
}
static styles = css`
.type-wrapper {
display: flex;
flex-direction: column;
border-radius: var(--ha-border-radius-xl);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-normal);
overflow: hidden;
}
.type-label {
background-color: var(--ha-color-surface-low);
padding: var(--ha-space-1) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
display: flex;
align-items: center;
height: 20px;
}
`;
}
declare global {
@@ -1,14 +1,14 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { TargetSelector } from "../../../data/selector";
import type { TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
export type NewBackupType = "automatic" | "manual";
export interface TargetDetailsDialogParams {
title: string;
type: TargetType;
itemId: string;
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
@@ -5,7 +5,7 @@ import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import "../ha-expansion-panel";
import "../ha-md-list";
import "../list/ha-list-base";
import "./ha-target-picker-item-row";
@customElement("ha-target-picker-item-group")
@@ -66,23 +66,25 @@ export class HaTargetPickerItemGroup extends LitElement {
}
)}
</div>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
<ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-list-base>
</ha-expansion-panel>`;
}
@@ -96,7 +98,7 @@ export class HaTargetPickerItemGroup extends LitElement {
--expansion-panel-content-padding: 0;
}
ha-expansion-panel::part(summary) {
background-color: var(--ha-color-fill-neutral-quiet-resting);
background-color: var(--ha-color-surface-low);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
@@ -104,9 +106,6 @@ export class HaTargetPickerItemGroup extends LitElement {
justify-content: space-between;
min-height: unset;
}
ha-md-list {
padding: 0;
}
`;
}
@@ -1,14 +1,24 @@
import { consume } from "@lit/context";
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiMinusBox,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import {
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -38,18 +48,17 @@ import {
type ExtractFromTargetResultReferenced,
type TargetType,
} from "../../data/target";
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon-button";
import "../ha-md-list";
import type { HaMdList } from "../ha-md-list";
import "../ha-md-list-item";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-svg-icon";
import "../item/ha-list-item-base";
import "../item/ha-list-item-button";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
@customElement("ha-target-picker-item-row")
@@ -65,6 +74,9 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "sub-entry", reflect: true })
public subEntry = false;
@property({ attribute: false })
public subLevel = 0;
@property({ type: Boolean, attribute: "hide-context" })
public hideContext = false;
@@ -106,12 +118,6 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
@query("ha-md-list-item") public item?: HaMdListItem;
@query("ha-md-list") public list?: HaMdList;
@query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
@@ -137,101 +143,130 @@ export class HaTargetPickerItemRow extends LitElement {
const replaceable = !this.subEntry && !this.expand;
return html`
<ha-md-list-item
type=${replaceable ? "button" : "text"}
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable ? this._replaceItem : undefined}
>
<div class="icon" slot="start">
${this.subEntry
? html`
<div class="horizontal-line-wrapper">
<div class="horizontal-line"></div>
</div>
`
: nothing}
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
const content = html`
<div class="icon" slot="start">
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${stateObject && this.subEntry
? html`<span slot="supporting-text" class="state"
>${this.hass.formatEntityState(stateObject)}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
</span>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: this.subEntry && this.type === "entity"
? html`
<ha-icon-button
.path=${mdiClose}
<ha-svg-icon
.path=${computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? mdiChevronLeft
: mdiChevronRight}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
></ha-svg-icon>
`
: nothing}
</ha-md-list-item>
`;
let item: TemplateResult;
if (replaceable || (this.subEntry && this.type === "entity")) {
item = html`
<ha-list-item-button
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable
? this._replaceItem
: this.subEntry && this.type === "entity"
? this._openMoreInfo
: undefined}
>
${content}
</ha-list-item-button>
`;
} else {
item = html`
<ha-list-item-base
class=${classMap({
error: notFound,
})}
>
${content}
</ha-list-item-base>
`;
}
return html`
${item}
${this.expand && entries && entries.referenced_entities
? this._renderEntries()
: nothing}
@@ -241,6 +276,10 @@ export class HaTargetPickerItemRow extends LitElement {
private _renderEntries() {
const entries = this.parentEntries || this._entries;
if (!entries || entries.referenced_entities.length === 0) {
return this._renderEmptyEntries();
}
let nextType: TargetType =
this.type === "floor"
? "area"
@@ -350,54 +389,64 @@ export class HaTargetPickerItemRow extends LitElement {
) || ([] as string[]),
}));
const nextSubLevel = this.subLevel + 1;
return html`
<div class="entries-tree">
<div class="line-wrapper">
<div class="line"></div>
</div>
<ha-md-list class="entries">
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
</ha-md-list>
</div>
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
`;
}
private _renderEmptyEntries() {
return html`<ha-list-item-base>
<ha-svg-icon .path=${mdiMinusBox} slot="start" class="icon"></ha-svg-icon>
<span slot="headline"
>${this.hass.localize("ui.components.target-picker.no_targets")}</span
>
</ha-list-item-base>`;
}
private async _updateItemData() {
if (this.type === "entity") {
this._entries = undefined;
@@ -566,7 +615,14 @@ export class HaTargetPickerItemRow extends LitElement {
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(computeRTL(this.hass) ? " ◂ " : " ▸ ");
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
return {
name: entityName || deviceName || item,
context,
@@ -640,6 +696,12 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
private _openMoreInfo = () => {
showMoreInfoDialog(this, {
entityId: this.itemId,
});
};
static styles = [
buttonLinkStyle,
css`
@@ -651,12 +713,6 @@ export class HaTargetPickerItemRow extends LitElement {
--md-list-item-two-line-container-height: 56px;
}
:host([expand]:not([sub-entry])) ha-md-list-item {
border: 2px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-fill-neutral-quiet-resting);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.error {
background: var(--ha-color-fill-warning-quiet-resting);
}
@@ -680,6 +736,7 @@ export class HaTargetPickerItemRow extends LitElement {
.icon {
width: 24px;
display: flex;
color: var(--ha-color-on-neutral-normal);
}
img {
@@ -697,53 +754,21 @@ export class HaTargetPickerItemRow extends LitElement {
line-height: var(--ha-line-height-condensed);
}
:host([sub-entry]) .summary {
margin-right: var(--ha-space-12);
margin-inline-start: var(--ha-space-12);
}
.summary .main {
font-weight: var(--ha-font-weight-medium);
}
:host([expand]) .summary .main {
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
}
.summary .secondary {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.entries-tree {
display: flex;
position: relative;
}
.entries-tree .line-wrapper {
padding: var(--ha-space-5);
}
.entries-tree .line-wrapper .line {
border-left: 2px dashed var(--divider-color);
height: calc(100% - 28px);
position: absolute;
top: 0;
}
:host([sub-entry]) .entries-tree .line-wrapper .line {
height: calc(100% - 12px);
top: -18px;
}
.entries {
padding: 0;
--md-item-overflow: visible;
}
.horizontal-line-wrapper {
position: relative;
}
.horizontal-line-wrapper .horizontal-line {
position: absolute;
top: 11px;
margin-inline-start: -28px;
width: 29px;
border-top: 2px dashed var(--divider-color);
}
button.link {
text-decoration: none;
color: var(--primary-color);
@@ -754,12 +779,19 @@ export class HaTargetPickerItemRow extends LitElement {
text-decoration: underline;
}
.domain {
.state {
width: fit-content;
border-radius: var(--ha-border-radius-md);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1);
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-s);
color: var(--ha-color-text-secondary);
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
:host([sub-entry]) ha-list-item-button::part(base),
:host([sub-entry]) ha-list-item-base::part(base) {
padding-inline-start: var(--sub-entry-indent);
}
`,
];
@@ -28,8 +28,6 @@ import "../ha-domain-icon";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon";
import "../ha-icon-button";
import "../ha-md-list";
import "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-tooltip";
@@ -76,7 +74,6 @@ export class HaTargetPickerValueChip extends LitElement {
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
+2 -1
View File
@@ -99,7 +99,8 @@ export class HaTileContainer extends LitElement {
display: flex;
flex-direction: row;
align-items: center;
padding: 10px;
padding: 0 10px;
min-height: var(--row-height, 56px);
flex: 1;
min-width: 0;
box-sizing: border-box;
@@ -5,19 +5,15 @@ import { customElement, property } from "lit/decorators";
import "../ha-code-editor";
import "../ha-icon-button";
import type { TraceExtended } from "../../data/trace";
import type { HomeAssistant } from "../../types";
@customElement("ha-trace-blueprint-config")
export class HaTraceBlueprintConfig extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trace!: TraceExtended;
protected render(): TemplateResult {
return html`
<ha-code-editor
.value=${dump(this.trace.blueprint_inputs || "").trimRight()}
.hass=${this.hass}
read-only
dir="ltr"
></ha-code-editor>
-4
View File
@@ -5,19 +5,15 @@ import { customElement, property } from "lit/decorators";
import "../ha-code-editor";
import "../ha-icon-button";
import type { TraceExtended } from "../../data/trace";
import type { HomeAssistant } from "../../types";
@customElement("ha-trace-config")
export class HaTraceConfig extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public trace!: TraceExtended;
protected render(): TemplateResult {
return html`
<ha-code-editor
.value=${dump(this.trace.config).trimRight()}
.hass=${this.hass}
read-only
dir="ltr"
></ha-code-editor>
+26 -14
View File
@@ -3,7 +3,6 @@ import { dump } from "js-yaml";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
@@ -23,9 +22,10 @@ import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-code-editor";
import "../ha-icon-button";
import "../ha-tab-group";
import "../ha-tab-group-tab";
import "./hat-logbook-note";
import type { NodeInfo } from "./hat-script-graph";
import { traceTabStyles } from "./trace-tab-styles";
const TRACE_PATH_TABS = [
"step_config",
@@ -66,21 +66,21 @@ export class HaTracePathDetails extends LitElement {
${this._renderSelectedTraceInfo()}
</div>
<div class="tabs top">
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TRACE_PATH_TABS.map(
(view) => html`
<button
.view=${view}
class=${classMap({ active: this._view === view })}
@click=${this._showTab}
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</button>
</ha-tab-group-tab>
`
)}
</div>
</ha-tab-group>
${this._view === "step_config"
? this._renderSelectedConfig()
: this._view === "changed_variables"
@@ -271,7 +271,6 @@ export class HaTracePathDetails extends LitElement {
return config
? html`<ha-code-editor
.value=${dump(config).trimEnd()}
.hass=${this.hass}
read-only
dir="ltr"
></ha-code-editor>`
@@ -308,7 +307,11 @@ export class HaTracePathDetails extends LitElement {
? this.hass!.localize(
"ui.panel.config.automation.trace.path.no_variables_changed"
)
: html`<pre>${dump(trace.changed_variables).trimEnd()}</pre>`}
: html`<ha-code-editor
read-only
dir="ltr"
.value=${dump(trace.changed_variables).trimEnd()}
></ha-code-editor>`}
`
)}
</div>
@@ -383,13 +386,12 @@ export class HaTracePathDetails extends LitElement {
</div>`;
}
private _showTab(ev) {
this._view = ev.target.view;
private _handleTabChanged(ev: CustomEvent) {
this._view = ev.detail.name as typeof this._view;
}
static get styles(): CSSResultGroup {
return [
traceTabStyles,
css`
.padded-box {
margin: 16px;
@@ -406,6 +408,16 @@ export class HaTracePathDetails extends LitElement {
.error {
color: var(--error-color);
}
ha-tab-group {
background-color: var(--primary-background-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
ha-tab-group-tab::part(base) {
padding: 2px 16px;
}
`,
];
}
+8 -7
View File
@@ -20,7 +20,7 @@ import {
} from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import type { Condition, Trigger } from "../../data/automation";
@@ -73,6 +73,9 @@ export class HatScriptGraph extends LitElement {
@property({ attribute: false }) public selected?: string;
@query("hat-graph-node[active], hat-graph-branch[active]")
private _activeNode?: HTMLElement;
public hass!: HomeAssistant;
public renderedNodes: Record<string, NodeInfo> = {};
@@ -667,12 +670,10 @@ export class HatScriptGraph extends LitElement {
// Scroll to active node when selection changes
if (changedProps.has("selected")) {
const activeNode = this.renderRoot.querySelector(
"hat-graph-node[active], hat-graph-branch[active]"
) as HTMLElement;
if (activeNode) {
activeNode.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
this._activeNode?.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
if (!changedProps.has("trace")) {
-40
View File
@@ -1,40 +0,0 @@
import { css } from "lit";
export const traceTabStyles = css`
.tabs {
background-color: var(--primary-background-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
display: flex;
padding-left: 4px;
padding-inline-start: 4px;
padding-inline-end: initial;
}
.tabs.top {
border-top: none;
}
.tabs > * {
padding: 2px 16px;
cursor: pointer;
position: relative;
bottom: -1px;
border: none;
border-bottom: 2px solid transparent;
user-select: none;
background: none;
color: var(--primary-text-color);
outline: none;
transition: background 15ms linear;
}
.tabs > *.active {
border-bottom-color: var(--primary-color);
}
.tabs > *:focus,
.tabs > *:hover {
background: var(--secondary-background-color);
}
`;
+14
View File
@@ -1,4 +1,5 @@
import type {
Connection,
HassEntityAttributeBase,
HassEntityBase,
HassServiceTarget,
@@ -584,6 +585,19 @@ export const testCondition = (
variables,
});
export const subscribeCondition = (
connection: Connection,
onChange: (result: {
result?: boolean;
error?: string | { code: string; message: string };
}) => void,
condition: Condition
) =>
connection.subscribeMessage(onChange, {
type: "subscribe_condition",
condition,
});
export interface AutomationClipboard {
trigger?: Trigger;
condition?: Condition;
+9
View File
@@ -5,6 +5,7 @@ import type {
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantFormatters,
HomeAssistantInternationalization,
HomeAssistantRegistries,
HomeAssistantUI,
@@ -63,6 +64,14 @@ export const uiContext = createContext<HomeAssistantUI>("hassUi");
*/
export const configContext = createContext<HomeAssistantConfig>("hassConfig");
/**
* Entity formatting functions: `formatEntityState`, `formatEntityStateToParts`,
* `formatEntityAttributeValue`, `formatEntityAttributeValueToParts`,
* `formatEntityAttributeName`, and `formatEntityName`.
*/
export const formattersContext =
createContext<HomeAssistantFormatters>("hassFormatters");
/**
* Map of all entities in the entity registry, keyed by entity ID.
*/
+28
View File
@@ -3,6 +3,7 @@ import type {
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantFormatters,
HomeAssistantInternationalization,
HomeAssistantRegistries,
HomeAssistantUI,
@@ -156,6 +157,32 @@ const updateConfig = (
return value;
};
const updateFormatters = (
hass: HomeAssistant,
value?: HomeAssistantFormatters
): HomeAssistantFormatters => {
if (
!value ||
value.formatEntityState !== hass.formatEntityState ||
value.formatEntityStateToParts !== hass.formatEntityStateToParts ||
value.formatEntityAttributeValue !== hass.formatEntityAttributeValue ||
value.formatEntityAttributeValueToParts !==
hass.formatEntityAttributeValueToParts ||
value.formatEntityAttributeName !== hass.formatEntityAttributeName ||
value.formatEntityName !== hass.formatEntityName
) {
return {
formatEntityState: hass.formatEntityState,
formatEntityStateToParts: hass.formatEntityStateToParts,
formatEntityAttributeValue: hass.formatEntityAttributeValue,
formatEntityAttributeValueToParts: hass.formatEntityAttributeValueToParts,
formatEntityAttributeName: hass.formatEntityAttributeName,
formatEntityName: hass.formatEntityName,
};
}
return value;
};
export const updateHassGroups = {
registries: updateRegistries,
internationalization: updateInternationalization,
@@ -163,4 +190,5 @@ export const updateHassGroups = {
connection: updateConnection,
ui: updateUi,
config: updateConfig,
formatters: updateFormatters,
};
+5
View File
@@ -164,6 +164,7 @@ export interface BatterySourceTypeEnergyPreference {
stat_energy_to: string;
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
stat_soc?: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";
@@ -181,6 +182,8 @@ export interface GasSourceTypeEnergyPreference {
entity_energy_price: string | null;
number_energy_price: number | null;
unit_of_measurement?: string | null;
name?: string;
}
export interface WaterSourceTypeEnergyPreference {
@@ -199,6 +202,8 @@ export interface WaterSourceTypeEnergyPreference {
entity_energy_price: string | null;
number_energy_price: number | null;
unit_of_measurement?: string | null;
name?: string;
}
export type EnergySource =
+4 -1
View File
@@ -96,7 +96,10 @@ export const getEntities = (
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(hass);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
+26 -9
View File
@@ -456,11 +456,13 @@ const getIconFromTranslations = (
};
export const entityIcon = async (
hass: HomeAssistant,
entities: HomeAssistant["entities"],
hassConfig: HomeAssistant["config"],
hassConnection: Connection,
stateObj: HassEntity,
state?: string
) => {
const entry = hass.entities?.[stateObj.entity_id] as
const entry = entities?.[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
if (entry?.icon) {
@@ -468,7 +470,14 @@ export const entityIcon = async (
}
const domain = computeStateDomain(stateObj);
return getEntityIcon(hass, domain, stateObj, state, entry);
return getEntityIcon(
hassConfig,
hassConnection,
domain,
stateObj,
state,
entry
);
};
export const entryIcon = async (
@@ -480,11 +489,19 @@ export const entryIcon = async (
}
const stateObj = hass.states[entry.entity_id] as HassEntity | undefined;
const domain = computeDomain(entry.entity_id);
return getEntityIcon(hass, domain, stateObj, undefined, entry);
return getEntityIcon(
hass.config,
hass.connection,
domain,
stateObj,
undefined,
entry
);
};
const getEntityIcon = async (
hass: HomeAssistant,
hassConfig: HomeAssistant["config"],
hassConnection: Connection,
domain: string,
stateObj?: HassEntity,
stateValue?: string,
@@ -498,8 +515,8 @@ const getEntityIcon = async (
let icon: string | undefined;
if (translation_key && platform) {
const platformIcons = await getPlatformIcons(
hass.config,
hass.connection,
hassConfig,
hassConnection,
platform
);
if (platformIcons) {
@@ -515,8 +532,8 @@ const getEntityIcon = async (
if (!icon) {
const entityComponentIcons = await getComponentIcons(
hass.connection,
hass.config,
hassConnection,
hassConfig,
domain
);
if (entityComponentIcons) {

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