Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6573c08f37 | ||
|
|
dcfde89448 | ||
|
|
3016bbdfa2 | ||
|
|
630d7ff82e | ||
|
|
6874781340 | ||
|
|
be214acfd1 |
No files matched your search
@@ -1,89 +0,0 @@
|
||||
---
|
||||
name: ha-frontend-query-params
|
||||
description: Home Assistant frontend URL query param patterns. Use when keeping page state such as filters, search, sorting or grouping in the URL, adding or reading typed query params, using QueryParamsController or the data table's sync-url and column urlName, building in-app links with a query string, or adding params to My Home Assistant redirects.
|
||||
---
|
||||
|
||||
# HA Frontend Query Params
|
||||
|
||||
Use this skill when implementing or reviewing state kept in the URL's query string, or links that set it. Cross-load `ha-frontend-lit` for the controller and lifecycle work, `ha-frontend-testing` for running tests, and `ha-frontend-review` when reviewing.
|
||||
|
||||
## Typed Params
|
||||
|
||||
`src/common/url/query-params.ts` turns a typed config into values and back:
|
||||
|
||||
- `QueryParamConfig` lists keys by type: `string`, `list` (one comma-separated param, `?label=a,b`), `date` (ISO string) and `boolean` (`{ key, trueValue }`, true only when the param equals `trueValue`).
|
||||
- `QueryParamValues<typeof config>` is the matching partial values type.
|
||||
- `decodeQueryParams(source, config)` reads a `SearchParamsSource` (a `URLSearchParams`, a plain record or a query string). Empty values are skipped.
|
||||
- `createQueryString(values, config)` writes the values, leaving out empty strings, empty lists and false booleans.
|
||||
- `updateQueryString(current, values, config)` replaces only the config's params in an existing query string and keeps every other param exactly as written.
|
||||
- `hasQueryParams(source, config)` tells whether a URL has any of the config's params, which is how a page knows whether a link asked for anything.
|
||||
- `serviceTargetFromQueryParams` and `queryParamsFromServiceTarget` map `HassServiceTarget` keys to list params.
|
||||
|
||||
Give each feature its own `*-query-params.ts` in `src/common/url/` holding its config (`as const satisfies QueryParamConfig`), its values type, and the helpers that turn params into page state and back. Follow the existing ones: `data-table-query-params.ts`, `marketplace-query-params.ts`, `history-logbook-query-params.ts` and `todo-query-params.ts`. Helpers in `src/data/` that build outside links use a local config the same way, as `src/data/integration.ts` and `src/data/system_log_report.ts` do. In new code, prefer these over hand-built `URLSearchParams`, string concatenation or `extractSearchParam` reads from `src/common/url/search-params.ts`.
|
||||
|
||||
## QueryParamsController
|
||||
|
||||
`src/common/controllers/query-params-controller.ts` keeps part of a page's state in the URL. Construct it with the host, the feature's config and options:
|
||||
|
||||
- `enabled()`: whether the host syncs right now. It can change, as on the Marketplace dashboard, which turns it off on the Discover tab.
|
||||
- `navigated()`: called when the page's URL changes while the host is connected (`location-changed` or `popstate`), and when the host reconnects after its first update. Read the URL with `searchParams()`, apply it, then `write()` the state back.
|
||||
|
||||
Methods:
|
||||
|
||||
- `searchParams()` returns the current params, or `undefined` when the controller is disabled or the URL is on another path.
|
||||
- `write(values, debounced?)` updates only the config's params with `replaceCurrentUrl`, so it replaces the current history entry. Pass `debounced` for values that change while typing; the write lands 300 ms after the last one and any later write replaces it.
|
||||
- `flush()` writes a pending debounced write now. The data table calls it when the search box loses focus. Call it before anything that adds its own history entry, such as a dialog.
|
||||
|
||||
The controller binds to `currentPath()` when the host connects and does nothing while the URL is on another path. Navigating away drops a pending write. When a page is reached through a router `defaultPage` (such as `/marketplace` landing on `/marketplace/browse`), check that a link to the bare path still syncs, since the redirect may land after the host connects.
|
||||
|
||||
Read the URL yourself on the first render, after the initial state is set (in `willUpdate` before `hasUpdated`, or `firstUpdated`), and leave later navigations to `navigated()`. `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` show both. When moving a page to the controller, remove its own `location-changed` and `popstate` listeners and any search kept in history state, since the controller replaces them.
|
||||
|
||||
When a router reuses one element for different items, such as one app's tab after another's, key it on the item so the controller binds again to the new path.
|
||||
|
||||
## URL Rules
|
||||
|
||||
- Writes replace the current history entry, so Back leaves the page instead of undoing each change. Push a new entry only for a deliberate navigation, such as picking another log provider.
|
||||
- Debounce search writes. Write sort, group and filter changes straight away.
|
||||
- Leave empty and default values out of the URL, such as an empty search or ascending direction.
|
||||
- A param in the URL wins over stored preferences. A link with none of the page's params keeps what the user picked before; `hasQueryParams` makes that check.
|
||||
- A link with any of the page's params replaces the search and every filter, since a filter left over from before would hide part of what the link shows. Store what the link applied as the user's choice.
|
||||
- The URL is the whole state. Don't add filters while applying a link; a link that needs one, such as showing disabled devices, says so in its params (`status=disabled`).
|
||||
- A link to a page that is already open must apply the new URL. Handle `navigated()`, not only the first render. The router often keeps a page alive, so this is the common case, not an edge case.
|
||||
- Use one comma-separated param for several values (`?status=new,installed`), which `list` params do.
|
||||
- Use the names other pages already use for the same thing: `search`, `sort`, `direction` and `group` for the table, and `floor`, `area`, `device`, `label`, `config_entry`, `sub_entry` and `assistants` for the shared filters.
|
||||
- Keep existing param names so existing links and My Home Assistant links keep working. When renaming, read the old name as well for about six months, with a code comment naming the release it can be removed in, and write only the new name. Keep the old name in the config, use it only as a fallback when reading, and leave it out of the values you write, so the next write removes it from the URL.
|
||||
- Pages opt in. Some use params for other things, such as `?domain=` starting the add integration flow on the integrations dashboard, so check what a page already reads before syncing it.
|
||||
- Collapsed groups, column order and hidden columns stay in storage (`@storage`), not the URL.
|
||||
- Link to a page by its main path, not an alias, so a link to an open page lands on the same path and applies.
|
||||
|
||||
## Data Tables
|
||||
|
||||
`hass-tabs-subpage-data-table` with the `sync-url` attribute keeps `search`, `sort`, `direction` and `group` in the URL through `dataTableQueryParamsFromState` and `dataTableQueryStateFromUrl`. A linked search replaces the current one, and sorting or grouping a link leaves out keeps what was picked before. The table fires `search-changed`, `sorting-changed` and `grouping-changed` when a link changes them, so the page can keep its stored values in step.
|
||||
|
||||
- Give sortable and groupable columns a short public `urlName` (for example `type`, not `translated_category`). Sorting falls back to the column id without one; grouping is only written for columns that have one, so internal ids don't end up in links.
|
||||
- When a column definition is shared between pages, set its `urlName` where the shared column is defined, so every page uses the same name.
|
||||
- The table reads the URL once, as soon as it has columns. When a column only exists once data has loaded, such as Filename on the dashboards page, turn syncing on only after loading succeeds (`?sync-url=${!this._loading && !this._loadFailed}`), or a link that sorts or groups by that column is dropped.
|
||||
- The page owns its filters. Give it a filter config in its `*-query-params.ts`, a helper that turns filter values into params, and one that turns a link back into the search and filter values. The link helper returns `undefined` when the URL has none of the filter params and none of the table's (`dataTableQueryParamConfig`), so stored choices stay. Use a second controller on the page for them, as `ha-marketplace-dashboard.ts` and `lorawan-devices.ts` do. `browseUrl` and `browseLinkFromUrl` in `src/panels/marketplace/dashboards/dashboard-repositories.ts` show the pair.
|
||||
- A memoized filter that also reads async state (anything loaded after the page connects) must take that state as a memoize argument, or a linked filter shows stale results until something else changes.
|
||||
- A filter component that looks up related items asynchronously should drop a lookup whose value has since changed, or two links landing within one lookup apply the older one.
|
||||
|
||||
## Coverage Check
|
||||
|
||||
Before calling a page done:
|
||||
|
||||
1. List every filter, sortable column, groupable column, the search box and any view toggle (such as a tab, raw view or provider). Confirm each one is written to the URL and read back from a link, both on first render and on a page that is already open.
|
||||
2. Search the codebase for in-app links and `navigate()` calls into the page with a query string, and move them to the current param names and the page's main path. `lorawan-connections.ts` and `lorawan-overview.ts` link into `lorawan-devices.ts` this way.
|
||||
|
||||
## My Home Assistant Links
|
||||
|
||||
When a page gains params, add them to its redirect in `getMyRedirects()` in `src/panels/my/ha-panel-my.ts`:
|
||||
|
||||
- List them under `params` as `"string?"`, matching what the page reads. For a data table page that includes `search`, `sort`, `direction` and `group` as well as its filters. Keep the list in a const next to the others, such as `marketplaceTableParams`, which several redirects share.
|
||||
- Point the redirect at the page's main path, the one the page's controller binds to.
|
||||
- Don't add legacy param names.
|
||||
- Add the same params to the redirect's entry in `redirect.json` in `home-assistant/my.home-assistant.io`, in a separate pull request that links the frontend pull request. Merge it only once the frontend pull request is in `dev`.
|
||||
- In the frontend pull request, link the My Home Assistant pull request under "Additional information": `- Link to My Home Assistant pull request: home-assistant/my.home-assistant.io#<n>`.
|
||||
|
||||
## Tests
|
||||
|
||||
Follow `test/common/url/query-params.test.ts`, `test/common/url/data-table-query-params.test.ts` and `test/common/controllers/query-params-controller.test.ts`. For a feature's params file, cover the encode and decode round trip, defaults being left out, and legacy names still being read. Test the helpers, not the page rendering, and only where the logic could break without anyone noticing.
|
||||
@@ -58,7 +58,7 @@ Readiness and invalidation:
|
||||
|
||||
Repository-owned contracts:
|
||||
|
||||
- Consult the public [frontend contributor documentation](https://contributors.home-assistant.io/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- Consult the public [frontend developer documentation](https://developers.home-assistant.io/docs/frontend/) for documented architecture, data flow, design, and development workflows.
|
||||
- For new leaf components, load `ha-frontend-contexts` and verify they consume narrow contexts instead of introducing a broad `hass` property; containers and external APIs may still require `hass`.
|
||||
- Verify backend assumptions against the owning Core, Supervisor, or WebSocket implementation, and component assumptions against the exported component contract.
|
||||
- Prefer canonical repository helpers and test setup over duplicate local implementations.
|
||||
|
||||
@@ -15,45 +15,38 @@ not UCAndroid > 0
|
||||
# Floors are pinned explicitly (not usage-based) so the support policy is
|
||||
# deliberate and does not drift with global usage statistics, which do not
|
||||
# represent old tablets and wall displays used as Home Assistant dashboards:
|
||||
# - iOS/Safari >= 15.4: where native <dialog> (ha-drawer, popovers,
|
||||
# wa-dialog) landed. Dropped: the iOS 12 devices, iPad Air 1 / mini 2 /
|
||||
# mini 3, iPhone 5s / 6 / 6 Plus and iPod touch 6. Nothing ends between
|
||||
# iOS 12 and 15.8, so no other device is affected. On the Mac, Safari 15.4
|
||||
# runs on macOS 10.15 Catalina and later. The Chrome floor, not Safari,
|
||||
# decides what the legacy build has to transpile.
|
||||
# - Chrome >= 67: the first Chromium with BigInt, which Babel cannot
|
||||
# transpile. The boundary on Fire tablets is the Amazon WebView build, not
|
||||
# the Fire OS major: WebView 70+ (Fire OS 5.6.4+ on the 2017 models, Fire
|
||||
# OS 6.3.1.2+, Fire OS 7) keeps working, WebView 59 (Fire OS 5 on the 2015
|
||||
# and 2016 models, Fire OS 6.3.0.1) is dropped. Also covers the Chromecast
|
||||
# 1st gen (frozen at Chrome 70, the next hard ceiling) and old kiosk
|
||||
# webviews above it.
|
||||
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
|
||||
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
|
||||
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
|
||||
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
|
||||
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
|
||||
# also covers old kiosk browsers and no-longer-updating webviews above it.
|
||||
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
|
||||
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
|
||||
# enterprise installs, whose engines can run the legacy build fine.
|
||||
# - Firefox >= 98: where native <dialog> landed; otherwise a zero-cost floor,
|
||||
# not a chased population — pinning it adds no babel transforms, core-js
|
||||
# modules, or Lightning CSS prefixes to the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
|
||||
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
|
||||
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
|
||||
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
|
||||
# If Firefox ever becomes the pin forcing extra output, raise it first.
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets (9.2 is Chromium 67)
|
||||
Chrome >= 67
|
||||
ChromeAndroid >= 67
|
||||
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
|
||||
Chrome >= 59
|
||||
ChromeAndroid >= 59
|
||||
Edge >= 79
|
||||
Firefox >= 98
|
||||
FirefoxAndroid >= 98
|
||||
iOS >= 15.4
|
||||
Safari >= 15.4
|
||||
Firefox >= 94
|
||||
FirefoxAndroid >= 94
|
||||
iOS >= 12
|
||||
Safari >= 12
|
||||
Samsung >= 9
|
||||
|
||||
[legacy-sw]
|
||||
# Same as legacy, restricted to browsers that support service workers
|
||||
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
|
||||
Chrome >= 67 and supports serviceworkers
|
||||
ChromeAndroid >= 67 and supports serviceworkers
|
||||
Chrome >= 59 and supports serviceworkers
|
||||
ChromeAndroid >= 59 and supports serviceworkers
|
||||
Edge >= 79 and supports serviceworkers
|
||||
Firefox >= 98 and supports serviceworkers
|
||||
FirefoxAndroid >= 98 and supports serviceworkers
|
||||
iOS >= 15.4 and supports serviceworkers
|
||||
Safari >= 15.4 and supports serviceworkers
|
||||
Firefox >= 94 and supports serviceworkers
|
||||
FirefoxAndroid >= 94 and supports serviceworkers
|
||||
iOS >= 12 and supports serviceworkers
|
||||
Safari >= 12 and supports serviceworkers
|
||||
Samsung >= 9 and supports serviceworkers
|
||||
@@ -105,4 +105,4 @@ To help with the load of incoming pull requests:
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
@@ -34,7 +34,7 @@ runs:
|
||||
hashFiles('.nvmrc', 'package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'patches/**') }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
|
||||
|
||||
@@ -192,8 +192,8 @@ export default async function checkPullRequestStandards({
|
||||
`\n\n` +
|
||||
`Please complete the ` +
|
||||
`[PR template](https://github.com/home-assistant/frontend/blob/dev/.github/PULL_REQUEST_TEMPLATE.md?plain=1) ` +
|
||||
`and see the [contributor docs](https://contributors.home-assistant.io/start/pull-requests/#fill-out-the-template) ` +
|
||||
`for more on creating a great pull request.`;
|
||||
`and see the [developer docs](https://developers.home-assistant.io/docs/review-process) ` +
|
||||
`for more on creating a great pull request (see point 6).`;
|
||||
|
||||
if (existing) {
|
||||
await withRetry("comment update", () =>
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-bundle-stats
|
||||
path: build/stats/*.json
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
- name: Check entrypoint bundle size budget
|
||||
run: pnpm run check-bundlesize
|
||||
- name: Upload frontend build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: frontend-build
|
||||
path: hass_frontend/
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
|
||||
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload demo build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload e2e test app build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download demo build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: demo-dist
|
||||
path: demo/dist/
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload demo blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-demo-${{ matrix.shardIndex }}
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -236,7 +236,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download e2e test app build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: e2e-test-app-dist
|
||||
path: test/e2e/app/dist/
|
||||
@@ -246,7 +246,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload app blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-app-${{ matrix.shardIndex }}
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
image: mcr.microsoft.com/playwright:v1.64.0-noble
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -290,7 +290,7 @@ jobs:
|
||||
node-modules-cache-key: node-modules-container-v1
|
||||
|
||||
- name: Download gallery build
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: gallery-dist
|
||||
path: gallery/dist/
|
||||
@@ -300,7 +300,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Upload gallery blob report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-gallery-${{ matrix.shardIndex }}
|
||||
@@ -331,21 +331,21 @@ jobs:
|
||||
node-modules-cache: true
|
||||
|
||||
- name: Download demo blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-demo-*
|
||||
path: test/e2e/reports/demo/
|
||||
|
||||
- name: Download app blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-app-*
|
||||
path: test/e2e/reports/app/
|
||||
|
||||
- name: Download gallery blob reports
|
||||
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
continue-on-error: true
|
||||
with:
|
||||
pattern: blob-report-gallery-*
|
||||
@@ -359,7 +359,7 @@ jobs:
|
||||
|
||||
- name: Upload merged HTML report
|
||||
id: upload-report
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report
|
||||
path: test/e2e/reports/combined/
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels
|
||||
path: dist/home_assistant_frontend*.whl
|
||||
@@ -137,7 +137,7 @@ jobs:
|
||||
run: tar -czvf translations.tar.gz translations
|
||||
|
||||
- name: Upload translations
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: translations
|
||||
path: translations.tar.gz
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@cd6f3635a740c84d8a14f6121a90068b8ece6bf6 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@06749dd8c0b54f350bc69c8752456cee498808a3 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -46,7 +46,6 @@ Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching
|
||||
- `ha-frontend-lit`: reactive fields, DOM queries, lifecycle behavior, and render-derived state.
|
||||
- `ha-frontend-styling`: theme variables, spacing tokens, responsive layout, RTL, and view transitions.
|
||||
- `ha-frontend-testing`: lint, typecheck, Vitest, Playwright e2e dev servers, and benchmarks.
|
||||
- `ha-frontend-query-params`: typed URL query params, `QueryParamsController`, data table URL sync, in-app links, and My Home Assistant redirect params.
|
||||
- `ha-frontend-user-facing-text`: localization, terminology, sentence case, and Home Assistant text style.
|
||||
- `ha-frontend-review`: PR template use, existing review feedback, review checklist, recurring issues, and UI/UX evidence including gallery specifications.
|
||||
- `ha-frontend-ux-readiness`: UI/UX routing that complements frontend review for new user experiences.
|
||||
|
||||
@@ -41,5 +41,5 @@ Contributions that do not follow this policy will be closed. Repeated violations
|
||||
---
|
||||
|
||||
The canonical version of this policy is published at
|
||||
<https://contributors.home-assistant.io/start/ai-policy/>. In case of differences,
|
||||
<https://developers.home-assistant.io/docs/ai_policy>. In case of differences,
|
||||
the published version applies.
|
||||
@@ -6,12 +6,12 @@ This is the repository for the official [Home Assistant](https://home-assistant.
|
||||
|
||||
- [View demo of Home Assistant](https://demo.home-assistant.io/)
|
||||
- [More information about Home Assistant](https://home-assistant.io)
|
||||
- [Frontend development instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
- [Frontend development instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
|
||||
## Development
|
||||
|
||||
- Initial setup: `script/setup`
|
||||
- Development: [Instructions](https://contributors.home-assistant.io/frontend/development/)
|
||||
- Development: [Instructions](https://developers.home-assistant.io/docs/frontend/development/)
|
||||
- Production build: `pnpm build`
|
||||
- Gallery: `cd gallery && script/develop_gallery`
|
||||
|
||||
|
||||
@@ -18,6 +18,17 @@ const polyfillSupport = {
|
||||
safari: 17.4,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"element-getattributenames": {
|
||||
android: 61,
|
||||
chrome: 61,
|
||||
edge: 18,
|
||||
firefox: 45,
|
||||
ios: 10.3,
|
||||
opera: 48,
|
||||
opera_mobile: 45,
|
||||
safari: 10.1,
|
||||
samsung: 8.0,
|
||||
},
|
||||
"element-toggleattribute": {
|
||||
android: 69,
|
||||
chrome: 69,
|
||||
@@ -56,20 +67,39 @@ const polyfillSupport = {
|
||||
"intl-other": {
|
||||
// Not specified (i.e. always try polyfill) since compatibility depends on supported locales
|
||||
},
|
||||
"resize-observer": {
|
||||
android: 64,
|
||||
chrome: 64,
|
||||
edge: 79,
|
||||
firefox: 69,
|
||||
ios: 13.4,
|
||||
opera: 51,
|
||||
opera_mobile: 47,
|
||||
safari: 13.1,
|
||||
samsung: 9.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Map of instance and static properties to the corresponding polyfill key
|
||||
// and actual module to import
|
||||
// Map of global variables and/or instance and static properties to the
|
||||
// corresponding polyfill key and actual module to import
|
||||
const polyfillMap = {
|
||||
global: {
|
||||
ResizeObserver: {
|
||||
key: "resize-observer",
|
||||
module: join(POLYFILL_DIR, "resize-observer.ts"),
|
||||
},
|
||||
},
|
||||
instance: {
|
||||
attachInternals: {
|
||||
key: "element-internals",
|
||||
module: "element-internals-polyfill",
|
||||
},
|
||||
toggleAttribute: {
|
||||
key: "element-toggleattribute",
|
||||
module: join(POLYFILL_DIR, "element-toggleattribute.ts"),
|
||||
},
|
||||
...Object.fromEntries(
|
||||
["getAttributeNames", "toggleAttribute"].map((prop) => {
|
||||
const key = `element-${prop.toLowerCase()}`;
|
||||
return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }];
|
||||
})
|
||||
),
|
||||
},
|
||||
static: {
|
||||
Intl: {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 627654,
|
||||
"core": 55147,
|
||||
"authorize": 593862,
|
||||
"onboarding": 709255
|
||||
"app": 624240,
|
||||
"core": 55144,
|
||||
"authorize": 594400,
|
||||
"onboarding": 705499
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 724225,
|
||||
"core": 157026,
|
||||
"authorize": 693913,
|
||||
"onboarding": 812415
|
||||
"app": 776696,
|
||||
"core": 188309,
|
||||
"authorize": 763316,
|
||||
"onboarding": 879807
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,8 @@ module.exports.htmlMinifierOptions = {
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
// Highest syntax the minifier may emit; it never downlevels. Every browser
|
||||
// in [modern] is well past ES2020 (universal since spring 2020); the
|
||||
// [legacy] floors (Chrome 67 / Safari 15) top out at ES2019: optional
|
||||
// catch binding is in, `?.` and `??` (Chrome 80) are not.
|
||||
ecma: latestBuild ? 2020 : 2019,
|
||||
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
|
||||
ecma: latestBuild ? 2020 : 2017,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
@@ -186,6 +185,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
|
||||
path.join(paths.root_dir, "src/resources/polyfills"),
|
||||
...[
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
const polyPath = (...parts) => path.resolve(paths.root_dir, ...parts);
|
||||
@@ -87,24 +83,9 @@ async function copyMapPanel(staticDir) {
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Resolve through barcode-detector, the wasm must match the zxing-wasm JS it bundles
|
||||
const zxingWasm = createRequire(require.resolve("barcode-detector")).resolve(
|
||||
"zxing-wasm/reader/zxing_reader.wasm"
|
||||
);
|
||||
const {
|
||||
ZXING_WASM_SHA256,
|
||||
ZXING_WASM_VERSION,
|
||||
} = require("barcode-detector/pure");
|
||||
const wasm = fs.readFileSync(zxingWasm);
|
||||
if (createHash("sha256").update(wasm).digest("hex") !== ZXING_WASM_SHA256) {
|
||||
throw new Error(
|
||||
`${zxingWasm} does not match the zxing-wasm ${ZXING_WASM_VERSION} bundled in barcode-detector`
|
||||
);
|
||||
}
|
||||
// Versioned file name, see locateFile in ha-qr-scanner
|
||||
fs.outputFileSync(
|
||||
staticPath("js", `zxing_reader-${ZXING_WASM_VERSION}.wasm`),
|
||||
wasm
|
||||
copyFileDir(
|
||||
npmPath("zxing-wasm/dist/reader/zxing_reader.wasm"),
|
||||
staticPath("js")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,9 +153,7 @@ gulp.task("rspack-watch-app", () => {
|
||||
: createAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
).watch({ poll: isWsl }, doneHandler());
|
||||
gulp.watch(
|
||||
// Watch the folder via a glob; git replaces en.json on checkout/stash,
|
||||
// which can permanently drop a watch on the file itself
|
||||
path.join(paths.translations_src, "*.json"),
|
||||
path.join(paths.translations_src, "en.json"),
|
||||
gulp.series("build-translations", "copy-translations-app")
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ const fs = require("fs");
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
const browserslist = require("browserslist");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { RsdoctorRspackPlugin } = require("@rsdoctor/rspack-plugin");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -41,21 +40,6 @@ const TOOLCHAIN_PACKAGES = [
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Not direct dependencies: read the copy the package that loads them uses.
|
||||
const TOOLCHAIN_PACKAGE_PARENTS = {
|
||||
terser: "terser-webpack-plugin",
|
||||
"caniuse-lite": "browserslist",
|
||||
};
|
||||
|
||||
const toolchainVersion = (pkg) => {
|
||||
const parent = TOOLCHAIN_PACKAGE_PARENTS[pkg];
|
||||
const lookup = parent && [
|
||||
path.dirname(require.resolve(`${parent}/package.json`)),
|
||||
];
|
||||
return require(require.resolve(`${pkg}/package.json`, { paths: lookup }))
|
||||
.version;
|
||||
};
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
@@ -79,7 +63,9 @@ const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map((pkg) => `${pkg}@${toolchainVersion(pkg)}`),
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
@@ -229,22 +215,6 @@ const createRspackConfig = ({
|
||||
{
|
||||
test: /\.css$/,
|
||||
type: "asset/source",
|
||||
// Imported stylesheets skip the minifier that compiles css``
|
||||
// templates, so legacy builds compile logical properties and :is()
|
||||
// here
|
||||
use: latestBuild
|
||||
? []
|
||||
: [
|
||||
{
|
||||
loader: "builtin:lightningcss-loader",
|
||||
options: {
|
||||
targets: browserslist.loadConfig({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -410,14 +380,6 @@ const createRspackConfig = ({
|
||||
resolve: {
|
||||
extensions: [".ts", ".js", ".json"],
|
||||
alias: {
|
||||
// @formatjs/bigdecimal uses BigInt (Safari 14, Chrome 67), which
|
||||
// breaks the NumberFormat and PluralRules polyfills
|
||||
...(!latestBuild && {
|
||||
"@formatjs/bigdecimal$": path.join(
|
||||
paths.root_dir,
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
@@ -434,6 +396,8 @@ const createRspackConfig = ({
|
||||
"lit/directives/keyed$": "lit/directives/keyed.js",
|
||||
"@lit-labs/virtualizer/layouts/grid":
|
||||
"@lit-labs/virtualizer/layouts/grid.js",
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver":
|
||||
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js",
|
||||
"@lit-labs/observers/resize-controller":
|
||||
"@lit-labs/observers/resize-controller.js",
|
||||
"@formatjs/intl-durationformat/should-polyfill$":
|
||||
|
||||
@@ -4,7 +4,7 @@ import { castContext } from "../cast_context";
|
||||
|
||||
export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
const touchSupported =
|
||||
castContext.getDeviceCapabilities()?.touch_input_supported;
|
||||
castContext.getDeviceCapabilities().touch_input_supported;
|
||||
return {
|
||||
views: [
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ const playerStylesAdded = false;
|
||||
let controls: HTMLElement | null;
|
||||
|
||||
const setTouchControlsVisibility = (visible: boolean) => {
|
||||
if (!castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
if (!castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
return;
|
||||
}
|
||||
controls =
|
||||
@@ -51,7 +51,7 @@ const playDummyMedia = (viewTitle?: string) => {
|
||||
clearTimeout(timeOut);
|
||||
timeOut = undefined;
|
||||
}
|
||||
if (castContext.getDeviceCapabilities()?.touch_input_supported) {
|
||||
if (castContext.getDeviceCapabilities().touch_input_supported) {
|
||||
timeOut = window.setTimeout(() => playDummyMedia(viewTitle), 540000); // repeat every 9 minutes to keep it active (gets deactivated after 10 minutes)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,45 +142,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
(localize?: LocalizeFunc) => localize ?? this.hass!.localize
|
||||
);
|
||||
|
||||
mockLovelace(hass, localizePromise, [
|
||||
{
|
||||
id: "music",
|
||||
url_path: "music",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:music",
|
||||
title: "Music",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "cameras",
|
||||
url_path: "cameras",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:cctv",
|
||||
title: "Cameras",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "garden",
|
||||
url_path: "garden",
|
||||
require_admin: false,
|
||||
show_in_sidebar: true,
|
||||
icon: "mdi:flower",
|
||||
title: "Garden",
|
||||
mode: "storage",
|
||||
},
|
||||
{
|
||||
id: "lovelace-advanced",
|
||||
url_path: "lovelace-advanced",
|
||||
require_admin: false,
|
||||
show_in_sidebar: false,
|
||||
icon: "mdi:cog",
|
||||
title: "Advanced",
|
||||
mode: "yaml",
|
||||
filename: "lovelace-advanced.yaml",
|
||||
},
|
||||
]);
|
||||
mockLovelace(hass, localizePromise);
|
||||
mockAuth(hass);
|
||||
mockTranslations(hass, localizePromise);
|
||||
mockHistory(hass);
|
||||
@@ -210,35 +172,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
mockAreaRegistry(hass);
|
||||
mockDeviceRegistry(hass, demoDevices);
|
||||
mockFloorRegistry(hass);
|
||||
mockLabelRegistry(hass, [
|
||||
{
|
||||
label_id: "kitchen",
|
||||
name: "Kitchen",
|
||||
icon: null,
|
||||
color: "red",
|
||||
description: "Everything in the kitchen",
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
{
|
||||
label_id: "garden",
|
||||
name: "Garden",
|
||||
icon: "mdi:flower",
|
||||
color: "green",
|
||||
description: null,
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
{
|
||||
label_id: "battery-powered",
|
||||
name: "Battery powered",
|
||||
icon: null,
|
||||
color: "orange",
|
||||
description: "Runs on batteries",
|
||||
created_at: 1735689600,
|
||||
modified_at: 1735689600,
|
||||
},
|
||||
]);
|
||||
mockLabelRegistry(hass);
|
||||
mockUsagePrediction(hass);
|
||||
mockEntityRegistryDisplay(hass);
|
||||
mockEntityRegistry(hass, [
|
||||
|
||||
@@ -42,12 +42,4 @@ export const mockBlueprint = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("blueprint/list", (msg: { domain: BlueprintDomain }) =>
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints
|
||||
);
|
||||
hass.mockWS(
|
||||
"blueprint/delete",
|
||||
(msg: { domain: BlueprintDomain; path: string }) => {
|
||||
const blueprints =
|
||||
msg.domain === "script" ? scriptBlueprints : automationBlueprints;
|
||||
delete blueprints[msg.path];
|
||||
}
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { lorawanFixtures } from "./lorawan/fixtures";
|
||||
import { bluetoothFixtures } from "./bluetooth/fixtures";
|
||||
import { infraredFixtures } from "./infrared/fixtures";
|
||||
import { matterFixtures } from "./matter/fixtures";
|
||||
@@ -16,7 +15,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
|
||||
// data to mock. Each owns its own fixtures, so they can be added and removed
|
||||
// one at a time.
|
||||
const INTEGRATIONS: ConnectivityFixtures[] = [
|
||||
lorawanFixtures,
|
||||
bluetoothFixtures,
|
||||
serialFixtures,
|
||||
modbusFixtures,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mockLoRaWAN } from "./lorawan/mock";
|
||||
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { mockBluetooth } from "./bluetooth/mock";
|
||||
import { mockMatter } from "./matter/mock";
|
||||
@@ -12,7 +11,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
|
||||
|
||||
// The WebSocket mocks, code-split into the config panel chunk.
|
||||
const MOCKS = [
|
||||
mockLoRaWAN,
|
||||
mockBluetooth,
|
||||
mockSerial,
|
||||
mockModbus,
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
import { manifest } from "../../manifest";
|
||||
import {
|
||||
configEntry,
|
||||
device,
|
||||
registryEntry,
|
||||
withRegistryLinks,
|
||||
} from "../helpers";
|
||||
import type { ConnectivityFixtures } from "../types";
|
||||
|
||||
export const CHIRPSTACK_ENTRY = "mock-lorawan-chirpstack";
|
||||
export const TTN_ENTRY = "mock-lorawan-ttn";
|
||||
export const SENSECAP_ENTRY = "mock-lorawan-sensecap";
|
||||
|
||||
const REGISTRY_ENTRIES = [
|
||||
registryEntry(
|
||||
"sensor.lorawan_greenhouse_temperature",
|
||||
"lorawan-greenhouse",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Temperature"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.lorawan_greenhouse_humidity",
|
||||
"lorawan-greenhouse",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Humidity"
|
||||
),
|
||||
registryEntry(
|
||||
"sensor.lorawan_study_carbon_dioxide",
|
||||
"lorawan-study",
|
||||
SENSECAP_ENTRY,
|
||||
"sensecap_lorawan",
|
||||
"Carbon dioxide"
|
||||
),
|
||||
];
|
||||
|
||||
export const lorawanFixtures: ConnectivityFixtures = {
|
||||
components: [
|
||||
"lorawan",
|
||||
"sensecap_lorawan",
|
||||
"chirpstack",
|
||||
"thethingsnetwork_lorawan",
|
||||
],
|
||||
commands: ["lorawan/"],
|
||||
manifests: [
|
||||
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
|
||||
manifest("sensecap_lorawan", "SenseCAP", {
|
||||
integration_type: "device",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
|
||||
manifest("thethingsnetwork_lorawan", "The Things Network", {
|
||||
integration_type: "hub",
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(CHIRPSTACK_ENTRY, "chirpstack", "Home ChirpStack"),
|
||||
},
|
||||
{
|
||||
type: "hub",
|
||||
entry: configEntry(
|
||||
TTN_ENTRY,
|
||||
"thethingsnetwork_lorawan",
|
||||
"The Things Network"
|
||||
),
|
||||
},
|
||||
{
|
||||
type: "device",
|
||||
entry: configEntry(SENSECAP_ENTRY, "sensecap_lorawan", "SenseCAP"),
|
||||
},
|
||||
],
|
||||
devices: [
|
||||
device(
|
||||
"lorawan-greenhouse",
|
||||
"Greenhouse",
|
||||
"Seeed Studio",
|
||||
"S2101",
|
||||
SENSECAP_ENTRY,
|
||||
{
|
||||
config_entries: [SENSECAP_ENTRY, CHIRPSTACK_ENTRY],
|
||||
identifiers: [
|
||||
["sensecap_lorawan", `${CHIRPSTACK_ENTRY}:0000000000000001`],
|
||||
],
|
||||
}
|
||||
),
|
||||
device(
|
||||
"lorawan-study",
|
||||
"Study air quality",
|
||||
"Seeed Studio",
|
||||
"S2103",
|
||||
SENSECAP_ENTRY,
|
||||
{
|
||||
config_entries: [SENSECAP_ENTRY, TTN_ENTRY],
|
||||
identifiers: [["sensecap_lorawan", `${TTN_ENTRY}:0000000000000002`]],
|
||||
}
|
||||
),
|
||||
],
|
||||
entityRegistryEntries: REGISTRY_ENTRIES,
|
||||
entities: () =>
|
||||
withRegistryLinks(REGISTRY_ENTRIES, {
|
||||
"sensor.lorawan_greenhouse_temperature": {
|
||||
entity_id: "sensor.lorawan_greenhouse_temperature",
|
||||
state: "24.3",
|
||||
attributes: {
|
||||
friendly_name: "Greenhouse temperature",
|
||||
device_class: "temperature",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "°C",
|
||||
},
|
||||
},
|
||||
"sensor.lorawan_greenhouse_humidity": {
|
||||
entity_id: "sensor.lorawan_greenhouse_humidity",
|
||||
state: "58",
|
||||
attributes: {
|
||||
friendly_name: "Greenhouse humidity",
|
||||
device_class: "humidity",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "%",
|
||||
},
|
||||
},
|
||||
"sensor.lorawan_study_carbon_dioxide": {
|
||||
entity_id: "sensor.lorawan_study_carbon_dioxide",
|
||||
state: "652",
|
||||
attributes: {
|
||||
friendly_name: "Study carbon dioxide",
|
||||
device_class: "carbon_dioxide",
|
||||
state_class: "measurement",
|
||||
unit_of_measurement: "ppm",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
import type { LoRaWANDevice } from "../../../../../src/data/lorawan";
|
||||
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
|
||||
import { minutesAgo } from "../helpers";
|
||||
import { emitInitial } from "../subscription";
|
||||
import { CHIRPSTACK_ENTRY, TTN_ENTRY } from "./fixtures";
|
||||
|
||||
const devices = (): LoRaWANDevice[] => {
|
||||
const base: LoRaWANDevice = {
|
||||
dev_eui: "0000000000000001",
|
||||
name: "Greenhouse",
|
||||
manufacturer: "Seeed Studio",
|
||||
model: "S2101",
|
||||
application_id: "home-sensors",
|
||||
device_id: "lorawan-greenhouse",
|
||||
connection: {
|
||||
entry_id: CHIRPSTACK_ENTRY,
|
||||
domain: "chirpstack",
|
||||
title: "Home ChirpStack",
|
||||
configuration_url: "https://www.chirpstack.io/",
|
||||
},
|
||||
support: "supported",
|
||||
last_uplink: minutesAgo(2),
|
||||
last_join: minutesAgo(2400),
|
||||
last_status: minutesAgo(64),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -67, snr: 9.5 }],
|
||||
battery_level: 87,
|
||||
external_power_source: false,
|
||||
downlink_margin: 12,
|
||||
};
|
||||
return [
|
||||
base,
|
||||
{
|
||||
...base,
|
||||
dev_eui: "0000000000000002",
|
||||
name: "Study air quality",
|
||||
model: "S2103",
|
||||
device_id: "lorawan-study",
|
||||
application_id: "demo-home",
|
||||
connection: {
|
||||
entry_id: TTN_ENTRY,
|
||||
domain: "thethingsnetwork_lorawan",
|
||||
title: "The Things Network",
|
||||
configuration_url: "https://eu1.cloud.thethings.network/console",
|
||||
},
|
||||
receptions: [
|
||||
{ gateway_id: "brooklyn-rooftop", rssi: -103, snr: -2.5 },
|
||||
{ gateway_id: "brooklyn-community", rssi: -110, snr: -5.2 },
|
||||
],
|
||||
battery_level: 64,
|
||||
last_uplink: minutesAgo(4),
|
||||
last_status: minutesAgo(140),
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "24E124128E123456",
|
||||
name: "Irrigation controller",
|
||||
manufacturer: "Milesight",
|
||||
model: "UC511",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 93,
|
||||
last_uplink: minutesAgo(7),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -83, snr: 6.8 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "A84041FFFF123456",
|
||||
name: "Garage power meter",
|
||||
manufacturer: "",
|
||||
model: "",
|
||||
device_id: null,
|
||||
support: "missing_identity",
|
||||
battery_level: null,
|
||||
external_power_source: true,
|
||||
last_uplink: minutesAgo(1),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -75, snr: 8.1 }],
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500199",
|
||||
name: "New soil sensor",
|
||||
model: "S2105",
|
||||
device_id: null,
|
||||
support: "unsupported_model",
|
||||
last_uplink: null,
|
||||
last_join: null,
|
||||
last_status: null,
|
||||
receptions: [],
|
||||
battery_level: null,
|
||||
external_power_source: null,
|
||||
downlink_margin: null,
|
||||
},
|
||||
{
|
||||
...base,
|
||||
dev_eui: "2CF7F1C071500166",
|
||||
name: "Garden shed",
|
||||
device_id: null,
|
||||
support: "integration_not_configured",
|
||||
battery_level: 18,
|
||||
last_uplink: minutesAgo(180),
|
||||
last_status: minutesAgo(600),
|
||||
receptions: [{ gateway_id: "home-indoor", rssi: -112, snr: -7.5 }],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const mockLoRaWAN = (hass: MockHomeAssistant) => {
|
||||
const deviceList = devices();
|
||||
const snapshot = {
|
||||
devices: deviceList,
|
||||
connections: [
|
||||
...new Map(
|
||||
deviceList.map((device) => [
|
||||
device.connection.entry_id,
|
||||
device.connection,
|
||||
])
|
||||
).values(),
|
||||
],
|
||||
};
|
||||
hass.mockWS("lorawan/devices/list", () => snapshot);
|
||||
hass.mockWS("lorawan/devices/subscribe", (_msg, _hass, onChange) =>
|
||||
emitInitial(() => onChange?.(snapshot))
|
||||
);
|
||||
};
|
||||
@@ -190,39 +190,6 @@ export const removeEntityRegistryEntry = (
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
export const removeLabelFromEntityRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
labelId: string
|
||||
) => {
|
||||
const withoutLabel = (labels: string[]) =>
|
||||
labels.filter((id) => id !== labelId);
|
||||
for (const [key, entry] of retiredEntries) {
|
||||
if (entry.labels.includes(labelId)) {
|
||||
retiredEntries.set(key, { ...entry, labels: withoutLabel(entry.labels) });
|
||||
}
|
||||
}
|
||||
if (!entries.some((entry) => entry.labels.includes(labelId))) {
|
||||
return;
|
||||
}
|
||||
entries = entries.map((entry) =>
|
||||
entry.labels.includes(labelId)
|
||||
? { ...entry, labels: withoutLabel(entry.labels) }
|
||||
: entry
|
||||
);
|
||||
const entities = { ...hass.entities };
|
||||
for (const entry of entries) {
|
||||
const entity = entities[entry.entity_id];
|
||||
if (entity?.labels.includes(labelId)) {
|
||||
entities[entry.entity_id] = {
|
||||
...entity,
|
||||
labels: withoutLabel(entity.labels),
|
||||
};
|
||||
}
|
||||
}
|
||||
hass.updateHass({ entities });
|
||||
hass.mockEvent("entity_registry_updated");
|
||||
};
|
||||
|
||||
// Adds an entry like core does when an integration creates an entity
|
||||
export const addEntityRegistryEntry = (
|
||||
hass: MockHomeAssistant,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { LabelRegistryEntry } from "../../../src/data/label/label_registry";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import { removeLabelFromEntityRegistry } from "./entity_registry";
|
||||
|
||||
let labels: LabelRegistryEntry[] = [];
|
||||
|
||||
@@ -10,22 +9,6 @@ export const mockLabelRegistry = (
|
||||
hass: MockHomeAssistant,
|
||||
data: LabelRegistryEntry[] = []
|
||||
) => {
|
||||
labels = [...data];
|
||||
hass.mockWS("config/label_registry/list", () =>
|
||||
labels.map((label) => ({ ...label }))
|
||||
);
|
||||
hass.mockWS(
|
||||
"config/label_registry/delete",
|
||||
(msg: { label_id: string }, currentHass: MockHomeAssistant) => {
|
||||
const index = labels.findIndex(
|
||||
(label) => label.label_id === msg.label_id
|
||||
);
|
||||
if (index === -1) {
|
||||
throw new Error(`Label ${msg.label_id} not found`);
|
||||
}
|
||||
labels.splice(index, 1);
|
||||
removeLabelFromEntityRegistry(currentHass, msg.label_id);
|
||||
hass.mockEvent("label_registry_updated");
|
||||
}
|
||||
);
|
||||
labels = data;
|
||||
hass.mockWS("config/label_registry/list", () => data);
|
||||
};
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceDashboard } from "../../../src/data/lovelace/dashboard";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
@@ -12,14 +11,10 @@ import "../custom-cards/ha-demo-card";
|
||||
import "../custom-cards/ha-demo-next-card";
|
||||
import { mockMap } from "./map";
|
||||
|
||||
let dashboards: LovelaceDashboard[] = [];
|
||||
|
||||
export const mockLovelace = (
|
||||
hass: MockHomeAssistant,
|
||||
localizePromise: Promise<LocalizeFunc>,
|
||||
data: LovelaceDashboard[] = []
|
||||
localizePromise: Promise<LocalizeFunc>
|
||||
) => {
|
||||
dashboards = data;
|
||||
hass.mockWS("lovelace/config", ({ url_path }) => {
|
||||
if (url_path === "map") {
|
||||
mockMap(hass);
|
||||
@@ -39,16 +34,7 @@ export const mockLovelace = (
|
||||
);
|
||||
hass.mockWS("lovelace/config/save", () => Promise.resolve());
|
||||
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
|
||||
hass.mockWS("lovelace/dashboards/list", () =>
|
||||
dashboards.map((dashboard) => ({ ...dashboard }))
|
||||
);
|
||||
hass.mockWS("lovelace/dashboards/delete", (msg: { dashboard_id: string }) => {
|
||||
const index = dashboards.findIndex((d) => d.id === msg.dashboard_id);
|
||||
if (index === -1) {
|
||||
throw new Error(`Dashboard ${msg.dashboard_id} not found`);
|
||||
}
|
||||
dashboards.splice(index, 1);
|
||||
});
|
||||
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
|
||||
};
|
||||
|
||||
customElements.whenDefined("hui-root").then(() => {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { TZDate } from "@date-fns/tz";
|
||||
import {
|
||||
addDays,
|
||||
addHours,
|
||||
addMinutes,
|
||||
addMonths,
|
||||
constructFrom,
|
||||
differenceInHours,
|
||||
endOfDay,
|
||||
} from "date-fns";
|
||||
@@ -15,18 +13,6 @@ import type {
|
||||
} from "../../../src/data/recorder";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
const getFirstDate = (
|
||||
start: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
): Date => {
|
||||
if (period === "day" || period === "month") {
|
||||
return constructFrom(start, start);
|
||||
}
|
||||
// Core returns UTC-aligned buckets whose start is at or after the request.
|
||||
const interval = (period === "hour" ? 60 : 5) * 60 * 1000;
|
||||
return constructFrom(start, Math.ceil(start.getTime() / interval) * interval);
|
||||
};
|
||||
|
||||
const getNextDate = (
|
||||
currentDate: Date,
|
||||
period: "5minute" | "hour" | "day" | "month"
|
||||
@@ -47,7 +33,8 @@ const generateMeanStatistics = (
|
||||
maxDiff: number
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
const delta = Math.random() * maxDiff;
|
||||
@@ -76,7 +63,8 @@ const generateSumStatistics = (
|
||||
maxDiff: number | ((start: Date, end: Date) => number)
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let sum = initValue;
|
||||
const now = new Date();
|
||||
while (end > currentDate && currentDate < now) {
|
||||
@@ -110,7 +98,8 @@ const generateCurvedStatistics = (
|
||||
metered: boolean
|
||||
): StatisticValue[] => {
|
||||
const statistics: StatisticValue[] = [];
|
||||
let currentDate = getFirstDate(start, period);
|
||||
let currentDate = new Date(start);
|
||||
currentDate.setMinutes(0, 0, 0);
|
||||
let sum = initValue;
|
||||
const hours = differenceInHours(end, start) - 1;
|
||||
let i = 0;
|
||||
@@ -173,9 +162,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = addHours(start, 10);
|
||||
const morningEnd = new Date(start.getTime() + 10 * 60 * 60 * 1000);
|
||||
const morningLow = generateSumStatistics(start, morningEnd, period, 0, 0.7);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const morningFinalVal = morningLow.length
|
||||
? morningLow[morningLow.length - 1].sum!
|
||||
: 0;
|
||||
@@ -210,8 +199,8 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const morningEnd = addHours(start, 9);
|
||||
const eveningStart = addHours(start, 20);
|
||||
const morningEnd = new Date(start.getTime() + 9 * 60 * 60 * 1000);
|
||||
const eveningStart = new Date(start.getTime() + 20 * 60 * 60 * 1000);
|
||||
const highTarif = generateSumStatistics(
|
||||
morningEnd,
|
||||
eveningStart,
|
||||
@@ -250,12 +239,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const productionStart = addHours(start, 9);
|
||||
const productionEnd = addHours(start, 21);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const productionStart = new Date(start.getTime() + 9 * 60 * 60 * 1000);
|
||||
const productionEnd = new Date(start.getTime() + 21 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(endOfDay(productionEnd));
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -294,12 +280,9 @@ const statisticsFunctions: Record<
|
||||
period === "day" ? 17 : 504
|
||||
);
|
||||
}
|
||||
const productionStart = addHours(start, 7);
|
||||
const productionEnd = addHours(start, 23);
|
||||
const dayEnd = constructFrom(
|
||||
end,
|
||||
Math.min(endOfDay(productionEnd).getTime(), end.getTime())
|
||||
);
|
||||
const productionStart = new Date(start.getTime() + 7 * 60 * 60 * 1000);
|
||||
const productionEnd = new Date(start.getTime() + 23 * 60 * 60 * 1000);
|
||||
const dayEnd = new Date(endOfDay(productionEnd));
|
||||
const production = generateCurvedStatistics(
|
||||
productionStart,
|
||||
productionEnd,
|
||||
@@ -346,9 +329,8 @@ export const mockRecorder = (mockHass: MockHomeAssistant) => {
|
||||
mockHass.mockWS(
|
||||
"recorder/statistics_during_period",
|
||||
({ statistic_ids, start_time, end_time, period }, hass): Statistics => {
|
||||
// Calendar buckets follow the server timezone, independent of the browser.
|
||||
const start = new TZDate(start_time, hass.config.time_zone);
|
||||
const end = new TZDate(end_time ?? Date.now(), hass.config.time_zone);
|
||||
const start = new Date(start_time);
|
||||
const end = end_time ? new Date(end_time) : new Date();
|
||||
|
||||
const statistics: Record<string, StatisticValue[]> = {};
|
||||
const cache: Record<string, StatisticValue[]> = {};
|
||||
|
||||
@@ -33,4 +33,4 @@ We want to make it as easy for designers to contribute as it is for developers.
|
||||
|
||||
## Developers
|
||||
|
||||
Everything you need to get started developing can be found in our <a href="https://contributors.home-assistant.io/frontend/" rel="noopener noreferrer" target="_blank">Home Assistant contributor docs</a>.
|
||||
Everything you need to get started developing can be found in our <a href="https://developers.home-assistant.io" rel="noopener noreferrer" target="_blank">Home Assistant Developer Docs</a>.
|
||||
@@ -271,7 +271,7 @@ const CONFIGS = [
|
||||
|
||||
### ha-alert
|
||||
|
||||
You can use our [\`ha-alert\`](https://contributors.home-assistant.io/design/components/alert/) component in markdown content rendered in the Home Assistant Frontend.
|
||||
You can use our [\`ha-alert\`](https://design.home-assistant.io/#components/ha-alert) component in markdown content rendered in the Home Assistant Frontend.
|
||||
|
||||
<ha-alert alert-type="error">This is an error alert — check it out!</ha-alert>
|
||||
<ha-alert alert-type="warning">This is a warning alert — check it out!</ha-alert>
|
||||
|
||||
@@ -109,29 +109,6 @@ const disabledFailedUnloadEntry = createConfigEntry(
|
||||
}
|
||||
);
|
||||
|
||||
const brandEntries: ConfigEntryExtended[] = (
|
||||
[
|
||||
["hue", "Philips Hue", "loaded"],
|
||||
["hue", "Philips Hue", "setup_error"],
|
||||
["mqtt", "MQTT", "setup_retry"],
|
||||
["zha", "Zigbee Home Automation", "not_loaded"],
|
||||
["sonos", "Sonos", "setup_error"],
|
||||
["shelly", "Shelly", "failed_unload"],
|
||||
["spotify", "Spotify", "setup_retry"],
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
createConfigEntry(`${name} ${entryState}`, {
|
||||
domain,
|
||||
localized_domain_name: name,
|
||||
state: entryState,
|
||||
})
|
||||
);
|
||||
|
||||
const configFlows: DataEntryFlowProgressExtended[] = [
|
||||
{
|
||||
flow_id: "adbb401329d8439ebb78ef29837826a8",
|
||||
@@ -348,21 +325,6 @@ export class DemoIntegrationCard extends LitElement {
|
||||
.deviceRegistryEntries=${createDeviceRegistryEntries(loadedEntry)}
|
||||
></ha-integration-card>
|
||||
</div>
|
||||
<div class="container">
|
||||
${brandEntries.map(
|
||||
(entry) => html`
|
||||
<ha-integration-card
|
||||
.domain=${entry.domain}
|
||||
.items=${[entry]}
|
||||
.manifest=${createManifest(
|
||||
this.isCustomIntegration,
|
||||
this.isCloud
|
||||
)}
|
||||
.entityRegistryEntries=${createEntityRegistryEntries(entry)}
|
||||
></ha-integration-card>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ It is similar to the core frontend dev.
|
||||
- `"mounts": ["source=/path/to/hass/frontend,target=/workspaces/frontend,type=bind,consistency=cached"]`
|
||||
- use the dev container
|
||||
- start the dev server with following optional env vars:
|
||||
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://contributors.home-assistant.io/os/supervisor/core-connection/#get-access-to-the-supervisor-api) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
|
||||
- `SUPERVISOR_HOST` to have real supervisor data, you can [setup a supervisor remote API access](https://developers.home-assistant.io/docs/supervisor/development/#supervisor-api-access) and set the host of your supervisor. e.g.: `SUPERVISOR_HOST=192.168.0.20:8888`
|
||||
- `SUPERVISOR_TOKEN` the supervisor api token you get from the Remote API proxy Addon Logs
|
||||
- `FRONTEND_PATH` the path inside your container should be `/workspaces/frontend`
|
||||
- example: `SUPERVISOR_TOKEN=abc123 SUPERVISOR_HOST=192.168.0.20:8888 FRONTEND_PATH=/workspaces/frontend go run main.go http.go mdns.go`
|
||||
|
||||
@@ -6,7 +6,7 @@ export default {
|
||||
],
|
||||
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
|
||||
"translations/*/*.json": (files) =>
|
||||
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://contributors.home-assistant.io/translate/ for details." ' +
|
||||
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
|
||||
files.join(" ") +
|
||||
" >&2 && exit 1",
|
||||
"pnpm-lock.yaml": () => "pnpm dedupe",
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.13.1",
|
||||
"@codemirror/lint": "6.9.7",
|
||||
@@ -63,7 +62,13 @@
|
||||
"@formatjs/intl-numberformat": "9.4.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.15",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.15",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.1",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.0",
|
||||
"@lezer/common": "1.5.3",
|
||||
"@lezer/highlight": "1.2.5",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
@@ -80,7 +85,7 @@
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@versatiles/style": "7.0.1",
|
||||
"@versatiles/style": "6.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
@@ -92,25 +97,24 @@
|
||||
"cropperjs": "1.7.0",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.4.0",
|
||||
"decimal.js": "10.6.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.5.0",
|
||||
"js-yaml": "5.4.3",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"marked": "18.1.1",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"marked": "18.1.0",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -123,7 +127,6 @@
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"temporal-polyfill": "1.0.6",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
@@ -133,30 +136,29 @@
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.15",
|
||||
"zrender": "6.1.0"
|
||||
"zrender": "6.1.0",
|
||||
"zxing-wasm": "3.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.7",
|
||||
"@babel/helper-compilation-targets": "8.0.7",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-class-properties": "8.0.1",
|
||||
"@babel/plugin-transform-private-methods": "8.0.1",
|
||||
"@babel/plugin-transform-runtime": "8.0.6",
|
||||
"@babel/preset-env": "8.0.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@html-eslint/eslint-plugin": "0.66.1",
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@playwright/test": "1.64.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.2.9",
|
||||
"@rspack/core": "2.2.8",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.27",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/culori": "4.0.1",
|
||||
@@ -165,24 +167,25 @@
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.6",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.0.4",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.3",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"core-js-compat": "3.50.0",
|
||||
"del": "9.0.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint": "10.12.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.3",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
@@ -195,7 +198,7 @@
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.1.3",
|
||||
"jsdom": "30.1.2",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
@@ -214,16 +217,16 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"typescript-eslint": "8.71.1",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.0.4",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]4.0",
|
||||
"packageManager": "[email protected]1.2",
|
||||
"volta": {
|
||||
"node": "24.22.0"
|
||||
"node": "24.21.0"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ overrides:
|
||||
lit-html: 3.3.3
|
||||
clean-css: 5.3.3
|
||||
"@lit/reactive-element": 2.1.2
|
||||
"@fullcalendar/daygrid": 6.1.21
|
||||
tslib: 2.8.1
|
||||
|
||||
patchedDependencies:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="16" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="20" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 408 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,5 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="36" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="40" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 330 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="56" height="16" viewBox="0 0 56 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="56" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
<rect x="60" width="16" height="16" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 252 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="black" fill-opacity="0.32"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -0,0 +1,3 @@
|
||||
<svg width="76" height="16" viewBox="0 0 76 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="76" height="16" rx="3" fill="white" fill-opacity="0.48"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
@@ -98,11 +98,6 @@
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group ECharts with zrender, which we import directly and must match the version echarts depends on",
|
||||
"groupName": "echarts",
|
||||
"matchPackageNames": ["echarts", "zrender"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
|
||||
@@ -29,7 +29,7 @@ const ALLOWED_LICENSES = new Set([
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT OR Apache-2.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"PSF-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
|
||||
"Unlicense",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Add a value to a most recently used list. The value goes first, also when it
|
||||
* was already in the list, and the list is capped at `max`.
|
||||
* Entries rejected by `isValid` are dropped first, so they don't take up slots.
|
||||
*/
|
||||
export const addRecent = <T>(
|
||||
list: readonly T[] | undefined,
|
||||
value: T,
|
||||
max: number,
|
||||
isValid?: (item: T) => boolean
|
||||
): T[] => {
|
||||
const current = Array.isArray(list)
|
||||
? list.filter((item) => item !== value && (!isValid || isValid(item)))
|
||||
: [];
|
||||
return [value, ...current].slice(0, max);
|
||||
};
|
||||
@@ -45,168 +45,69 @@ export const formatNumericDuration = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const DURATION_FIELDS = [
|
||||
["days", "day"],
|
||||
["hours", "hour"],
|
||||
["minutes", "minute"],
|
||||
["seconds", "second"],
|
||||
["milliseconds", "millisecond"],
|
||||
] as const satisfies readonly (readonly [keyof HaDurationData, string])[];
|
||||
|
||||
type DurationFormatOptions = {
|
||||
style: "long" | "narrow" | "digital";
|
||||
} & Partial<Record<`${keyof HaDurationData}Display`, "always" | "auto">>;
|
||||
|
||||
type DurationFormatter = (duration: HaDurationData) => string;
|
||||
|
||||
// Throws like Intl.DurationFormat on fractions, NaN and mixed signs
|
||||
const isNegativeDuration = (duration: HaDurationData) => {
|
||||
const values = DURATION_FIELDS.map(([field]) => Number(duration[field] ?? 0));
|
||||
const negative = values.some((value) => value < 0);
|
||||
if (
|
||||
!values.every((value) => Number.isInteger(value)) ||
|
||||
(negative && values.some((value) => value > 0))
|
||||
) {
|
||||
throw new RangeError("Invalid duration");
|
||||
}
|
||||
return negative;
|
||||
};
|
||||
|
||||
const fallbackUnitsFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const style = options.style === "narrow" ? "narrow" : "long";
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style });
|
||||
const unitFormats = DURATION_FIELDS.map(
|
||||
([, unit]) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit,
|
||||
unitDisplay: style,
|
||||
})
|
||||
);
|
||||
return (duration) => {
|
||||
const negative = isNegativeDuration(duration);
|
||||
const parts: string[] = [];
|
||||
DURATION_FIELDS.forEach(([field], index) => {
|
||||
const value = Math.abs(duration[field] ?? 0);
|
||||
if (value || options[`${field}Display`] === "always") {
|
||||
// Like Intl.DurationFormat, only the first unit shows the sign
|
||||
parts.push(
|
||||
unitFormats[index].format(negative && !parts.length ? -value : value)
|
||||
);
|
||||
}
|
||||
});
|
||||
return list.format(parts);
|
||||
};
|
||||
};
|
||||
|
||||
const fallbackDigitalFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style: "short" });
|
||||
const dayFormat = new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit: "day",
|
||||
unitDisplay: "short",
|
||||
});
|
||||
const hourFormat = new Intl.NumberFormat(language, { useGrouping: false });
|
||||
const twoDigitFormat = new Intl.NumberFormat(language, {
|
||||
minimumIntegerDigits: 2,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: false,
|
||||
});
|
||||
return (duration) => {
|
||||
const sign = isNegativeDuration(duration) ? "-" : "";
|
||||
const days = Math.abs(duration.days ?? 0);
|
||||
const hours = Math.abs(duration.hours ?? 0);
|
||||
const minutes = Math.abs(duration.minutes ?? 0);
|
||||
const seconds =
|
||||
Math.abs(duration.seconds ?? 0) +
|
||||
Math.abs(duration.milliseconds ?? 0) / 1000;
|
||||
let time = `${twoDigitFormat.format(minutes)}:${twoDigitFormat.format(seconds)}`;
|
||||
if (hours || options.hoursDisplay !== "auto") {
|
||||
time = `${hourFormat.format(hours)}:${time}`;
|
||||
}
|
||||
return days
|
||||
? list.format([sign + dayFormat.format(days), time])
|
||||
: sign + time;
|
||||
};
|
||||
};
|
||||
|
||||
// The Intl.DurationFormat polyfill needs BigInt, which some legacy browsers lack
|
||||
const createDurationFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
if ("DurationFormat" in Intl) {
|
||||
const durationFormat = new Intl.DurationFormat(language, options);
|
||||
return (duration) => durationFormat.format(duration);
|
||||
}
|
||||
return options.style === "digital"
|
||||
? fallbackDigitalFormat(language, options)
|
||||
: fallbackUnitsFormat(language, options);
|
||||
};
|
||||
|
||||
const formatDurationLongMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
const formatDurationLongMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationLong = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationLongMem(locale)(duration);
|
||||
) => formatDurationLongMem(locale).format(duration);
|
||||
|
||||
const formatDurationNarrowMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
const formatDurationNarrowMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationNarrow = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationNarrowMem(locale)(duration);
|
||||
) => formatDurationNarrowMem(locale).format(duration);
|
||||
|
||||
const formatDigitalDurationMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
const formatDigitalDurationMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationDigital = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDigitalDurationMem(locale)(duration);
|
||||
) => formatDigitalDurationMem(locale).format(duration);
|
||||
|
||||
export const DURATION_UNITS = ["min", "h", "d"] as const;
|
||||
|
||||
type DurationUnit = (typeof DURATION_UNITS)[number];
|
||||
|
||||
const formatDurationDayMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
const formatDurationDayMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationHourMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
const formatDurationHourMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationMinuteMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
const formatDurationMinuteMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDuration = (
|
||||
@@ -228,7 +129,7 @@ export const formatDuration = (
|
||||
days,
|
||||
hours,
|
||||
};
|
||||
return formatDurationDayMem(locale)(input);
|
||||
return formatDurationDayMem(locale).format(input);
|
||||
}
|
||||
case "h": {
|
||||
const hours = Math.floor(value);
|
||||
@@ -237,7 +138,7 @@ export const formatDuration = (
|
||||
hours,
|
||||
minutes,
|
||||
};
|
||||
return formatDurationHourMem(locale)(input);
|
||||
return formatDurationHourMem(locale).format(input);
|
||||
}
|
||||
case "min": {
|
||||
const minutes = Math.floor(value);
|
||||
@@ -246,7 +147,7 @@ export const formatDuration = (
|
||||
minutes,
|
||||
seconds,
|
||||
};
|
||||
return formatDurationMinuteMem(locale)(input);
|
||||
return formatDurationMinuteMem(locale).format(input);
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid duration unit");
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { timeZonesNames } from "@vvo/tzdb";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
|
||||
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
|
||||
// instead of an IANA zone name. Only accept values that are known IANA zones,
|
||||
// matching the list used by ha-timezone-picker.
|
||||
const isKnownTimeZone = (zone: string | undefined): zone is string =>
|
||||
!!zone &&
|
||||
(zone === "UTC" || zone === "Etc/UTC" || timeZonesNames.includes(zone));
|
||||
|
||||
// The FormatJS polyfill reports the canonical zone (Europe/Amsterdam becomes
|
||||
// Europe/Brussels), so the name read before it loaded comes first. Its own
|
||||
// name still covers old aliases the list lacks, like Asia/Calcutta.
|
||||
const RESOLVED_TIME_ZONE = [
|
||||
globalThis.__haNativeTimeZone,
|
||||
Intl.DateTimeFormat?.().resolvedOptions?.().timeZone,
|
||||
].find(isKnownTimeZone);
|
||||
const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
timeZonesNames.includes(RESOLVED_RAW))
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export const listenMediaQuery = (
|
||||
): MediaQueriesListener => {
|
||||
const mql = matchMedia(mediaQuery);
|
||||
const listener = (e) => matchesChanged(e.matches);
|
||||
mql.addEventListener("change", listener);
|
||||
mql.addListener(listener);
|
||||
matchesChanged(mql.matches);
|
||||
return () => mql.removeEventListener("change", listener);
|
||||
return () => mql.removeListener(listener);
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
|
||||
export const ALERT_DOMAINS = [
|
||||
"alarm_control_panel",
|
||||
"binary_sensor",
|
||||
"cover",
|
||||
"lock",
|
||||
];
|
||||
|
||||
export const DEFAULT_ALERT_COLOR = "red";
|
||||
|
||||
export const computeAlertActiveStates = (entityId: string): string[] => {
|
||||
switch (computeDomain(entityId)) {
|
||||
case "alarm_control_panel":
|
||||
return ["triggered"];
|
||||
case "cover":
|
||||
return ["open"];
|
||||
case "lock":
|
||||
return ["jammed", "unlocked", "open"];
|
||||
default:
|
||||
return ["on"];
|
||||
}
|
||||
};
|
||||
|
||||
export const isAlertActive = (stateObj: HassEntity): boolean =>
|
||||
computeAlertActiveStates(stateObj.entity_id).includes(stateObj.state);
|
||||
@@ -70,10 +70,13 @@ export const supportsWebGL2 = (): boolean => {
|
||||
|
||||
// The one gate for MapLibre, whether through the Leaflet adapter or the
|
||||
// engine `ha-map` drives directly. Its worker is bundled per build target
|
||||
// like the rest of it, so wherever the page runs, the worker runs too: the
|
||||
// legacy floor (Chrome 67 / Safari 15) guarantees the BigInt literals MapLibre
|
||||
// uses, which Babel cannot transpile.
|
||||
export const supportsVectorMaps = (): boolean => supportsWebGL2();
|
||||
// like the rest of it, so wherever the page runs, the worker runs too - with
|
||||
// one exception. Babel transpiles everything MapLibre is written in except
|
||||
// BigInt literals, which have no ES2017 spelling. A browser without BigInt
|
||||
// (Chromium below 67, such as Fire OS 5 tablets) cannot parse the chunk, and
|
||||
// a worker that cannot parse never reports back, hence the check up front.
|
||||
export const supportsVectorMaps = (): boolean =>
|
||||
supportsWebGL2() && typeof BigInt === "function";
|
||||
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs, which is why those come from VersaTiles.
|
||||
|
||||
@@ -14,6 +14,7 @@ export type LocalizeKeys =
|
||||
| `ui.card.weather.attributes.${string}`
|
||||
| `ui.card.weather.cardinal_direction.${string}`
|
||||
| `ui.card.lawn_mower.actions.${string}`
|
||||
| `ui.common.${string}`
|
||||
| `ui.components.calendar.event.rrule.${string}`
|
||||
| `ui.components.selectors.file.${string}`
|
||||
| `ui.dialogs.entity_registry.editor.${string}`
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import type {
|
||||
DataTableFiltersValue,
|
||||
DataTableFiltersValues,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
decodeQueryParams,
|
||||
hasQueryParams,
|
||||
type QueryParamConfig,
|
||||
type QueryParamValues,
|
||||
type SearchParamsSource,
|
||||
} from "./query-params";
|
||||
|
||||
// The filters of the helpers table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const helpersFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
// Read until 2027.5, replaced with `assistants` on the next write
|
||||
"voice_assistant",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type HelpersFilterQueryParams = QueryParamValues<
|
||||
typeof helpersFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (value: DataTableFiltersValue): string[] | undefined =>
|
||||
Array.isArray(value) ? value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
value: DataTableFiltersValue
|
||||
): Record<string, string[]> | undefined =>
|
||||
value && !Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const helpersFilterQueryParams = (
|
||||
filters: DataTableFiltersValues
|
||||
): HelpersFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
});
|
||||
|
||||
// What a link says to show, undefined when it has no table params so what was
|
||||
// picked before stays. A link replaces the search and the filters, ones left
|
||||
// behind would hide part of what it shows. The table applies its sorting and
|
||||
// grouping.
|
||||
export interface HelpersLink {
|
||||
search: string;
|
||||
filters: DataTableFiltersValues;
|
||||
}
|
||||
|
||||
export const helpersLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): HelpersLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, helpersFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, helpersFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof HelpersFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
const assistants = values("assistants");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-labels": values("label"),
|
||||
"ha-filter-categories": values("category"),
|
||||
"ha-filter-voice-assistants": assistants.length
|
||||
? assistants
|
||||
: values("voice_assistant"),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -5,20 +5,14 @@ export const subscribePollingCollection = (
|
||||
updateData: (hass: HomeAssistant) => void,
|
||||
interval: number
|
||||
) => {
|
||||
let timeout: number | undefined;
|
||||
let cancelled = false;
|
||||
let timeout;
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
await updateData(hass);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timeout = window.setTimeout(() => fetchData(), interval);
|
||||
}
|
||||
timeout = setTimeout(() => fetchData(), interval);
|
||||
}
|
||||
};
|
||||
fetchData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
return () => clearTimeout(timeout);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
import { parsePlainText } from "zrender/lib/graphic/helper/parseText.js";
|
||||
|
||||
const FONT_FAMILY = "Roboto, Noto, sans-serif";
|
||||
// zrender's overflow "break" only wraps at these characters, see isWordBreakChar
|
||||
const WORD_BREAK =
|
||||
/[ ,&?/;\]]|[^\u0020-\u024F\u0370-\u10FF\u1200-\u13FF\u1E00-\u206F]/;
|
||||
|
||||
interface FitLabelOptions {
|
||||
/** Font size the label is measured at, and the largest size returned */
|
||||
fontSize: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Height of the label at `fontSize` before ECharts wraps it */
|
||||
unwrappedHeight: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Largest font size at which an ECharts label with `overflow: "break"`,
|
||||
* wrapped at `width`, fits in `height` without breaking a word.
|
||||
*/
|
||||
export function fitLabelFontSize(
|
||||
text: string,
|
||||
{ fontSize: baseFontSize, width, height, unwrappedHeight }: FitLabelOptions
|
||||
) {
|
||||
const font = `${baseFontSize}px ${FONT_FAMILY}`;
|
||||
const measure = (label: string, wrapWidth?: number) =>
|
||||
parsePlainText(
|
||||
label,
|
||||
{ font, width: wrapWidth, overflow: "break" },
|
||||
undefined,
|
||||
undefined
|
||||
);
|
||||
// ECharts measures at the final font size, leave room for rounding
|
||||
const fitWidth = Math.max(width - 1, 1);
|
||||
const unwrappedContentHeight = measure(text).contentHeight;
|
||||
const words = text.split(WORD_BREAK).filter(Boolean);
|
||||
// a label at a smaller font wraps like one at baseFontSize in a wider box
|
||||
const wrapWidthAt = (fontSize: number) =>
|
||||
(fitWidth * baseFontSize) / fontSize;
|
||||
const maxFontSize = (height / unwrappedHeight) * baseFontSize;
|
||||
const fits = (fontSize: number) => {
|
||||
const wrapWidth = wrapWidthAt(fontSize);
|
||||
if (words.some((word) => measure(word, wrapWidth).lines.length > 1)) {
|
||||
return false;
|
||||
}
|
||||
const wrappedHeight =
|
||||
unwrappedHeight +
|
||||
(measure(text, wrapWidth).contentHeight - unwrappedContentHeight);
|
||||
// exact at maxFontSize when the label doesn't wrap
|
||||
return fontSize <= maxFontSize * (unwrappedHeight / wrappedHeight);
|
||||
};
|
||||
let low = 0;
|
||||
let high = Math.min(baseFontSize, maxFontSize);
|
||||
if (fits(high)) {
|
||||
low = high;
|
||||
} else {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const mid = (low + high) / 2;
|
||||
if (fits(mid)) {
|
||||
low = mid;
|
||||
} else {
|
||||
high = mid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// zrender moves a space that doesn't fit to the next line, indenting it.
|
||||
// Shrinking further only widens the wrap, so the label still fits.
|
||||
const spaceWidth = measure(" ").contentWidth;
|
||||
const indents = (fontSize: number) =>
|
||||
text
|
||||
.split("\n")
|
||||
.some((paragraph) =>
|
||||
measure(paragraph, wrapWidthAt(fontSize)).lines.some(
|
||||
(line, i) => i > 0 && line.startsWith(" ")
|
||||
)
|
||||
);
|
||||
let fontSize = low;
|
||||
for (let i = 0; i < 3 && indents(fontSize); i++) {
|
||||
fontSize = (fitWidth * baseFontSize) / (wrapWidthAt(fontSize) + spaceWidth);
|
||||
}
|
||||
return fontSize;
|
||||
}
|
||||
@@ -280,7 +280,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
private _pendingUpdate?: Set<PropertyKey>;
|
||||
|
||||
private _legendItemCount?: number;
|
||||
private _pendingOptions?: HaECOption;
|
||||
|
||||
private _pendingZoom?: [number, number, boolean];
|
||||
|
||||
@@ -292,6 +292,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._tooltipHiddenWhilePanning = false;
|
||||
this._pendingSetup = false;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
this._pendingZoom = undefined;
|
||||
// The observers are about to be torn down, so nothing would correct a stale
|
||||
// value if this element is reattached inside a hidden container.
|
||||
@@ -444,6 +445,11 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (!this._pendingUpdate) {
|
||||
this._pendingUpdate = new Set();
|
||||
}
|
||||
if (prop === "options" && !this._pendingUpdate.has(prop)) {
|
||||
// The one previous value a replay reads, and it has to stay the options
|
||||
// the chart currently renders, not those of a later deferred change.
|
||||
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
|
||||
}
|
||||
this._pendingUpdate.add(prop);
|
||||
}
|
||||
}
|
||||
@@ -460,8 +466,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
const pending = this._pendingUpdate!;
|
||||
const previousOptions = this._pendingOptions;
|
||||
this._pendingUpdate = undefined;
|
||||
this._applyChartUpdate(pending);
|
||||
this._pendingOptions = undefined;
|
||||
this._applyChartUpdate(pending, previousOptions);
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
@@ -477,6 +485,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (invisible) {
|
||||
this._pendingSetup = true;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
} else {
|
||||
this._setupChart();
|
||||
}
|
||||
@@ -494,10 +503,16 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._applyChartUpdate(changedProps);
|
||||
this._applyChartUpdate(
|
||||
changedProps,
|
||||
changedProps.get("options") as HaECOption | undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _applyChartUpdate(changedProps: { has: (prop: string) => boolean }) {
|
||||
private _applyChartUpdate(
|
||||
changedProps: { has: (prop: string) => boolean },
|
||||
previousOptions: HaECOption | undefined
|
||||
) {
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
|
||||
chartOptions.series = this._getSeries();
|
||||
@@ -517,28 +532,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
this._disposeSonification();
|
||||
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
|
||||
// custom legend changes may require a resize to layout properly
|
||||
this._shouldResizeChart = true;
|
||||
this._resizeAnimationDuration = 250;
|
||||
}
|
||||
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
|
||||
chartOptions.dataZoom = this._getDataZoomConfig();
|
||||
}
|
||||
let legendChanged = false;
|
||||
if (changedProps.has("options") || changedProps.has("data")) {
|
||||
const legendItemCount = this._getLegendItems()?.length;
|
||||
legendChanged = legendItemCount !== this._legendItemCount;
|
||||
this._legendItemCount = legendItemCount;
|
||||
}
|
||||
if (Object.keys(chartOptions).length > 0) {
|
||||
this._setChartOptions(chartOptions);
|
||||
if (chartOptions.series || changedProps.has("_isZoomed")) {
|
||||
this._updateSankeyRoam();
|
||||
}
|
||||
}
|
||||
if (legendChanged) {
|
||||
// Within a fixed height the host does not resize when the legend grows.
|
||||
// Without animation, setOption has already fired "finished" by now.
|
||||
this._shouldResizeChart = true;
|
||||
this._resizeAnimationDuration = 250;
|
||||
this.updateComplete.then(() => this._resizeChartIfNeeded());
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -825,6 +832,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._loading = true;
|
||||
this._pendingSetup = false;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
try {
|
||||
// The connection holds a reference to the chart instance, so it cannot
|
||||
// outlive it. Focusing the chart again reconnects.
|
||||
@@ -875,14 +883,13 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
const sankeySeries = series?.find((s: any) => s.type === "sankey");
|
||||
const zoomed = Math.abs(sankeySeries.zoom - 1) > 1e-6;
|
||||
this._isZoomed = zoomed;
|
||||
// Before setOption, which lays the labels out again
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
|
||||
if (!zoomed) {
|
||||
// Reset center when fully zoomed out
|
||||
this.chart!.setOption({
|
||||
series: [{ id: sankeySeries.id, center: null }],
|
||||
});
|
||||
}
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: sankeySeries.zoom });
|
||||
// Clear cached emphasis states so labels don't revert to pre-zoom sizes
|
||||
this.chart!.dispatchAction({ type: "downplay" });
|
||||
});
|
||||
@@ -995,7 +1002,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
...this._createOptions(),
|
||||
series: this._getSeries(),
|
||||
});
|
||||
this._legendItemCount = this._getLegendItems()?.length;
|
||||
this._updateSankeyRoam();
|
||||
if (this._pendingZoom) {
|
||||
const [start, end, silent] = this._pendingZoom;
|
||||
@@ -1452,7 +1458,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (this._isZoomed) {
|
||||
this._handleZoomReset();
|
||||
} else {
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: 2 });
|
||||
this.chart.setOption({
|
||||
series: sankeySeries.map((s: any) => ({
|
||||
id: s.id,
|
||||
@@ -1497,19 +1502,15 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _handleZoomReset() {
|
||||
const option = this.chart?.getOption();
|
||||
const sankeySeries = (option?.series as any[])?.filter(
|
||||
(s: any) => s.type === "sankey"
|
||||
);
|
||||
// Before the dataZoom action and setOption, which lay the labels out again
|
||||
if (sankeySeries?.length) {
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
|
||||
}
|
||||
// A dataZoom action reruns the layout of every series, graphs included
|
||||
if (this._isZoomed) {
|
||||
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
|
||||
}
|
||||
// Reset sankey roam zoom
|
||||
const option = this.chart?.getOption();
|
||||
const sankeySeries = (option?.series as any[])?.filter(
|
||||
(s: any) => s.type === "sankey"
|
||||
);
|
||||
if (sankeySeries?.length) {
|
||||
this.chart?.setOption({
|
||||
series: sankeySeries.map((s: any) => ({
|
||||
@@ -1519,6 +1520,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
})),
|
||||
});
|
||||
this._isZoomed = false;
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
|
||||
}
|
||||
this._getGraphRoams().forEach(({ series, zoom, x, y }) => {
|
||||
this.chart!.dispatchAction({
|
||||
@@ -1870,6 +1872,22 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._resizeAnimationDuration = undefined;
|
||||
}
|
||||
|
||||
private _compareCustomLegendOptions(
|
||||
oldOptions: HaECOption | undefined,
|
||||
newOptions: HaECOption | undefined
|
||||
): boolean {
|
||||
const oldLegends = ensureArray(
|
||||
oldOptions?.legend || []
|
||||
) as LegendComponentOption[];
|
||||
const newLegends = ensureArray(
|
||||
newOptions?.legend || []
|
||||
) as LegendComponentOption[];
|
||||
return (
|
||||
oldLegends.some((l) => l.show && l.type === "custom") !==
|
||||
newLegends.some((l) => l.show && l.type === "custom")
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
@@ -32,7 +32,6 @@ export interface NetworkNode {
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
};
|
||||
label?: { position: "left" | "right" | "top" | "bottom" };
|
||||
fixed?: boolean;
|
||||
/**
|
||||
* Distance from the center, where 0 is the center and 1 is the edge
|
||||
@@ -316,7 +315,6 @@ export class HaNetworkGraph extends LitElement {
|
||||
symbolSize: node.symbolSize || 30,
|
||||
symbol: node.symbol || "circle",
|
||||
itemStyle: node.itemStyle || {},
|
||||
label: node.label,
|
||||
fixed: node.fixed,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fitLabelFontSize } from "./fit-label-font-size";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import "../ha-alert";
|
||||
@@ -238,10 +237,8 @@ export class HaSankeyChart extends LitElement {
|
||||
distance: LABEL_DISTANCE,
|
||||
minMargin: LABEL_MIN_MARGIN,
|
||||
overflow: "break",
|
||||
lineOverflow: this.vertical ? "truncate" : "none",
|
||||
},
|
||||
labelLayout: (params) => {
|
||||
// params.rect is already zoomed, only the gap slack needs scaling
|
||||
if (this.vertical) {
|
||||
// reduce the label font size so the longest word fits on one line
|
||||
const longestWord = params.text
|
||||
@@ -260,7 +257,7 @@ export class HaSankeyChart extends LitElement {
|
||||
: longest;
|
||||
}, "");
|
||||
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
|
||||
const availableWidth = params.rect.width + 6 * this._currentZoom;
|
||||
const availableWidth = (params.rect.width + 6) * this._currentZoom;
|
||||
// minMargin is applied as padding on the label box, so words must
|
||||
// fit in the inner wrap width or overflow:break splits them.
|
||||
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
|
||||
@@ -269,25 +266,21 @@ export class HaSankeyChart extends LitElement {
|
||||
(wrapWidth / wordWidth) * FONT_SIZE
|
||||
);
|
||||
return {
|
||||
fontSize,
|
||||
// Safari draws a 0px canvas font at its default size, so hide
|
||||
// unreadable labels by dropping all of their lines instead.
|
||||
height: fontSize > 1 ? undefined : 0,
|
||||
fontSize: fontSize > 1 ? fontSize : 0,
|
||||
width: availableWidth,
|
||||
align: "center",
|
||||
dy: -2, // shift up or the lowest row labels may be cut off
|
||||
};
|
||||
}
|
||||
|
||||
const availableHeight = params.rect.height + 8 * this._currentZoom; // account for the margin
|
||||
const availableHeight = (params.rect.height + 8) * this._currentZoom; // account for the margin
|
||||
const fontSize = Math.min(
|
||||
(availableHeight / params.labelRect.height) * FONT_SIZE,
|
||||
FONT_SIZE
|
||||
);
|
||||
return {
|
||||
// labelRect is measured before the width is applied, so unwrapped
|
||||
fontSize: fitLabelFontSize(params.text, {
|
||||
fontSize: FONT_SIZE,
|
||||
width: labelSpace,
|
||||
height: availableHeight,
|
||||
unwrappedHeight: params.labelRect.height,
|
||||
}),
|
||||
fontSize,
|
||||
lineHeight: fontSize,
|
||||
width: labelSpace,
|
||||
height: params.rect.height,
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ export class HaFilterChip extends HaChipBase {
|
||||
// Dispatch from the chip so listeners see the new selection before deciding
|
||||
// whether to cancel it. Preserve pointer and modifier-key information.
|
||||
const click =
|
||||
event instanceof PointerEvent
|
||||
typeof PointerEvent !== "undefined" && event instanceof PointerEvent
|
||||
? new PointerEvent("click", event)
|
||||
: new MouseEvent("click", event);
|
||||
|
||||
|
||||
@@ -1504,10 +1504,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table {
|
||||
background: var(
|
||||
--ha-data-table-background,
|
||||
var(--data-table-background-color)
|
||||
);
|
||||
background-color: var(--data-table-background-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
@@ -1765,6 +1762,10 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--numeric {
|
||||
text-align: var(--float-end);
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.sortable:hover,
|
||||
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) {
|
||||
text-align: var(--float-start);
|
||||
}
|
||||
|
||||
/* custom from here */
|
||||
|
||||
@@ -1779,6 +1780,7 @@ export class HaDataTable extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.group-header ha-icon-button {
|
||||
@@ -1837,8 +1839,8 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell > * {
|
||||
transition: var(--float-start) 0.2s ease;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric ha-svg-icon {
|
||||
transition: var(--float-end) 0.2s ease;
|
||||
.mdc-data-table__header-cell--numeric > span {
|
||||
transition: none;
|
||||
}
|
||||
.mdc-data-table__header-cell ha-svg-icon {
|
||||
top: -3px;
|
||||
@@ -1861,25 +1863,6 @@ export class HaDataTable extends LitElement {
|
||||
inset-inline-start: 12px;
|
||||
inset-inline-end: initial;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.not-sorted ha-svg-icon {
|
||||
right: -20px;
|
||||
inset-inline-end: -20px;
|
||||
inset-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted)
|
||||
ha-svg-icon,
|
||||
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted
|
||||
ha-svg-icon {
|
||||
right: 12px;
|
||||
inset-inline-end: 12px;
|
||||
inset-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__header-cell--numeric.sortable:not(.not-sorted) span,
|
||||
.mdc-data-table__header-cell--numeric.sortable:hover.not-sorted span {
|
||||
left: -24px;
|
||||
inset-inline-start: -24px;
|
||||
inset-inline-end: initial;
|
||||
}
|
||||
.table-header {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
@@ -1929,10 +1912,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table__row:has(.group-header) {
|
||||
background: var(
|
||||
--ha-data-table-background,
|
||||
var(--primary-background-color)
|
||||
);
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.mdc-data-table__table.auto-height .scroller {
|
||||
|
||||
@@ -188,8 +188,8 @@ const sortData = (
|
||||
: b[column.valueColumn || sortColumn];
|
||||
|
||||
if (column.type === "numeric") {
|
||||
valA = valA == null || isNaN(valA) ? undefined : Number(valA);
|
||||
valB = valB == null || isNaN(valB) ? undefined : Number(valB);
|
||||
valA = isNaN(valA) ? undefined : Number(valA);
|
||||
valB = isNaN(valB) ? undefined : Number(valB);
|
||||
} else if (column.type === "ip") {
|
||||
return sort * ipCompare(valA, valB);
|
||||
} else if (typeof valA === "string" && typeof valB === "string") {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { STATES_OFF } from "../../common/const";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { apiContext, formattersContext } from "../../data/context";
|
||||
@@ -14,7 +13,6 @@ import { forwardHaptic } from "../../data/haptics";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
import { getToggleAction } from "../../common/entity/get_toggle_action";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
@@ -39,10 +37,6 @@ export class HaEntityToggle extends LitElement {
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters?: ContextType<typeof formattersContext>;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property() public label?: string;
|
||||
@@ -55,14 +49,6 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
|
||||
const name = this._formatters?.formatEntityName(this.stateObj) ?? "";
|
||||
const turnOnLabel = this._localize(
|
||||
"ui.components.entity.entity-toggle.turn_on",
|
||||
{ name }
|
||||
);
|
||||
const turnOffLabel = this._localize(
|
||||
"ui.components.entity.entity-toggle.turn_off",
|
||||
{ name }
|
||||
);
|
||||
|
||||
if (
|
||||
this.stateObj.attributes.assumed_state ||
|
||||
@@ -70,7 +56,7 @@ export class HaEntityToggle extends LitElement {
|
||||
) {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.label=${turnOffLabel}
|
||||
.label=${`Turn ${name} off`}
|
||||
.path=${mdiFlashOff}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@click=${this._turnOff}
|
||||
@@ -79,7 +65,7 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
.label=${turnOnLabel}
|
||||
.label=${`Turn ${name} on`}
|
||||
.path=${mdiFlash}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@click=${this._turnOn}
|
||||
@@ -89,10 +75,7 @@ export class HaEntityToggle extends LitElement {
|
||||
}
|
||||
|
||||
return html`<ha-switch
|
||||
.accessibleLabel=${this._localize(
|
||||
"ui.components.entity.entity-toggle.toggle",
|
||||
{ name }
|
||||
)}
|
||||
aria-label=${`Toggle ${name} ${this._isOn ? "off" : "on"}`}
|
||||
.checked=${this._isOn}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@change=${this._toggleChanged}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mdiUnfoldMoreHorizontal } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
@@ -14,10 +13,6 @@ import {
|
||||
state as litState,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import "./ha-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
const LINE_TIMESTAMP = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/;
|
||||
|
||||
interface State {
|
||||
bold: boolean;
|
||||
@@ -35,27 +30,12 @@ export class HaAnsiToHtml extends LitElement {
|
||||
@property({ type: Boolean, attribute: "wrap-disabled" }) public wrapDisabled =
|
||||
false;
|
||||
|
||||
// Lines to show before and after each line that matches the filter.
|
||||
@property({ type: Number, attribute: "context-lines" }) public contextLines =
|
||||
0;
|
||||
|
||||
// Text for the button that shows a run of hidden lines between matches.
|
||||
@property({ attribute: false }) public expandLabel?: (
|
||||
count: number
|
||||
) => string;
|
||||
|
||||
@query("pre") private _pre?: HTMLPreElement;
|
||||
|
||||
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
|
||||
|
||||
@litState() private _filter = "";
|
||||
|
||||
// Hidden lines the user chose to show while a filter is set.
|
||||
private _expandedLines = new WeakSet<HTMLDivElement>();
|
||||
|
||||
// The hidden lines each expand row shows when clicked.
|
||||
private _expandGaps = new WeakMap<HTMLElement, HTMLDivElement[]>();
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<pre class=${classMap({ wrap: !this.wrapDisabled })}></pre>`;
|
||||
}
|
||||
@@ -77,15 +57,6 @@ export class HaAnsiToHtml extends LitElement {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
.context {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.expand {
|
||||
display: block;
|
||||
margin: var(--ha-space-1) 0;
|
||||
font-family: var(--ha-font-family-body);
|
||||
white-space: normal;
|
||||
}
|
||||
.bold {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
}
|
||||
@@ -148,7 +119,8 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
|
||||
::highlight(search-results) {
|
||||
background-color: var(--ha-color-fill-primary-normal-resting);
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -350,16 +322,12 @@ export class HaAnsiToHtml extends LitElement {
|
||||
* @returns true if there are lines to display
|
||||
*/
|
||||
filterLines(filter: string): boolean {
|
||||
if (filter !== this._filter) {
|
||||
this._expandedLines = new WeakSet();
|
||||
}
|
||||
this._filter = filter;
|
||||
const lines = this._divs;
|
||||
let numberOfFoundLines = 0;
|
||||
if (!filter) {
|
||||
lines.forEach((line) => {
|
||||
line.style.display = "";
|
||||
line.classList.remove("context");
|
||||
});
|
||||
numberOfFoundLines = lines.length;
|
||||
if (CSS.highlights) {
|
||||
@@ -367,59 +335,9 @@ export class HaAnsiToHtml extends LitElement {
|
||||
}
|
||||
} else {
|
||||
const highlightRanges: Range[] = [];
|
||||
|
||||
const lineList = Array.from(lines);
|
||||
|
||||
const matches = lineList.map(
|
||||
(line) =>
|
||||
line.textContent?.toLowerCase().includes(filter.toLowerCase()) ??
|
||||
false
|
||||
);
|
||||
|
||||
// A line without a timestamp continues the entry above it, such as a
|
||||
// build's output, so a matching entry shows all of its lines.
|
||||
let entryStart = -1;
|
||||
|
||||
const entryOf = lineList.map((line, lineIndex) => {
|
||||
if (LINE_TIMESTAMP.test(line.textContent ?? "")) {
|
||||
entryStart = lineIndex;
|
||||
}
|
||||
|
||||
return entryStart === -1 ? lineIndex : entryStart;
|
||||
});
|
||||
|
||||
const matchedEntries = new Set(
|
||||
entryOf.filter((_entry, lineIndex) => matches[lineIndex])
|
||||
);
|
||||
|
||||
const inMatch = entryOf.map(
|
||||
(entry, lineIndex) =>
|
||||
matches[lineIndex] ||
|
||||
(this.contextLines > 0 && matchedEntries.has(entry))
|
||||
);
|
||||
|
||||
// Like a diff, also show the lines around each match.
|
||||
const visible = inMatch.map(
|
||||
(_match, lineIndex) =>
|
||||
this._expandedLines.has(lineList[lineIndex]) ||
|
||||
inMatch
|
||||
.slice(
|
||||
Math.max(0, lineIndex - this.contextLines),
|
||||
lineIndex + this.contextLines + 1
|
||||
)
|
||||
.some(Boolean)
|
||||
);
|
||||
|
||||
lineList.forEach((line, lineIndex) => {
|
||||
line.classList.toggle(
|
||||
"context",
|
||||
visible[lineIndex] && !inMatch[lineIndex]
|
||||
);
|
||||
|
||||
if (!visible[lineIndex]) {
|
||||
lines.forEach((line) => {
|
||||
if (!line.textContent?.toLowerCase().includes(filter.toLowerCase())) {
|
||||
line.style.display = "none";
|
||||
} else if (!matches[lineIndex]) {
|
||||
line.style.display = "";
|
||||
} else {
|
||||
line.style.display = "";
|
||||
numberOfFoundLines++;
|
||||
@@ -449,104 +367,18 @@ export class HaAnsiToHtml extends LitElement {
|
||||
if (CSS.highlights) {
|
||||
CSS.highlights.set("search-results", new Highlight(...highlightRanges));
|
||||
}
|
||||
|
||||
if (this.contextLines && numberOfFoundLines) {
|
||||
this._updateExpandButtons(lineList, visible);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
this._updateExpandButtons([], []);
|
||||
|
||||
return !!numberOfFoundLines;
|
||||
}
|
||||
|
||||
// Filtering runs again for every new log line, so rows for unchanged gaps
|
||||
// are kept rather than recreated, which would drop keyboard focus.
|
||||
private _updateExpandButtons(lines: HTMLDivElement[], visible: boolean[]) {
|
||||
const rows = new Map<Element | null, HTMLSpanElement>();
|
||||
this._pre?.querySelectorAll(".expand").forEach((row) => {
|
||||
if (row instanceof HTMLSpanElement) {
|
||||
rows.set(row.nextElementSibling, row);
|
||||
}
|
||||
});
|
||||
|
||||
let hidden: HTMLDivElement[] = [];
|
||||
|
||||
lines.forEach((line, lineIndex) => {
|
||||
if (!visible[lineIndex]) {
|
||||
hidden.push(line);
|
||||
}
|
||||
|
||||
if (
|
||||
hidden.length &&
|
||||
(visible[lineIndex] || lineIndex === lines.length - 1)
|
||||
) {
|
||||
const label = this.expandLabel?.(hidden.length) ?? `${hidden.length}`;
|
||||
let row = rows.get(hidden[0]);
|
||||
|
||||
if (row) {
|
||||
rows.delete(hidden[0]);
|
||||
const text = row.querySelector("ha-button")?.lastChild;
|
||||
|
||||
if (text instanceof Text && text.data !== label) {
|
||||
text.data = label;
|
||||
}
|
||||
} else {
|
||||
row = this._createExpandRow(label);
|
||||
hidden[0].before(row);
|
||||
}
|
||||
|
||||
this._expandGaps.set(row, hidden);
|
||||
hidden = [];
|
||||
}
|
||||
});
|
||||
|
||||
rows.forEach((row) => row.remove());
|
||||
}
|
||||
|
||||
private _createExpandRow(label: string): HTMLSpanElement {
|
||||
const row = document.createElement("span");
|
||||
row.className = "expand";
|
||||
const button = document.createElement("ha-button");
|
||||
button.setAttribute("appearance", "filled");
|
||||
button.setAttribute("variant", "neutral");
|
||||
button.setAttribute("size", "s");
|
||||
const icon = document.createElement("ha-svg-icon");
|
||||
icon.slot = "start";
|
||||
icon.path = mdiUnfoldMoreHorizontal;
|
||||
button.append(icon, label);
|
||||
button.addEventListener("click", () => {
|
||||
const gap = this._expandGaps.get(row) ?? [];
|
||||
const hadFocus = row.matches(":focus-within");
|
||||
gap.forEach((hiddenLine) => this._expandedLines.add(hiddenLine));
|
||||
this.filterLines(this._filter);
|
||||
|
||||
// The row is gone once its lines show, so keep focus on the first of them.
|
||||
if (hadFocus && gap[0]) {
|
||||
gap[0].tabIndex = -1;
|
||||
gap[0].focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
row.append(button);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
public trimLines(maxLines: number) {
|
||||
const lines = this._divs;
|
||||
|
||||
if (lines.length <= maxLines) {
|
||||
const pre = this._pre;
|
||||
if (!pre) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(lines)
|
||||
.slice(0, lines.length - maxLines)
|
||||
.forEach((line) => line.remove());
|
||||
|
||||
if (this._filter) {
|
||||
this.filterLines(this._filter);
|
||||
while (pre.childElementCount > maxLines && pre.firstElementChild) {
|
||||
pre.firstElementChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,16 +15,14 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
createAssistMessageProcessor,
|
||||
type AssistMessage,
|
||||
type AssistMessageProcessor,
|
||||
} from "../data/assist_chat";
|
||||
import {
|
||||
runAssistPipeline,
|
||||
type AssistPipeline,
|
||||
type ConversationChatLogAssistantDelta,
|
||||
type ConversationChatLogToolResultDelta,
|
||||
type PipelineRunEvent,
|
||||
} from "../data/assist_pipeline";
|
||||
import type { ChatLogToolResult } from "../data/chat_log";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
@@ -54,6 +52,22 @@ import "./ha-markdown";
|
||||
import "./input/ha-input";
|
||||
import type { HaInput } from "./input/ha-input";
|
||||
|
||||
interface AssistMessage {
|
||||
who: string;
|
||||
text: string;
|
||||
thinking: string;
|
||||
thinking_expanded?: boolean;
|
||||
tool_calls: Record<
|
||||
string,
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: ChatLogToolResult;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const initialPromptToSubmit = (
|
||||
prompt: string | undefined,
|
||||
submit: boolean
|
||||
@@ -639,9 +653,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
hassMessageProcesser.addMessage();
|
||||
} else if (event.type.startsWith("intent-")) {
|
||||
hassMessageProcesser.processEvent(event);
|
||||
if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
}
|
||||
} else if (event.type === "run-end") {
|
||||
this._stt_binary_handler_id = undefined;
|
||||
unsub();
|
||||
@@ -733,9 +744,6 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
(event) => {
|
||||
if (event.type.startsWith("intent-")) {
|
||||
hassMessageProcesser.processEvent(event);
|
||||
if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
}
|
||||
}
|
||||
if (event.type === "intent-end") {
|
||||
unsub();
|
||||
@@ -762,11 +770,118 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
}
|
||||
}
|
||||
|
||||
private _createAddHassMessageProcessor(): AssistMessageProcessor {
|
||||
return createAssistMessageProcessor({
|
||||
addMessage: (message) => this._addMessage(message),
|
||||
requestUpdate: () => this.requestUpdate("_conversation"),
|
||||
});
|
||||
private _createAddHassMessageProcessor() {
|
||||
let currentDeltaRole = "";
|
||||
|
||||
const progressToNextMessage = () => {
|
||||
if (
|
||||
progress.hassMessage.text === "…" &&
|
||||
!progress.hassMessage.thinking &&
|
||||
(!progress.hassMessage.tool_calls ||
|
||||
Object.keys(progress.hassMessage.tool_calls).length === 0)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (progress.hassMessage.text?.endsWith("…")) {
|
||||
progress.hassMessage.text = progress.hassMessage.text.slice(0, -1);
|
||||
}
|
||||
|
||||
progress.hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: false,
|
||||
};
|
||||
this._addMessage(progress.hassMessage);
|
||||
};
|
||||
|
||||
const isAssistantDelta = (
|
||||
_delta: any
|
||||
): _delta is Partial<ConversationChatLogAssistantDelta> =>
|
||||
currentDeltaRole === "assistant";
|
||||
|
||||
const isToolResult = (
|
||||
_delta: any
|
||||
): _delta is ConversationChatLogToolResultDelta =>
|
||||
currentDeltaRole === "tool_result";
|
||||
|
||||
const progress = {
|
||||
continueConversation: false,
|
||||
hassMessage: {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
thinking: "",
|
||||
tool_calls: {},
|
||||
error: false,
|
||||
},
|
||||
addMessage: () => {
|
||||
this._addMessage(progress.hassMessage);
|
||||
},
|
||||
setError: (error: string) => {
|
||||
progressToNextMessage();
|
||||
progress.hassMessage.text = error;
|
||||
progress.hassMessage.error = true;
|
||||
this.requestUpdate("_conversation");
|
||||
},
|
||||
processEvent: (event: PipelineRunEvent) => {
|
||||
if (event.type === "intent-progress" && event.data.chat_log_delta) {
|
||||
const delta = event.data.chat_log_delta;
|
||||
|
||||
// new message
|
||||
if (delta.role) {
|
||||
currentDeltaRole = delta.role;
|
||||
}
|
||||
|
||||
if (isAssistantDelta(delta)) {
|
||||
if (delta.content) {
|
||||
if (progress.hassMessage.text.endsWith("…")) {
|
||||
progress.hassMessage.text =
|
||||
progress.hassMessage.text.substring(
|
||||
0,
|
||||
progress.hassMessage.text.length - 1
|
||||
) +
|
||||
delta.content +
|
||||
"…";
|
||||
} else {
|
||||
progress.hassMessage.text += delta.content + "…";
|
||||
}
|
||||
}
|
||||
if (delta.thinking_content) {
|
||||
progress.hassMessage.thinking += delta.thinking_content;
|
||||
}
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
progress.hassMessage.tool_calls[toolCall.id] = toolCall;
|
||||
}
|
||||
}
|
||||
this.requestUpdate("_conversation");
|
||||
} else if (isToolResult(delta)) {
|
||||
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
|
||||
progress.hassMessage.tool_calls[delta.tool_call_id].result =
|
||||
delta.result;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
} else if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
progress.continueConversation =
|
||||
event.data.intent_output.continue_conversation;
|
||||
const response =
|
||||
event.data.intent_output.response.speech.plain?.speech;
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
if (event.data.intent_output.response.response_type === "error") {
|
||||
progress.setError(response);
|
||||
} else {
|
||||
progress.hassMessage.text = response;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
return progress;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -38,10 +38,6 @@ export class HaBaseTimeInput extends LitElement {
|
||||
*/
|
||||
@property() label?: string;
|
||||
|
||||
/** Accessible name for the input when it has no visible label. */
|
||||
@property({ attribute: "accessible-label" })
|
||||
public accessibleLabel?: string;
|
||||
|
||||
/**
|
||||
* Helper for the input
|
||||
*/
|
||||
@@ -182,7 +178,6 @@ export class HaBaseTimeInput extends LitElement {
|
||||
class="time-input-wrap"
|
||||
role="group"
|
||||
aria-labelledby=${ifDefined(this.label ? "label" : undefined)}
|
||||
aria-label=${ifDefined(this.label ? undefined : this.accessibleLabel)}
|
||||
>
|
||||
${
|
||||
this.enableSign
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import WaCheckbox from "@home-assistant/webawesome/dist/components/checkbox/checkbox";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant checkbox component
|
||||
@@ -40,13 +40,9 @@ import { customElement, property } from "lit/decorators";
|
||||
* @attr {boolean} disabled - Disables the checkbox.
|
||||
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state.
|
||||
* @attr {boolean} required - Makes the checkbox a required field.
|
||||
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the checked color variant. "brand" is default.
|
||||
*/
|
||||
@customElement("ha-checkbox")
|
||||
export class HaCheckbox extends WaCheckbox {
|
||||
@property({ reflect: true })
|
||||
variant: "brand" | "neutral" | "success" | "warning" | "danger" = "brand";
|
||||
|
||||
/**
|
||||
* Returns the configured checkbox value, independent of checked state.
|
||||
*
|
||||
@@ -82,19 +78,15 @@ export class HaCheckbox extends WaCheckbox {
|
||||
);
|
||||
--checked-icon-color: var(
|
||||
--ha-checkbox-checked-icon-color,
|
||||
var(--checkbox-on-fill-color)
|
||||
var(--wa-color-brand-on-loud)
|
||||
);
|
||||
|
||||
--wa-form-control-activated-color: var(
|
||||
--ha-checkbox-checked-background-color,
|
||||
var(--checkbox-fill-color)
|
||||
var(--ha-color-fill-primary-loud-resting)
|
||||
);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
--checkbox-fill-color: var(--ha-color-fill-primary-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-primary-loud-hover);
|
||||
--checkbox-on-fill-color: var(--wa-color-brand-on-loud);
|
||||
|
||||
--checked-icon-scale: var(--ha-checkbox-checked-icon-scale, 0.9);
|
||||
--wa-form-control-required-content: var(
|
||||
--ha-checkbox-required-marker,
|
||||
@@ -106,30 +98,6 @@ export class HaCheckbox extends WaCheckbox {
|
||||
);
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-neutral-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-neutral-loud);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-success-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-success-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-success-loud);
|
||||
}
|
||||
|
||||
:host([variant="warning"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-warning-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-warning-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-warning-loud);
|
||||
}
|
||||
|
||||
:host([variant="danger"]) {
|
||||
--checkbox-fill-color: var(--ha-color-fill-danger-loud-resting);
|
||||
--checkbox-fill-color-hover: var(--ha-color-fill-danger-loud-hover);
|
||||
--checkbox-on-fill-color: var(--ha-color-on-danger-loud);
|
||||
}
|
||||
|
||||
[part~="base"] {
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
@@ -190,11 +158,11 @@ export class HaCheckbox extends WaCheckbox {
|
||||
label:has(input:indeterminate:not(:disabled)):hover [part~="control"] {
|
||||
background-color: var(
|
||||
--ha-checkbox-checked-background-color-hover,
|
||||
var(--checkbox-fill-color-hover)
|
||||
var(--ha-color-fill-primary-loud-hover)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-checkbox-checked-background-color-hover,
|
||||
var(--checkbox-fill-color-hover)
|
||||
var(--ha-color-fill-primary-loud-hover)
|
||||
);
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -80,9 +80,6 @@ export class HaControlCircularSlider extends LitElement {
|
||||
@property({ type: String, attribute: "high-label" })
|
||||
public highLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public formatValue?: (value: number) => string;
|
||||
|
||||
@property({ type: Number }) public value?: number;
|
||||
|
||||
@property({ type: Number }) public low?: number;
|
||||
@@ -565,22 +562,13 @@ export class HaControlCircularSlider extends LitElement {
|
||||
aria-valuemin=${this.min}
|
||||
aria-valuemax=${this.max}
|
||||
aria-valuenow=${
|
||||
value != null ? this._steppedValue(value) : undefined
|
||||
}
|
||||
aria-valuetext=${ifDefined(
|
||||
value != null && this.formatValue
|
||||
? this.formatValue(this._steppedValue(value))
|
||||
this._localValue != null
|
||||
? this._steppedValue(this._localValue)
|
||||
: undefined
|
||||
)}
|
||||
}
|
||||
aria-disabled=${this.disabled}
|
||||
aria-readonly=${this.readonly}
|
||||
aria-label=${ifDefined(
|
||||
id === "low"
|
||||
? (this.lowLabel ?? this.label)
|
||||
: id === "high"
|
||||
? (this.highLabel ?? this.label)
|
||||
: this.label
|
||||
)}
|
||||
aria-label=${ifDefined(this.lowLabel ?? this.label)}
|
||||
@keydown=${this._handleKeyDown}
|
||||
@keyup=${this._handleKeyUp}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { mdiMinus, mdiPlus } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { conditionalClamp } from "../common/number/clamp";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import type { FrontendLocaleData } from "../data/translation";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@@ -48,10 +45,6 @@ export class HaControlNumberButton extends LitElement {
|
||||
|
||||
@query("#input") _input!: HTMLDivElement;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private _hideUnit = new ResizeController(this, {
|
||||
callback: (entries) => {
|
||||
const width = entries[0]?.contentRect.width;
|
||||
@@ -167,9 +160,7 @@ export class HaControlNumberButton extends LitElement {
|
||||
class="button minus"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label=${ifDefined(
|
||||
this._i18n?.localize("ui.components.number-buttons.decrement")
|
||||
)}
|
||||
aria-label="decrement"
|
||||
@click=${this._handleMinusButton}
|
||||
.disabled=${
|
||||
this.disabled || (this.min != null && this._value <= this.min)
|
||||
@@ -181,9 +172,7 @@ export class HaControlNumberButton extends LitElement {
|
||||
class="button plus"
|
||||
type="button"
|
||||
tabindex="-1"
|
||||
aria-label=${ifDefined(
|
||||
this._i18n?.localize("ui.components.number-buttons.increment")
|
||||
)}
|
||||
aria-label="increment"
|
||||
@click=${this._handlePlusButton}
|
||||
.disabled=${
|
||||
this.disabled || (this.max != null && this._value >= this.max)
|
||||
|
||||
@@ -71,12 +71,11 @@ export class HaControlSelectMenu extends LitElement {
|
||||
>
|
||||
${this._renderIcon()}
|
||||
<div class="content">
|
||||
<p
|
||||
id="label"
|
||||
class="label ${classMap({ "visually-hidden": this.hideLabel })}"
|
||||
>
|
||||
${this.label}
|
||||
</p>
|
||||
${
|
||||
this.hideLabel
|
||||
? nothing
|
||||
: html`<p id="label" class="label">${this.label}</p>`
|
||||
}
|
||||
${selectedText ? html`<p class="value">${selectedText}</p>` : nothing}
|
||||
</div>
|
||||
${this._renderArrow()}
|
||||
@@ -236,17 +235,6 @@ export class HaControlSelectMenu extends LitElement {
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
|
||||
.content .visually-hidden {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.select-anchor:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--control-select-menu-focus-color);
|
||||
}
|
||||
|
||||
@@ -346,8 +346,7 @@ export class HaControlSelect extends LitElement {
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: clip;
|
||||
unicode-bidi: plaintext;
|
||||
text-overflow: ellipsis;
|
||||
hyphens: manual;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
|
||||
@@ -50,10 +50,6 @@ export class HaDateInput extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
/** Accessible name for the input when it has no visible label. */
|
||||
@property({ attribute: "accessible-label" })
|
||||
public accessibleLabel?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "can-clear", type: Boolean }) public canClear = false;
|
||||
@@ -69,7 +65,6 @@ export class HaDateInput extends LitElement {
|
||||
.label=${this.label ?? ""}
|
||||
.hint=${this.helper ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
.accessibleLabel=${this.accessibleLabel}
|
||||
readonly
|
||||
@click=${this._openDialog}
|
||||
@keydown=${this._keyDown}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { customElement } from "lit/decorators";
|
||||
*
|
||||
* @remarks
|
||||
* **Button Styling Guidance:**
|
||||
* - `primaryAction` slot: Use the default `appearance="accent"`
|
||||
* - `secondaryAction` slot: Use `appearance="plain"`
|
||||
* - `primaryAction` slot: Use `variant="accent"`
|
||||
* - `secondaryAction` slot: Use `variant="plain"`
|
||||
*/
|
||||
@customElement("ha-dialog-footer")
|
||||
export class HaDialogFooter extends LitElement {
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import {
|
||||
mdiDotsHorizontal,
|
||||
mdiFilterVariantRemove,
|
||||
mdiPuzzle,
|
||||
mdiRobot,
|
||||
mdiScriptText,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../data/context";
|
||||
import type { LogbookCauseGroup } from "../data/logbook";
|
||||
import { LOGBOOK_CAUSE_GROUPS, logbookUserCauseKey } from "../data/logbook";
|
||||
import type { User } from "../data/user";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "./list/ha-list-selectable";
|
||||
import "./user/ha-user-badge";
|
||||
|
||||
interface PersonUser {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const GROUP_ICONS: Record<LogbookCauseGroup, string> = {
|
||||
automation: mdiRobot,
|
||||
script: mdiScriptText,
|
||||
integration: mdiPuzzle,
|
||||
other: mdiDotsHorizontal,
|
||||
};
|
||||
|
||||
@customElement("ha-filter-logbook-causes")
|
||||
export class HaFilterLogbookCauses extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
@state()
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
const count = this.value?.length ?? 0;
|
||||
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.logbook.cause_filter.caption")}
|
||||
${
|
||||
count
|
||||
? html`<div class="badge">${count}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
.label=${this._localize(
|
||||
"ui.components.subpage-data-table.clear_filter"
|
||||
)}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
</ha-expansion-panel>
|
||||
${this._panel.showContent ? this._renderContent() : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderContent() {
|
||||
const users = this._personUsers(
|
||||
this._states,
|
||||
this._formatters,
|
||||
this._i18n.locale.language
|
||||
);
|
||||
const value = this.value ?? [];
|
||||
|
||||
return html`<div class="content" ${ref(this._content)}>
|
||||
<ha-list-selectable
|
||||
multi
|
||||
controlled
|
||||
aria-label=${this._localize("ui.components.logbook.cause_filter.caption")}
|
||||
@ha-list-item-selected=${this._handleItemToggled}
|
||||
@ha-list-item-deselected=${this._handleItemToggled}
|
||||
>
|
||||
${repeat(
|
||||
users,
|
||||
(user) => user.id,
|
||||
(user) => {
|
||||
const key = logbookUserCauseKey(user.id);
|
||||
return html`<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${key}
|
||||
.selected=${value.includes(key)}
|
||||
>
|
||||
<ha-user-badge slot="start" .user=${user as User}></ha-user-badge>
|
||||
<span slot="headline">${user.name}</span>
|
||||
</ha-list-item-option>`;
|
||||
}
|
||||
)}
|
||||
${LOGBOOK_CAUSE_GROUPS.map(
|
||||
(group) =>
|
||||
html`<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${group}
|
||||
.selected=${value.includes(group)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${GROUP_ICONS[group]}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline"
|
||||
>${this._localize(
|
||||
`ui.components.logbook.cause_filter.${group}`
|
||||
)}</span
|
||||
>
|
||||
</ha-list-item-option>`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _personUsers = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
formatters: ContextType<typeof formattersContext>,
|
||||
language: string | undefined
|
||||
): PersonUser[] =>
|
||||
Object.values(states)
|
||||
.filter(
|
||||
(stateObj) =>
|
||||
computeStateDomain(stateObj) === "person" &&
|
||||
stateObj.attributes.user_id
|
||||
)
|
||||
.map((stateObj) => ({
|
||||
id: stateObj.attributes.user_id as string,
|
||||
name: formatters.formatEntityName(stateObj),
|
||||
}))
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language))
|
||||
);
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemToggled(ev: CustomEvent<number>) {
|
||||
const option = this._list?.items[ev.detail] as HaListItemOption | undefined;
|
||||
const key = option?.value;
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
const value = new Set(this.value ?? []);
|
||||
if (!value.delete(key)) {
|
||||
value.add(key);
|
||||
}
|
||||
this.value = [...value];
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list-selectable {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable::part(base) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-user-badge {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-logbook-causes": HaFilterLogbookCauses;
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,7 @@ export class HaFilterPane extends LitElement {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
HaFormBaseSchema,
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
HaFormFieldCondition,
|
||||
HaFormSchema,
|
||||
HaFormVisible,
|
||||
} from "./types";
|
||||
|
||||
const isEmpty = (value: unknown): boolean =>
|
||||
@@ -57,10 +57,11 @@ export const evaluateCondition = (
|
||||
return matchFieldCondition(condition, data);
|
||||
};
|
||||
|
||||
export const isVisible = (
|
||||
visible: HaFormVisible | undefined,
|
||||
export const isFieldVisible = (
|
||||
schema: HaFormSchema,
|
||||
data: HaFormDataContainer | undefined
|
||||
): boolean => {
|
||||
const { visible } = schema as HaFormBaseSchema;
|
||||
if (visible === undefined || visible === true) {
|
||||
return true;
|
||||
}
|
||||
@@ -71,26 +72,10 @@ export const isVisible = (
|
||||
return conditions.every((condition) => evaluateCondition(condition, data));
|
||||
};
|
||||
|
||||
export const combineVisible = (
|
||||
...options: (HaFormVisible | undefined)[]
|
||||
): HaFormVisible => {
|
||||
const conditions: HaFormCondition[] = [];
|
||||
for (const visible of options) {
|
||||
if (visible === undefined || visible === true) {
|
||||
continue;
|
||||
}
|
||||
if (visible === false) {
|
||||
return false;
|
||||
}
|
||||
conditions.push(...(Array.isArray(visible) ? visible : [visible]));
|
||||
}
|
||||
return conditions;
|
||||
};
|
||||
|
||||
// Hiding a field drops its value, which can flip another field's condition, so
|
||||
// resolve the set to a fixpoint. Fields are given in order as name and visible.
|
||||
// resolve the set to a fixpoint.
|
||||
export const getHiddenFields = (
|
||||
fields: readonly (readonly [string, HaFormVisible | undefined])[],
|
||||
schema: readonly HaFormSchema[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> => {
|
||||
const hidden = new Set<string>();
|
||||
@@ -98,23 +83,14 @@ export const getHiddenFields = (
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [name, visible] of fields) {
|
||||
if (hidden.has(name) || isVisible(visible, evalData)) {
|
||||
for (const field of schema) {
|
||||
if (hidden.has(field.name) || isFieldVisible(field, evalData)) {
|
||||
continue;
|
||||
}
|
||||
hidden.add(name);
|
||||
delete evalData[name];
|
||||
hidden.add(field.name);
|
||||
delete evalData[field.name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return hidden;
|
||||
};
|
||||
|
||||
export const getHiddenSchemaFields = (
|
||||
schema: readonly HaFormSchema[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> =>
|
||||
getHiddenFields(
|
||||
schema.map((field) => [field.name, field.visible] as const),
|
||||
data
|
||||
);
|
||||
@@ -2,7 +2,7 @@ import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, queryAll } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getHiddenSchemaFields } from "./conditions";
|
||||
import { getHiddenFields } from "./conditions";
|
||||
import "./ha-form";
|
||||
import type { HaForm } from "./ha-form";
|
||||
import type {
|
||||
@@ -68,7 +68,7 @@ export class HaFormGrid extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const hiddenFields = getHiddenSchemaFields(this.schema.schema, this.data);
|
||||
const hiddenFields = getHiddenFields(this.schema.schema, this.data);
|
||||
|
||||
return html`
|
||||
${this.schema.schema
|
||||
|
||||
@@ -7,7 +7,7 @@ import { resolveSelectorContext } from "../../data/selector";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-selector/ha-selector";
|
||||
import { getHiddenSchemaFields } from "./conditions";
|
||||
import { getHiddenFields } from "./conditions";
|
||||
import type {
|
||||
HaFormData,
|
||||
HaFormDataContainer,
|
||||
@@ -106,8 +106,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
) => string | undefined;
|
||||
|
||||
@property({ attribute: false }) public localizeValue?: (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
key: string
|
||||
) => string;
|
||||
|
||||
@property({ attribute: false }) public context?: Record<string, any>;
|
||||
@@ -136,7 +135,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
let isValid = true;
|
||||
let firstInvalidElement: HTMLElement | undefined;
|
||||
|
||||
const hiddenFields = getHiddenSchemaFields(this.schema, this.data);
|
||||
const hiddenFields = getHiddenFields(this.schema, this.data);
|
||||
const visibleSchema = this.schema.filter(
|
||||
(item) => !hiddenFields.has(item.name)
|
||||
);
|
||||
@@ -195,7 +194,7 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const renderHiddenFields = getHiddenSchemaFields(this.schema, this.data);
|
||||
const renderHiddenFields = getHiddenFields(this.schema, this.data);
|
||||
|
||||
return html`
|
||||
<div class="root" part="root">
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface HaFormBaseSchema {
|
||||
disabled?: boolean;
|
||||
// Field is visible while the condition holds (visible by default).
|
||||
// Serializable so it can be shared with the backend and other renderers.
|
||||
visible?: HaFormVisible;
|
||||
visible?: boolean | HaFormCondition | HaFormCondition[];
|
||||
description?: {
|
||||
suffix?: string;
|
||||
// This value will be set initially when form is loaded
|
||||
@@ -34,8 +34,6 @@ export interface HaFormBaseSchema {
|
||||
context?: Record<string, string>;
|
||||
}
|
||||
|
||||
export type HaFormVisible = boolean | HaFormCondition | HaFormCondition[];
|
||||
|
||||
export type HaFormConditionOperator =
|
||||
"eq" | "not_eq" | "in" | "not_in" | "exists" | "not_exists";
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ export class HaIconButton extends LitElement {
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
variant="neutral"
|
||||
.accessibleLabel=${this.label ?? ""}
|
||||
.ariaLabel=${this.label ?? null}
|
||||
.title=${this.hideTitle ? "" : (this.label ?? "")}
|
||||
aria-haspopup=${ifDefined(this.ariaHasPopup)}
|
||||
.disabled=${this.disabled}
|
||||
|
||||
@@ -245,10 +245,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
protected firstUpdated() {
|
||||
this._registerKeyboardShortcuts();
|
||||
// The virtualized list pins the selected row itself.
|
||||
if (this._plainList) {
|
||||
this._scrollToSelectedRow();
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate() {
|
||||
@@ -269,28 +265,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll before the surface animates in, so the list does not jump at the
|
||||
// end of the animation. Wait a frame for the surface to be positioned.
|
||||
private async _scrollToSelectedRow() {
|
||||
await this._list?.updateComplete;
|
||||
await new Promise(requestAnimationFrame);
|
||||
if (!this._list || !this.value || this._search) {
|
||||
return;
|
||||
}
|
||||
const row = this._list.items[this._getInitialSelectedIndex()];
|
||||
const listRect = this._list.getBoundingClientRect();
|
||||
if (!row || !listRect.height) {
|
||||
return;
|
||||
}
|
||||
// The surface scales in, so convert the measured offset to unscaled pixels.
|
||||
const scale = this._list.offsetHeight / listRect.height;
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
// Center the row, like the virtualized list does.
|
||||
this._list.scrollTop +=
|
||||
(rowRect.top - listRect.top) * scale -
|
||||
(this._list.clientHeight - row.offsetHeight) / 2;
|
||||
}
|
||||
|
||||
// The list resets its active row when it takes new rows, so wait for that
|
||||
// first. Skip if the search was cleared or a key already moved the cursor.
|
||||
private async _highlightTopMatch() {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
// and "qr-scanner" defaults to a suboptimal implementation if it is not available.
|
||||
// The following import makes a better implementation available that is based on a
|
||||
// WebAssembly port of ZXing:
|
||||
import { prepareZXingModule, ZXING_WASM_VERSION } from "barcode-detector";
|
||||
import { prepareZXingModule } from "barcode-detector";
|
||||
import type QrScanner from "qr-scanner";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
@@ -29,8 +29,7 @@ prepareZXingModule({
|
||||
overrides: {
|
||||
locateFile: (path: string, prefix: string) => {
|
||||
if (path.endsWith(".wasm")) {
|
||||
// Versioned, /static is cached for a month and the glue only works with its own wasm
|
||||
return `/static/js/zxing_reader-${ZXING_WASM_VERSION}.wasm`;
|
||||
return "/static/js/zxing_reader.wasm";
|
||||
}
|
||||
return prefix + path;
|
||||
},
|
||||
|
||||
@@ -43,8 +43,7 @@ export class HaObjectSelector extends LitElement {
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public localizeValue?: (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
key: string
|
||||
) => string;
|
||||
|
||||
@query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
|
||||
@@ -80,45 +79,37 @@ export class HaObjectSelector extends LitElement {
|
||||
return this.selector.object?.fields?.[schema.name]?.description || "";
|
||||
};
|
||||
|
||||
private _formatField(item: any, field: string): string {
|
||||
const fields = this.selector.object!.fields!;
|
||||
const value = formatSelectorValue(
|
||||
this.hass,
|
||||
item[field],
|
||||
fields[field]?.selector
|
||||
);
|
||||
const translationKey = this.selector.object!.translation_key;
|
||||
if (!value || !this.localizeValue || !translationKey) {
|
||||
return value;
|
||||
}
|
||||
return (
|
||||
this.localizeValue(`${translationKey}.fields.${field}.overview_label`, {
|
||||
value,
|
||||
}) || value
|
||||
);
|
||||
}
|
||||
|
||||
private _renderItem(item: any, index: number) {
|
||||
const fields = this.selector.object!.fields!;
|
||||
const preferredLabel = this.selector.object!.label_field;
|
||||
const hasValidLabelField = preferredLabel && preferredLabel in fields;
|
||||
|
||||
const label = hasValidLabelField
|
||||
? this._formatField(item, preferredLabel!)
|
||||
: Object.keys(fields)
|
||||
.map((key) => this._formatField(item, key))
|
||||
? formatSelectorValue(
|
||||
this.hass,
|
||||
item[preferredLabel!],
|
||||
fields[preferredLabel!]?.selector
|
||||
)
|
||||
: Object.entries(fields)
|
||||
.map(([key, field]) =>
|
||||
formatSelectorValue(this.hass, item[key], field.selector)
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
let description = "";
|
||||
|
||||
const descriptionField = this.selector.object!.description_field;
|
||||
if (
|
||||
descriptionField &&
|
||||
descriptionField in fields &&
|
||||
fields[descriptionField]?.selector
|
||||
) {
|
||||
description = this._formatField(item, descriptionField);
|
||||
if (descriptionField && descriptionField in fields) {
|
||||
const descriptionSelector = fields[descriptionField]?.selector;
|
||||
|
||||
description = descriptionSelector
|
||||
? formatSelectorValue(
|
||||
this.hass,
|
||||
item[descriptionField],
|
||||
descriptionSelector
|
||||
)
|
||||
: "";
|
||||
}
|
||||
|
||||
const reorderable = this.selector.object!.multiple || false;
|
||||
|
||||
@@ -93,10 +93,7 @@ export class HaSelector extends LitElement {
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public localizeValue?: (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => string;
|
||||
public localizeValue?: (key: string) => string;
|
||||
|
||||
@property() public placeholder?: any;
|
||||
|
||||
|
||||
@@ -773,16 +773,12 @@ export class HaServiceControl extends LitElement {
|
||||
filter_entity: targetEntities || undefined,
|
||||
}));
|
||||
|
||||
private _localizeValueCallback = (
|
||||
key: string,
|
||||
values?: Record<string, string>
|
||||
) => {
|
||||
private _localizeValueCallback = (key: string) => {
|
||||
if (!this._value?.action) {
|
||||
return "";
|
||||
}
|
||||
return this.hass.localize(
|
||||
`component.${computeDomain(this._value.action)}.selector.${key}`,
|
||||
values
|
||||
`component.${computeDomain(this._value.action)}.selector.${key}`
|
||||
);
|
||||
};
|
||||
|
||||
|
||||