Compare commits

..

69 Commits

Author SHA1 Message Date
Bram Kragten 3abd355004 add floor_id and label_id to YAML hover tooltip selector type mapping 2026-05-13 20:24:12 +02:00
Bram Kragten 7b2a90b967 Add to developer tools action 2026-05-13 20:19:39 +02:00
Bram Kragten 1d633601f0 add built-in triggers and conditions, add floor and label 2026-05-13 20:15:54 +02:00
Bram Kragten 94148702e8 Add support for numeric threshold and behavior selectors 2026-05-13 18:16:40 +02:00
Bram Kragten e5548065ba Add field-aware YAML completions, hover tooltips, and linting to automation editor
- New files: yaml_field_schema.ts (types), yaml_ha_completions.ts (completion/hover/lint
  sources), ha_completion_items.ts (entity/device/area completion builders),
  ha-code-editor-yaml-hover.ts (key hover tooltip element),
  yaml_schema_helpers.ts (action/trigger/condition schema converters)
- ha-code-editor: wire schema completions, hover tooltips, and schema linter;
  replace setDiagnostics-based syntax error reporting with a linter() so syntax
  and schema diagnostics no longer overwrite each other; add forceLinting export
- ha-yaml-editor: suppress generic entity autocomplete when schema is present
- Action/trigger/condition editors: pass yamlFieldSchema to ha-yaml-editor;
  memoize on (services, localize) not full hass to avoid constant re-renders
- codemirror.ts: export forceLinting; mount-aware BlockMapping traversal in lint
  source to handle jinja({ base: yaml() }) tree structure (Template → Text with
  NodeProp.mounted subtree)
2026-05-13 17:44:20 +02: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
Michael Bisbjerg 1b736960b2 Add backup locations filter (#51970)
* Add backup locations filter

* Update src/panels/config/backup/ha-config-backup.ts

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-11 05:39:43 +00:00
George Caliment e8c8047ff9 Fixed blueprint rows event result chip render when collapsed (#51910) 2026-05-11 08:30:47 +03:00
karwosts 9376f4ce81 Fix sensor card when visibility changes (#51953)
* Fix sensor card when visibility changes

* History card

* map card

* trend graph

* minor change
2026-05-11 08:24:34 +03:00
renovate[bot] 7befa9782a Update dependency tar to v7.5.15 (#51969)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-11 08:10:15 +03:00
renovate[bot] 0186ec1265 Update dependency @codemirror/view to v6.42.1 (#51965)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 19:13:48 +02:00
Tom Carpenter 641c444fdc Fix demo instance mock recorder data generation (#51950)
Fix demo mock recorder data end times

The mock recorder was setting the start and end time for each of the samples to be the same value, causing the solar graph in the energy dashboard to render incorrectly.

Fix the recorder to set the end time of each sample to the start time of the next.
2026-05-10 10:18:44 +02:00
renovate[bot] 93dd2a5dc8 Update dependency fs-extra to v11.3.5 (#51956)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-10 10:00:13 +02:00
dependabot[bot] f7cf3d5b39 Bump github/codeql-action from 4.35.2 to 4.35.3 (#51959)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.35.2 to 4.35.3.
- [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/95e58e9a2cdfd71adc6e0353d5c52f41a045d225...e46ed2cbd01164d986452f91f178727624ae40d7)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.35.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-10 10:00:10 +02:00
dependabot[bot] b861543865 Bump release-drafter/release-drafter from 7.2.0 to 7.2.1 (#51960)
Bumps [release-drafter/release-drafter](https://github.com/release-drafter/release-drafter) from 7.2.0 to 7.2.1.
- [Release notes](https://github.com/release-drafter/release-drafter/releases)
- [Commits](https://github.com/release-drafter/release-drafter/compare/5de93583980a40bd78603b6dfdcda5b4df377b32...563bf132657a13ded0b01fcb723c5a58cdd824e2)

---
updated-dependencies:
- dependency-name: release-drafter/release-drafter
  dependency-version: 7.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-10 10:00:07 +02:00
renovate[bot] e749956eaa Update dependency @rspack/core to v2.0.2 (#51955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 19:23:29 +02:00
renovate[bot] 5b0f0dade5 Update dependency lint-staged to v17.0.2 (#51952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 15:06:17 +02:00
renovate[bot] f86d2753f7 Update dependency lint-staged to v17 (#51949)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 10:31:16 +00:00
renovate[bot] f3f549737f Update CodeMirror (#51948)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 10:31:01 +00:00
Petar Petrov d9929905b5 Localize trigger description in trace timeline (#51927) 2026-05-09 12:23:46 +02:00
renovate[bot] 25487e373e Update dependency sinon to v22 (#51945)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 09:20:09 +03:00
karwosts 2ff56d3eb7 Fix heading badge current-entity visibility (#51942) 2026-05-09 09:13:58 +03:00
renovate[bot] 6c4f7506b5 Update dependency tar to v7.5.14 (#51944)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-09 09:07:54 +03:00
karwosts 5755aebff6 Fix create new person with login (#51939) 2026-05-08 20:39:45 +02:00
dependabot[bot] 76996ea3cc Bump fast-uri from 3.1.0 to 3.1.2 (#51938)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 18:17:23 +00:00
renovate[bot] d7d6766f80 Update dependency @babel/preset-env to v7.29.5 (#51935)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 20:11:42 +02:00
dependabot[bot] b632e8e6f8 Bump flatted from 3.4.1 to 3.4.2 (#51937)
Bumps [flatted](https://github.com/WebReflection/flatted) from 3.4.1 to 3.4.2.
- [Commits](https://github.com/WebReflection/flatted/compare/v3.4.1...v3.4.2)

---
updated-dependencies:
- dependency-name: flatted
  dependency-version: 3.4.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 18:09:10 +00:00
Tom Carpenter ee4eaaa613 Remove extra padding to right of ha-switch (#51932)
Fix empty padding to right of ha-switch

When the label slot for the ha-switch is empty, the initial margin is still present which causes an odd misalignment on the switches in e,g, the entities card.

To fix this, if the label slot is empty, hide the label to remove the unwanted margin.
2026-05-08 20:08:12 +02:00
renovate[bot] 395faebd0c Update formatjs monorepo (#51936)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 18:07:56 +00:00
Petar Petrov 71b8676e02 Treat unregistered entities as having no entity_category (#51925) 2026-05-08 20:06:31 +02:00
Petar Petrov d54516dd42 Show external access as disabled for local-only users (#51931) 2026-05-08 19:58:49 +02:00
Aidan Timson 1a3eef9c4f Refactor config flow dialog (#51924)
* Move buttons to standard footers

* Fix negative margin, use space tokens

* Space tokens with tweaks

* Hide form if empty

* Standardise padding

* Only show skip if no devices are assigned

* Use ref instead of queries

* nothing

* Token

* Typing
2026-05-08 17:56:21 +03:00
Aidan Timson 1f2f9e6330 Filter all data points for integration page (#51923)
* Filter all datapoints for integration page (discovery, attention flows)

* Use multitermsearch

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

* Split

* memoise flows

* memoise entries too

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-05-08 17:33:22 +03:00
Petar Petrov 1774219f9a Clamp power sources graph usage line to non-negative (#51902) 2026-05-08 13:36:07 +02:00
Marcin Bauer ac66ad1a32 Improve continue on error tooltip in automation editor (#51926)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-08 13:34:34 +02:00
Wendelin 7bb51c746d Allow ha-list-items within sub components shadow DOM (#51907)
* Allow ha-list-items within sub components shadow dom.

* fix sort

* Fix start end slots
2026-05-08 09:24:13 +03:00
Tom Carpenter 13e32c41e0 Round bar chart end time to half-hour mark for hourly periods (#51916)
* Don't round bar chart end time for hourly periods

If we do this, it causes the last hour of the energy dashboard bar charts to be cut off. This went unnoticed previously because they were placed at times of xx:00, while now they are times of xx:30.

* Round to 30minute for hourly bars rather than leaving unrounded
This better matches the axes with line charts by cutting off padding into the next day, whilst leaving mid-point bars visible.

* Update tests to account for new behaviour
2026-05-08 08:51:27 +03:00
Tom Carpenter d89af52e3b Fix type exception in ha-chart-base _updateSankeyRoam() (#51917)
Fix type exception in chart _updateSankeyRoam

When there is no data for some series in the sankey chart, then the series map can contain null entries. This raised an exception as the _updateSankeyRoam tried to access the 'type' property on a null value.

Add an explicit check for null. Using != not !== to also filter undefined in case that ever shows up.
2026-05-08 08:48:24 +03:00
Simon Lamon da6114fa5f Deduplicate workbox by updating patch (#51919)
Deduplicate by updating patch
2026-05-08 08:47:25 +03:00
renovate[bot] c144533834 Update workbox monorepo to v7.4.1 (#51918)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 04:54:50 +00:00
renovate[bot] e6c6ab93ef Update dependency typescript-eslint to v8.59.2 (#51914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:46:15 +02:00
renovate[bot] 62df56e5d9 Update dependency barcode-detector to v3.1.3 (#51913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:46:00 +02:00
358 changed files with 8224 additions and 7167 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# ️ 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
-276
View File
@@ -1,276 +0,0 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local, e2e-browserstack]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-7
View File
@@ -54,14 +54,7 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
+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
-53
View File
@@ -1,53 +0,0 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+1 -18
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -320,22 +321,4 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
-4
View File
@@ -1,13 +1,9 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,3 +1,4 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+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"),
-7
View File
@@ -45,10 +45,3 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
-41
View File
@@ -1,41 +0,0 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+2 -21
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 = () => {
@@ -266,24 +268,3 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
-20
View File
@@ -201,23 +201,3 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
-1
View File
@@ -4,7 +4,6 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
+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 }
);
});
-20
View File
@@ -14,7 +14,6 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -211,22 +210,3 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
-11
View File
@@ -50,15 +50,4 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+1 -6
View File
@@ -1,3 +1,4 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -337,11 +338,6 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -349,5 +345,4 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
-21
View File
@@ -1,21 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
-21
View File
@@ -1,21 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockCloud = (hass: MockHomeAssistant) => {
// REST mock for cloud status — returns disconnected so config panel loads
// without errors but without requiring cloud integration.
hass.mockAPI("cloud/status", () => ({
logged_in: false,
cloud: "disconnected",
prefs: {
google_enabled: false,
alexa_enabled: false,
cloudhooks: {},
remote_enabled: false,
},
google_registered: false,
alexa_registered: false,
remote_domain: null,
remote_connected: false,
remote_certificate: null,
}));
};
+25 -18
View File
@@ -1,6 +1,7 @@
import {
addDays,
addHours,
addMinutes,
addMonths,
differenceInHours,
endOfDay,
@@ -12,6 +13,19 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
return period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: period === "hour"
? addHours(currentDate, 1)
: addMinutes(currentDate, 5);
};
const generateMeanStatistics = (
start: Date,
end: Date,
@@ -25,9 +39,10 @@ const generateMeanStatistics = (
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
const mean = delta;
const nextDate = getNextDate(currentDate, period);
statistics.push({
start: currentDate.getTime(),
end: currentDate.getTime(),
end: nextDate.getTime(),
mean,
min: mean - Math.random() * maxDiff,
max: mean + Math.random() * maxDiff,
@@ -35,12 +50,7 @@ const generateMeanStatistics = (
state: mean,
sum: null,
});
currentDate =
period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: addHours(currentDate, 1);
currentDate = nextDate;
}
return statistics;
};
@@ -58,11 +68,12 @@ const generateSumStatistics = (
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const add = Math.random() * maxDiff;
sum += add;
statistics.push({
start: currentDate.getTime(),
end: currentDate.getTime(),
end: nextDate.getTime(),
mean: null,
min: null,
max: null,
@@ -71,12 +82,7 @@ const generateSumStatistics = (
state: initValue + sum,
sum,
});
currentDate =
period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: addHours(currentDate, 1);
currentDate = nextDate;
}
return statistics;
};
@@ -84,7 +90,7 @@ const generateSumStatistics = (
const generateCurvedStatistics = (
start: Date,
end: Date,
_period: "5minute" | "hour" | "day" | "month" = "hour",
period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number,
metered: boolean
@@ -98,11 +104,12 @@ const generateCurvedStatistics = (
let half = false;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const add = i * (Math.random() * maxDiff);
sum += add;
statistics.push({
start: currentDate.getTime(),
end: currentDate.getTime(),
end: nextDate.getTime(),
mean: null,
min: null,
max: null,
@@ -111,7 +118,7 @@ const generateCurvedStatistics = (
state: initValue + sum,
sum: metered ? sum : null,
});
currentDate = addHours(currentDate, 1);
currentDate = nextDate;
if (!half && i > hours / 2) {
half = true;
}
@@ -289,7 +296,7 @@ const statisticsFunctions: Record<
end,
period,
productionFinalVal,
2
0.2
);
return [...morning, ...production, ...evening, ...rest];
},
-5
View File
@@ -1,5 +0,0 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
-6
View File
@@ -228,12 +228,6 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+35 -43
View File
@@ -14,22 +14,14 @@
"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",
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -37,26 +29,26 @@
"dependencies": {
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.5",
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.41.1",
"@codemirror/view": "6.42.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.1",
"@formatjs/intl-displaynames": "7.3.4",
"@formatjs/intl-durationformat": "0.10.7",
"@formatjs/intl-getcanonicallocales": "3.2.5",
"@formatjs/intl-listformat": "8.3.4",
"@formatjs/intl-locale": "5.3.4",
"@formatjs/intl-numberformat": "9.3.4",
"@formatjs/intl-pluralrules": "6.3.4",
"@formatjs/intl-relativetimeformat": "12.3.4",
"@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",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
@@ -89,7 +81,7 @@
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.2",
"barcode-detector": "3.1.3",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -108,7 +100,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.3",
"intl-messageformat": "11.2.4",
"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",
@@ -130,29 +122,28 @@
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.3",
"@babel/preset-env": "7.29.5",
"@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",
"@playwright/test": "1.59.1",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.1",
"@rsdoctor/rspack-plugin": "1.5.10",
"@rspack/core": "2.0.2",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -175,7 +166,6 @@
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
"browserstack-node-sdk": "1.53.2",
"del": "8.0.1",
"eslint": "10.3.0",
"eslint-config-prettier": "10.1.8",
@@ -186,7 +176,8 @@
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.4",
"fs-extra": "11.3.5",
"generate-license-file": "4.1.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -197,7 +188,8 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"lint-staged": "16.4.0",
"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",
@@ -206,17 +198,17 @@
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "21.1.2",
"tar": "7.5.13",
"terser-webpack-plugin": "5.5.0",
"sinon": "22.0.0",
"tar": "7.5.15",
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.1",
"typescript-eslint": "8.59.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
},
"resolutions": {
"lit": "3.3.2",
+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.`
);
}
);
+54
View File
@@ -0,0 +1,54 @@
/**
* Walks up the composed tree (jumping shadow roots their hosts), returning
* the ancestor chain top-down. Used to compare two nodes that may live in
* different shadow trees `Node.compareDocumentPosition` only works within a
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
*/
const composedAncestorPath = (node: Node): Node[] => {
const path: Node[] = [];
let cur: Node | null = node;
while (cur) {
path.push(cur);
const parent = cur.parentNode;
if (parent instanceof ShadowRoot) {
cur = parent.host;
} else if (parent) {
cur = parent;
} else {
const root = cur.getRootNode();
cur = root instanceof ShadowRoot ? root.host : null;
}
}
return path.reverse();
};
/**
* Document-order comparator that works across shadow boundaries. Suitable as
* the `Array.prototype.sort` callback for collections of nodes that may live
* in different shadow trees.
*/
export const compareNodeOrder = (a: Node, b: Node): number => {
if (a === b) {
return 0;
}
const pa = composedAncestorPath(a);
const pb = composedAncestorPath(b);
let i = 0;
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
i++;
}
if (i === 0) {
return 0;
}
if (i === pa.length) {
return -1;
}
if (i === pb.length) {
return 1;
}
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
// eslint-disable-next-line no-bitwise
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
};
+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 =
-3
View File
@@ -117,9 +117,6 @@ export const generateEntityFilter = (
}
}
if (entityCategories) {
if (!entity) {
return false;
}
const category = entity?.entity_category || "none";
if (!entityCategories.has(category)) {
return false;
+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 {
@@ -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 {
+1 -1
View File
@@ -1110,7 +1110,7 @@ export class HaChartBase extends LitElement {
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
(s: any) => s != null && s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
@@ -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 =
@@ -144,7 +144,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 +170,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 +202,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",
+108 -31
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,
@@ -241,6 +243,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 +256,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 +328,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 +424,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 ||
@@ -506,33 +567,53 @@ 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]!]);
} 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]);
}
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = end;
prevEndTime = limit;
};
let color = colors[statistic_id];
@@ -692,11 +773,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);
}
});
@@ -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}
+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
+8 -2
View File
@@ -210,7 +210,10 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(hass);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const output: StatisticComboBoxItem[] = [];
@@ -353,7 +356,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}
+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
+25 -19
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,6 +67,10 @@ 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;
@@ -89,22 +93,24 @@ 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;
// }
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
const element = this.renderRoot.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-bottom-sheet-autofocus";
}
this._hassConfig.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: element.id,
},
});
}
return;
}
(
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
)?.focus();
+146
View File
@@ -0,0 +1,146 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
/**
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
* keys in the automation / script / card YAML editors.
*
* Shows:
* - Field name (monospace)
* - "required" badge when applicable
* - Description paragraph
* - Selector type hint
* - Example value
* - Default value
*/
@customElement("ha-code-editor-yaml-hover")
export class HaCodeEditorYamlHover extends LitElement {
@property({ attribute: false }) public fieldName = "";
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
/**
* Optional localize callback forwarded from the editor so translated
* descriptions can be rendered. When absent, strings are shown verbatim.
*/
@property({ attribute: false }) public localize?: (
key: string,
...args: unknown[]
) => string;
render() {
const schema = this.fieldSchema;
if (!schema) return nothing;
const description = schema.description
? (this.localize ? this.localize(schema.description) : "") ||
schema.description
: undefined;
const selectorType = schema.selector
? Object.keys(schema.selector)[0]
: undefined;
return html`
<div class="header">
<code class="key">${this.fieldName}</code>
${schema.required
? html`<span class="badge required">required</span>`
: nothing}
${selectorType
? html`<span class="badge type">${selectorType}</span>`
: nothing}
</div>
${description ? html`<div class="desc">${description}</div>` : nothing}
${schema.example != null
? html`<div class="meta">
<span class="meta-label">Example:</span>
<code>${String(schema.example)}</code>
</div>`
: nothing}
${schema.default != null
? html`<div class="meta">
<span class="meta-label">Default:</span>
<code>${String(schema.default)}</code>
</div>`
: nothing}
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 320px;
line-height: 1.5;
font-size: 0.9em;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
code.key {
font-family: var(--ha-font-family-code);
font-size: 1em;
font-weight: bold;
}
.badge {
display: inline-block;
border-radius: 4px;
padding: 0 5px;
font-size: 0.78em;
line-height: 1.6;
font-family: var(--ha-font-family-body);
}
.badge.required {
background: color-mix(
in srgb,
var(--error-color, #db4437) 15%,
transparent
);
color: var(--error-color, #db4437);
}
.badge.type {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-text-color);
}
.desc {
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.meta {
display: flex;
gap: 6px;
align-items: baseline;
color: var(--secondary-text-color);
}
.meta-label {
font-size: 0.85em;
opacity: 0.75;
flex-shrink: 0;
}
.meta code {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
}
}
+136 -94
View File
@@ -31,15 +31,21 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import {
buildEntityCompletions,
buildDeviceCompletions,
buildAreaCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "../resources/ha_completion_items";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import "./ha-code-editor-yaml-hover";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
@@ -80,6 +86,14 @@ export class HaCodeEditor extends ReactiveElement {
public hass?: HomeAssistant;
/**
* Optional field schema for YAML mode. When set, the editor will provide
* field-aware key/value completions, hover tooltips, and linting for the
* known fields described by this map.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -136,6 +150,12 @@ export class HaCodeEditor extends ReactiveElement {
private _completionInfoDestroy?: () => void;
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
private _yamlSyntaxError: {
mark?: { position: number; line: number; column: number };
reason?: string;
} | null = null;
private _completionInfoRequest = 0;
private _completionInfoKey?: string;
@@ -169,6 +189,10 @@ export class HaCodeEditor extends ReactiveElement {
* Push a YAML parse error (or null to clear) into the lint gutter as a
* diagnostic. Avoids re-parsing the document the caller (ha-yaml-editor)
* already has the error from its own js-yaml load() call.
*
* Stores the error and triggers forceLinting() so the yamlLintCompartment
* linter re-runs and returns it as a diagnostic rather than calling
* setDiagnostics() which would wipe diagnostics from other linters.
*/
public setYamlError(
err: {
@@ -176,27 +200,10 @@ export class HaCodeEditor extends ReactiveElement {
reason?: string;
} | null
): void {
if (!this.codemirror || !this._loadedCodeMirror) return;
let diagnostics: {
from: number;
to: number;
severity: "error";
message: string;
}[] = [];
if (err) {
const doc = this.codemirror.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this.hass?.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 })})` : ""}`;
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
this._yamlSyntaxError = err;
if (this.codemirror && this._loadedCodeMirror) {
this._loadedCodeMirror.forceLinting(this.codemirror);
}
this.codemirror.dispatch(
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
);
}
public connectedCallback() {
@@ -257,9 +264,7 @@ export class HaCodeEditor extends ReactiveElement {
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
],
});
@@ -271,20 +276,23 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
],
});
this._updateToolbarButtons();
}
if (changedProps.has("lint")) {
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
this._buildYamlSyntaxLinter()
),
});
}
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
this._buildSchemaLinter()
),
});
}
@@ -337,6 +345,60 @@ export class HaCodeEditor extends ReactiveElement {
return this._loadedCodeMirror!.langs[this.mode];
}
private _buildSchemaLinter() {
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
return [];
}
const schema = this.yamlFieldSchema;
return [
this._loadedCodeMirror.linter(
(view) => this._loadedCodeMirror!.haYamlLintSource(view, schema),
{ delay: 500 }
),
];
}
/**
* Builds the yamlLintCompartment extensions: a linter that surfaces the
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
* either syntax linting or schema linting is active.
*
* Using a linter() instead of setDiagnostics() means this linter's
* diagnostics are managed independently of the schema linter's diagnostics
* they don't overwrite each other.
*/
private _buildYamlSyntaxLinter() {
if (this.readOnly) return [];
const showGutter = this.lint || !!this.yamlFieldSchema;
const extensions: Extension[] = [];
if (showGutter) {
extensions.push(this._loadedCodeMirror!.lintGutter());
}
if (this.lint) {
extensions.push(
this._loadedCodeMirror!.linter(
(view) => {
const err = this._yamlSyntaxError;
if (!err) return [];
const doc = view.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this.hass?.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 })})` : ""}`;
return [
{ from: pos, to: line.to, severity: "error" as const, message },
];
},
{ delay: 0 }
)
);
}
return extensions;
}
private _createCodeMirror() {
if (!this._loadedCodeMirror) {
throw new Error("Cannot create editor before CodeMirror is loaded");
@@ -385,7 +447,10 @@ export class HaCodeEditor extends ReactiveElement {
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.yamlLintCompartment.of(
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
this._buildYamlSyntaxLinter()
),
this._loadedCodeMirror.yamlSchemaCompartment.of(
this._buildSchemaLinter()
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.tooltips({
@@ -401,6 +466,23 @@ export class HaCodeEditor extends ReactiveElement {
),
{ hoverTime: 300 }
),
...(this.mode === "yaml" && this.yamlFieldSchema
? [
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
schema: this.yamlFieldSchema!,
localize: this.hass?.localize.bind(this.hass) as
| ((key: string, ...args: unknown[]) => string)
| undefined,
hassContext: this.hass
? this._hassArgHoverContext()
: undefined,
}),
{ hoverTime: 300 }
),
]
: []),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -408,6 +490,18 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.mode === "yaml" && this.yamlFieldSchema) {
completionSources.push(
this._loadedCodeMirror.haYamlCompletionSource({
schema: this.yamlFieldSchema,
states: this.hass?.states,
devices: this.hass?.devices,
areas: this.hass?.areas,
floors: this.hass?.floors,
labels: this._labels,
})
);
}
if (this.autocompleteEntities && this.hass) {
completionSources.push(this._entityCompletions.bind(this));
}
@@ -418,6 +512,7 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
activateOnCompletion: (completion) => completion.type === "yaml-key",
}),
this._loadedCodeMirror.closeBrackets(),
this._loadedCodeMirror.closeBracketsOverride,
@@ -965,23 +1060,9 @@ export class HaCodeEditor extends ReactiveElement {
});
};
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
buildEntityCompletions(states)
);
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
@@ -1378,18 +1459,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getDevices = memoizeOne(
(devices: HomeAssistant["devices"]): Completion[] =>
Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device);
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name ?? device.id,
detail: device.id,
apply: device.id,
};
})
buildDeviceCompletions(devices)
);
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
@@ -1408,17 +1478,7 @@ export class HaCodeEditor extends ReactiveElement {
}
private _getAreas = memoizeOne(
(areas: HomeAssistant["areas"]): Completion[] =>
Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
})
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
);
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
@@ -1438,16 +1498,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getFloors = memoizeOne(
(floors: HomeAssistant["floors"]): Completion[] =>
Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
})
buildFloorCompletions(floors)
);
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
@@ -1467,16 +1518,7 @@ export class HaCodeEditor extends ReactiveElement {
private _getLabels = memoizeOne(
(labels: LabelRegistryEntry[]): Completion[] =>
labels.map((label) => {
const name = label.name.trim() || label.label_id;
return {
type: "variable",
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: label.label_id,
apply: label.label_id,
};
})
buildLabelCompletions(labels)
);
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
+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();
});
};
+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 = {
+1
View File
@@ -59,6 +59,7 @@ export class HaExpansionPanel extends LitElement {
<slot class="secondary" name="secondary">${this.secondary}</slot>
</div>
</slot>
<slot name="event"></slot>
${!this.leftChevron ? chevronIcon : nothing}
<slot name="icons"></slot>
</div>
+1 -5
View File
@@ -122,11 +122,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>`;
+4 -1
View File
@@ -137,7 +137,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,
})}
>
+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();
});
-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"
@@ -13,13 +13,16 @@ import "../ha-input-helper-text";
import type { SelectBoxOption } from "../ha-select-box";
import "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"any",
"first",
"last",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
"any",
"all",
];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
+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>
+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)}`;
}
+4
View File
@@ -228,6 +228,10 @@ export class HaSwitch extends Switch {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
:host(:empty) slot.label {
display: none;
}
`,
];
}
+4 -1
View File
@@ -1136,7 +1136,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;
}
+11 -1
View File
@@ -7,6 +7,7 @@ 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 type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-code-editor";
@@ -32,6 +33,14 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
/**
* Optional field schema for YAML mode. When provided, the code editor will
* offer field-aware key/value completions, hover tooltips, and linting.
* This is forwarded directly to ha-code-editor.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
@property({ attribute: false }) public defaultValue?: any;
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
@@ -119,8 +128,9 @@ export class HaYamlEditor extends LitElement {
.inDialog=${this.inDialog}
mode="yaml"
lint
autocomplete-entities
.autocompleteEntities=${!this.yamlFieldSchema}
autocomplete-icons
.yamlFieldSchema=${this.yamlFieldSchema}
.error=${this.isValid === false}
@value-changed=${this._onChange}
@editor-save=${this._onEditorSave}
+8
View File
@@ -1,6 +1,8 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../list/types";
import { HaRowItem } from "./ha-row-item";
/**
@@ -39,6 +41,12 @@ export class HaListItemBase extends HaRowItem {
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
fireEvent(this, "ha-list-item-register", { item: this });
}
public disconnectedCallback(): void {
super.disconnectedCallback();
fireEvent(this, "ha-list-item-unregister", { item: this });
}
/**
+34 -15
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());
}
@@ -62,26 +83,20 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
const hasStart = this._slotController.test("start");
const hasEnd = this._slotController.test("end");
const hasContent = this._slotController.test("content");
return html`
${hasStart
? html`<div part="start" class="start">
<slot name="start"></slot>
</div>`
: nothing}
<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>
${hasEnd
? html`<div part="end" class="end">
<slot name="end"></slot>
</div>`
: nothing}
<div part="end" class="end" ?hidden=${!this._hasEnd}>
<slot name="end" @slotchange=${this._onSlotChange("end")}></slot>
</div>
`;
}
@@ -148,6 +163,10 @@ export class HaRowItem extends LitElement {
align-items: center;
flex: 0 0 auto;
}
.start[hidden],
.end[hidden] {
display: none;
}
.headline {
overflow: hidden;
text-overflow: ellipsis;
+58 -36
View File
@@ -1,10 +1,12 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemBase } from "../item/ha-list-item-base";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
/**
* @element ha-list-base
@@ -12,9 +14,11 @@ import "./types";
*
* @summary
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
* `render()` to specialize.
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
* `HaListItemBase` descendants via the `ha-list-item-register` /
* `ha-list-item-unregister` events they fire on connect/disconnect works
* across any nesting depth and shadow boundaries. Subclasses override
* `hostRole` and/or `render()` to specialize.
*
* @slot - List items (`<ha-list-item-*>`).
*
@@ -68,6 +72,14 @@ export class HaListBase extends LitElement {
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public disconnectedCallback() {
@@ -75,11 +87,14 @@ export class HaListBase extends LitElement {
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
}
public firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this.updateListItems();
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public focus(options?: FocusOptions) {
@@ -115,18 +130,14 @@ export class HaListBase extends LitElement {
this._applyActive(focusItem);
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
const next = this._discoverListItems();
const changed =
next.length !== this.items.length ||
next.some((it, i) => it !== this.items[i]);
if (!changed) {
return;
}
this.items = next;
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= next.length ||
this._activeItemIndex >= this.items.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
@@ -135,6 +146,32 @@ export class HaListBase extends LitElement {
this._applyActive(false);
}
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (this.items.includes(item)) {
return;
}
const next = [...this.items, item];
next.sort(compareNodeOrder);
this.items = next;
this.updateListItems();
};
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (!this.items.includes(item)) {
return;
}
this.items = this.items.filter((it) => it !== item);
this.updateListItems();
};
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
@@ -151,27 +188,12 @@ export class HaListBase extends LitElement {
this._hasFocusableItem = first !== -1;
}
public handleSlotChange = () => {
this.updateListItems();
};
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot @slotchange=${this.handleSlotChange}></slot>
<slot></slot>
</div>`;
}
private _discoverListItems(): HaListItemBase[] {
const slot =
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
if (!slot) {
return [];
}
return slot
.assignedElements({ flatten: true })
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
}
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;
+1 -1
View File
@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot @slotchange=${this.handleSlotChange}></slot>
<slot></slot>
</div>
</nav>`;
}
+6
View File
@@ -11,9 +11,15 @@ export interface HaListActivatedDetail {
item: HaListItemBase;
}
export interface HaListItemRegistrationDetail {
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
}
}
-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
@@ -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,31 @@
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 +37,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 +56,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 +129,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);
}
`,
];
@@ -76,7 +76,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;
+27 -13
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"
@@ -308,7 +308,12 @@ 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"
.hass=${this.hass}
.value=${dump(trace.changed_variables).trimEnd()}
></ha-code-editor>`}
`
)}
</div>
@@ -383,13 +388,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 +410,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;
}
`,
];
}
+5 -1
View File
@@ -18,6 +18,7 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -332,7 +333,10 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: this.trace.trigger,
trigger: localizeTriggerDescription(
this.hass.localize,
this.trace.trigger
),
time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale,
-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;
+1
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";
+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) {
+43
View File
@@ -195,6 +195,49 @@ export const localizeTriggerSource = (
return source;
};
// Mapping from a phrase key to the bare-phrase translation key (without the
// "triggered by" prefix), used by localizeTriggerDescription below.
const triggerDescriptionKeys: Record<
TriggerPhraseKeys,
| "numeric_state_of"
| "state_of"
| "event"
| "time"
| "time_pattern"
| "homeassistant_stopping"
| "homeassistant_starting"
> = {
triggered_by_numeric_state_of: "numeric_state_of",
triggered_by_state_of: "state_of",
triggered_by_event: "event",
triggered_by_time_pattern: "time_pattern",
triggered_by_time: "time",
triggered_by_homeassistant_stopping: "homeassistant_stopping",
triggered_by_homeassistant_starting: "homeassistant_starting",
};
// Like localizeTriggerSource, but returns just the bare localized trigger
// description (without the "triggered by" prefix). Used where the surrounding
// template already supplies its own "triggered by" wording.
export const localizeTriggerDescription = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
return source.replace(
phrase,
`${localize(`ui.components.logbook.${bareKey}`)}`
);
}
}
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
@@ -58,7 +58,6 @@ class DialogConfigEntrySystemOptions extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.dialogs.config_entry_system_options.title",
@@ -3,10 +3,13 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
@@ -40,14 +43,33 @@ interface FlowUpdateEvent {
stepPromise?: Promise<DataEntryFlowStep>;
}
interface FlowStepFooterStateChangedEvent {
loading?: boolean;
hasPendingUpdates?: boolean;
}
interface FormStepElement extends HTMLElement {
submit(): Promise<void>;
}
interface AbortStepElement extends HTMLElement {
close(): void;
}
interface CreateEntryStepElement extends HTMLElement {
finish(): Promise<void>;
}
declare global {
// for fire event
interface HASSDomEvents {
"flow-update": FlowUpdateEvent;
"flow-step-footer-state-changed": FlowStepFooterStateChangedEvent;
}
// for add event listener
interface HTMLElementEventMap {
"flow-update": HASSDomEvent<FlowUpdateEvent>;
"flow-step-footer-state-changed": HASSDomEvent<FlowStepFooterStateChangedEvent>;
}
}
@@ -73,6 +95,16 @@ class DataEntryFlowDialog extends LitElement {
@state() private _handler?: string;
@state() private _formStepLoading = false;
@state() private _createEntryHasPendingUpdates = false;
private _formStepRef = createRef<FormStepElement>();
private _abortStepRef = createRef<AbortStepElement>();
private _createEntryStepRef = createRef<CreateEntryStepElement>();
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
@@ -301,7 +333,6 @@ class DataEntryFlowDialog extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
prevent-scrim-close
@after-show=${this._focusFormStep}
@@ -366,11 +397,14 @@ class DataEntryFlowDialog extends LitElement {
${this._step.type === "form"
? html`
<step-flow-form
${ref(this._formStepRef)}
autofocus
narrow
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
`
: this._step.type === "external"
@@ -384,6 +418,7 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort"
? html`
<step-flow-abort
${ref(this._abortStepRef)}
.params=${this._params}
.step=${this._step}
.hass=${this.hass}
@@ -411,11 +446,14 @@ class DataEntryFlowDialog extends LitElement {
`
: html`
<step-flow-create-entry
${ref(this._createEntryStepRef)}
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult ?? false}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
@@ -426,10 +464,95 @@ class DataEntryFlowDialog extends LitElement {
`}
`}
</div>
${this._renderFooter()}
</ha-dialog>
`;
}
private _renderFooter() {
if (!this._step || this._loading) {
return nothing;
}
switch (this._step.type) {
case "form":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.loading=${this._formStepLoading}
@click=${this._submitFormStep}
>
${this._params!.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this._step
)}
</ha-button>
</ha-dialog-footer>
`;
case "abort":
return this._step.reason === "missing_credentials"
? nothing
: html`
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._closeAbortStep}
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}
</ha-button>
</ha-dialog-footer>
`;
case "external":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</ha-dialog-footer>
`;
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
this._params!.carryOverDevices
);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
@click=${this._finishCreateEntryStep}
>
${this.hass.localize(
`ui.panel.config.integrations.config_flow.${
!devices.length ||
this._createEntryHasPendingUpdates ||
devices.some((device) => device.area_id)
? "finish"
: "finish_skip"
}`
)}
</ha-button>
</ha-dialog-footer>
`;
}
default:
return nothing;
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("flow-update", (ev) => {
@@ -479,6 +602,8 @@ class DataEntryFlowDialog extends LitElement {
}
this._step = undefined;
this._formStepLoading = false;
this._createEntryHasPendingUpdates = false;
await this.updateComplete;
this._step = _step;
if (
@@ -562,20 +687,36 @@ class DataEntryFlowDialog extends LitElement {
}
await this.updateComplete;
(
this.renderRoot.querySelector(
"step-flow-form[autofocus]"
) as HTMLElement | null
)?.focus();
this._formStepRef.value?.focus();
};
private _handleFooterStateChanged = (
ev: HASSDomEvent<FlowStepFooterStateChangedEvent>
) => {
if (ev.detail.loading !== undefined) {
this._formStepLoading = ev.detail.loading;
}
if (ev.detail.hasPendingUpdates !== undefined) {
this._createEntryHasPendingUpdates = ev.detail.hasPendingUpdates;
}
};
private _submitFormStep = () => {
this._formStepRef.value?.submit();
};
private _closeAbortStep = () => {
this._abortStepRef.value?.close();
};
private _finishCreateEntryStep = () => {
this._createEntryStepRef.value?.finish();
};
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;
@@ -18,7 +18,7 @@ import "../../../components/ha-slider";
import "../../../components/ha-time-input";
import "../../../components/input/ha-input";
import { isTiltOnly } from "../../../data/cover";
import { isUnavailableState } from "../../../data/entity/entity";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import type { ImageEntity } from "../../../data/image";
import { computeImageUrl } from "../../../data/image";
import "../../../panels/lovelace/components/hui-timestamp-display";
@@ -266,7 +266,7 @@ class EntityPreviewRow extends LitElement {
<div class="numberflex">
<ha-slider
labeled
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${stateObj.state === UNAVAILABLE}
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
@@ -280,7 +280,7 @@ class EntityPreviewRow extends LitElement {
: html`<div class="numberflex numberstate">
<ha-input
auto-validate
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${stateObj.state === UNAVAILABLE}
pattern="[0-9]+([\\.][0-9]+)?"
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
@@ -303,7 +303,7 @@ class EntityPreviewRow extends LitElement {
<ha-select
.label=${computeStateName(stateObj)}
.value=${stateObj.state}
.disabled=${isUnavailableState(stateObj.state)}
.disabled=${stateObj.state === UNAVAILABLE}
.options=${stateObj.attributes.options?.map((option) => ({
value: option,
label: this.hass!.formatEntityState(stateObj, option),
+4 -8
View File
@@ -8,7 +8,6 @@ import type { HomeAssistant } from "../../types";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-abort")
class StepFlowAbort extends LitElement {
@@ -37,13 +36,6 @@ class StepFlowAbort extends LitElement {
<div class="content">
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>
<div class="buttons">
<ha-button appearance="plain" @click=${this._flowDone}
>${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}</ha-button
>
</div>
`;
}
@@ -68,6 +60,10 @@ class StepFlowAbort extends LitElement {
fireEvent(this, "flow-update", { step: undefined });
}
public close(): void {
this._flowDone();
}
static get styles(): CSSResultGroup {
return configFlowContentStyles;
}
@@ -10,7 +10,6 @@ import {
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-area-picker";
import "../../components/ha-button";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
@@ -31,6 +30,10 @@ import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voi
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
interface DeviceTarget {
device: string;
}
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -192,20 +195,18 @@ class StepFlowCreateEntry extends LitElement {
</div>
`}
</div>
<div class="buttons">
<ha-button @click=${this._flowDone}
>${localize(
`ui.panel.config.integrations.config_flow.${
!this.devices.length || Object.keys(this._deviceUpdate).length
? "finish"
: "finish_skip"
}`
)}</ha-button
>
</div>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
}
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -224,18 +225,20 @@ class StepFlowCreateEntry extends LitElement {
return updateDeviceRegistryEntry(this.hass, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: any) => {
}).catch((err: unknown) => {
const message =
err instanceof Error ? err.message : "Unknown error";
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_device",
{ error: err.message }
{ error: message }
),
});
});
}
);
await Promise.allSettled(deviceUpdates);
const entityUpdates: Promise<any>[] = [];
const entityUpdates: Promise<unknown>[] = [];
const entityIds: string[] = [];
renamedDevices.forEach((deviceId) => {
const entities = this._deviceEntities(
@@ -281,8 +284,15 @@ class StepFlowCreateEntry extends LitElement {
}
}
public finish(): Promise<void> {
return this._flowDone();
}
private async _areaPicked(ev: ValueChangedEvent<string>) {
const picker = ev.currentTarget as any;
const picker = ev.currentTarget as DeviceTarget | null;
if (!picker) {
return;
}
const device = picker.device;
const area = ev.detail.value;
@@ -294,8 +304,11 @@ class StepFlowCreateEntry extends LitElement {
}
private _deviceNameChanged(ev: InputEvent): void {
const picker = ev.currentTarget as HaInput;
const device = (picker as any).device;
const picker = ev.currentTarget as (HaInput & DeviceTarget) | null;
if (!picker) {
return;
}
const device = picker.device;
const name = picker.value;
if (!(device in this._deviceUpdate)) {
@@ -311,22 +324,13 @@ class StepFlowCreateEntry extends LitElement {
css`
.devices {
display: flex;
margin: -4px;
max-height: 600px;
overflow-y: auto;
gap: var(--ha-space-2);
flex-direction: column;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.devices {
/* header - margin content - footer */
max-height: calc(100vh - 52px - 20px - 52px);
}
}
.device {
border: 1px solid var(--divider-color);
padding: 6px;
border-radius: var(--ha-border-radius-sm);
margin: 4px;
display: inline-block;
}
.device-info {
@@ -352,11 +356,6 @@ class StepFlowCreateEntry extends LitElement {
ha-input {
margin: var(--ha-space-2) 0;
}
.buttons > *:last-child {
margin-left: auto;
margin-inline-start: auto;
margin-inline-end: initial;
}
.error {
color: var(--error-color);
}
+2 -23
View File
@@ -1,11 +1,10 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-external")
class StepFlowExternal extends LitElement {
@@ -16,18 +15,9 @@ class StepFlowExternal extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepExternal;
protected render(): TemplateResult {
const localize = this.hass.localize;
return html`
<div class="content">
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
<div class="open-button">
<ha-button href=${this.step.url} target="_blank" rel="noreferrer">
${localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</div>
</div>
`;
}
@@ -38,18 +28,7 @@ class StepFlowExternal extends LitElement {
}
static get styles(): CSSResultGroup {
return [
configFlowContentStyles,
css`
.open-button {
text-align: center;
padding: 24px 0;
}
.open-button a {
text-decoration: none;
}
`,
];
return [configFlowContentStyles];
}
}
+47 -37
View File
@@ -1,11 +1,11 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-button";
import "../../components/ha-alert";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
import "../../components/ha-form/ha-form";
@@ -19,7 +19,7 @@ import { autocompleteLoginFields } from "../../data/auth";
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
import { previewModule } from "../../data/preview";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -47,6 +47,8 @@ class StepFlowForm extends LitElement {
private _errors?: Record<string, string>;
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -88,24 +90,27 @@ class StepFlowForm extends LitElement {
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
${this._errorMsg
? html`<ha-alert alert-type="error">${this._errorMsg}</ha-alert>`
: ""}
<ha-form
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>
: nothing}
${step.data_schema.length
? html`<ha-form
${ref(this._formRef)}
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>`
: nothing}
</div>
${step.preview
? html`<div class="preview" @set-flow-errors=${this._setError}>
@@ -125,14 +130,6 @@ class StepFlowForm extends LitElement {
})}
</div>`
: nothing}
<div class="buttons">
<ha-button @click=${this._submitStep} .loading=${this._loading}>
${this.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this.step
)}
</ha-button>
</div>
`;
}
@@ -145,8 +142,17 @@ class StepFlowForm extends LitElement {
this.addEventListener("keydown", this._handleKeyDown);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_loading")) {
fireEvent(this, "flow-step-footer-state-changed", {
loading: this._loading,
});
}
}
public override focus(_options?: FocusOptions): void {
this.renderRoot.querySelector("ha-form")?.focus();
this._formRef.value?.focus();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -229,13 +235,15 @@ class StepFlowForm extends LitElement {
const flowId = this.step.flow_id;
const toSendData = {};
const toSendData: Record<string, unknown> = {};
Object.keys(stepData).forEach((key) => {
const value = stepData[key];
const isEmpty = [undefined, ""].includes(value);
const field = this.step.data_schema?.find((f) => f.name === key);
const selector = (field as HaFormSelector)?.selector ?? {};
const read_only = (Object.values(selector)[0] as any)?.read_only;
const read_only = (
Object.values(selector)[0] as { read_only?: boolean } | null | undefined
)?.read_only;
if (!isEmpty && !read_only) {
toSendData[key] = value;
}
@@ -277,7 +285,13 @@ class StepFlowForm extends LitElement {
}
}
private _stepDataChanged(ev: CustomEvent): void {
public submit(): Promise<void> {
return this._submitStep();
}
private _stepDataChanged(
ev: ValueChangedEvent<Record<string, unknown>>
): void {
this._stepData = ev.detail.value;
}
@@ -321,13 +335,9 @@ class StepFlowForm extends LitElement {
ha-alert,
ha-form {
margin-top: 24px;
margin-top: var(--ha-space-6);
display: block;
}
.buttons {
padding: 16px;
}
`,
];
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-spinner";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
@@ -28,7 +28,7 @@ class StepFlowLoading extends LitElement {
return html`
<div class="content">
<ha-spinner size="large"></ha-spinner>
${description ? html`<div>${description}</div>` : ""}
${description ? html`<div>${description}</div>` : nothing}
</div>
`;
}
@@ -40,7 +40,7 @@ class StepFlowLoading extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
`;
}
+6 -6
View File
@@ -78,7 +78,7 @@ class StepFlowMenu extends LitElement {
);
return html`
${description ? html`<div class="content">${description}</div>` : ""}
${description ? html`<div class="content">${description}</div>` : nothing}
<div class="options">
${options.map(
(option) => html`
@@ -119,17 +119,17 @@ class StepFlowMenu extends LitElement {
configFlowContentStyles,
css`
.options {
margin-top: 20px;
margin-bottom: 16px;
margin-top: var(--ha-space-5);
margin-bottom: var(--ha-space-4);
}
.content {
padding-bottom: 16px;
padding-bottom: var(--ha-space-4);
}
.content + .options {
margin-top: 8px;
margin-top: var(--ha-space-2);
}
ha-list-item {
--mdc-list-side-padding: 24px;
--mdc-list-side-padding: var(--ha-space-6);
}
`,
];
@@ -53,7 +53,7 @@ class StepFlowProgress extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: 16px;
margin-bottom: var(--ha-space-4);
}
`,
];
+3 -17
View File
@@ -2,12 +2,9 @@ import { css } from "lit";
export const configFlowContentStyles = css`
h2 {
margin: 24px 38px 0 0;
margin: var(--ha-space-6) var(--ha-space-10) 0 0;
margin-inline-start: 0px;
margin-inline-end: 38px;
padding: 0 24px;
padding-inline-start: 24px;
padding-inline-end: 24px;
margin-inline-end: var(--ha-space-10);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-family: var(
@@ -28,19 +25,8 @@ export const configFlowContentStyles = css`
.content,
.preview {
margin-top: 20px;
padding: 0 24px;
margin-top: var(--ha-space-5);
}
.buttons {
position: relative;
padding: 16px;
margin: 8px 0 0;
color: var(--primary-color);
display: flex;
justify-content: flex-end;
}
ha-markdown {
overflow-wrap: break-word;
}
@@ -103,7 +103,6 @@ export class ListItemsDialog
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.title ?? " "}
@closed=${this._dialogClosed}

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