Compare commits

..
462 changed files with 6854 additions and 25423 deletions

No files matched your search

@@ -1,91 +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. Show what the link applied, but don't store it: links often carry someone else's choices, such as a My Home Assistant link made with `m`. Store only what the user changes on the page, and only that setting.
- 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.
- A search box's text is always `search`, never `filter` or `q`.
- 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, with `link` set, so the page shows them without storing them. Use `DataTableSettingsController` (`src/common/controllers/data-table-settings-controller.ts`) for the stored search, sorting and grouping: give it the page's storage keys, bind its `search`, `sorting` and `grouping` to the table, and its `searchChanged`, `sortingChanged` and `groupingChanged` to the table's events. Call `showSearch` for a search the page reads from a link itself. A page the router keeps open shows what the user stored when it's opened again without params, like from the sidebar: the controller and table restore the search, sorting and grouping, and a page with filters restores its stored filters when its URL has no params.
- 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.
- Filter components also send the value the page gave them once they have looked up their items, so a filter event isn't always the user's. Store a filter only when it changes what the URL holds, comparing the filters' query strings before and after, as `helpersFilterQueryString` does, and store only that filter.
- 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.
+1 -1
View File
@@ -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.
+22 -29
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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", () =>
+2 -2
View File
@@ -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/
+2 -2
View File
@@ -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"
+17 -17
View File
@@ -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/
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
24.22.0
24.21.0
-1
View File
@@ -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.
+1 -1
View File
@@ -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.
+2 -2
View File
@@ -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: {
+8 -8
View File
@@ -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
}
}
+3 -3
View File
@@ -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}/`)),
+3 -22
View File
@@ -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")
);
}
+1 -3
View File
@@ -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")
);
});
+5 -41
View File
@@ -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$":
+1 -1
View File
@@ -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: [
{
+2 -2
View File
@@ -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)
}
};
+2 -68
View File
@@ -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, [
-8
View File
@@ -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];
}
);
};
-2
View File
@@ -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,
-2
View File
@@ -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",
},
},
}),
};
-124
View File
@@ -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))
);
};
-33
View File
@@ -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,
+2 -19
View File
@@ -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);
};
+2 -16
View File
@@ -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(() => {
+18 -36
View File
@@ -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[]> = {};
-6
View File
@@ -37,7 +37,6 @@ export default [
pages: [
"ha-form",
"ha-selector",
"ha-target-picker-exclusions",
"ha-select-box",
"ha-input",
"ha-textarea",
@@ -127,7 +126,6 @@ export default [
"gauge-card",
"plant-card",
"map-card",
"calendar-card",
"media-control-card",
"media-player-row",
],
@@ -169,10 +167,6 @@ export default [
header: "Inputs and values",
pages: ["input-number", "input-text", "number", "timer"],
},
{
header: "Time and schedules",
pages: ["calendar"],
},
{
header: "System",
pages: ["update"],
-166
View File
@@ -1,166 +0,0 @@
import { mockEntityRegistry } from "../../../demo/src/stubs/entity_registry";
import type { CalendarEventApiData } from "../../../src/data/calendar";
import { CalendarEntityFeature } from "../../../src/data/calendar";
import type { EntityRegistryEntry } from "../../../src/data/entity/entity_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
// Sample calendars with events relative to today, so demos always show
// something in the current week and month.
const pad = (value: number) => String(value).padStart(2, "0");
const day = (offset: number): Date => {
const date = new Date();
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + offset);
return date;
};
const isoDate = (date: Date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
const at = (offset: number, hours: number, minutes = 0) => {
const date = day(offset);
date.setHours(hours, minutes);
return date.toISOString();
};
const allDay = (offset: number, days = 1) => ({
start: isoDate(day(offset)),
end: isoDate(day(offset + days)),
all_day: true,
});
const FAMILY_EVENTS: CalendarEventApiData[] = [
{ summary: "School run", start: at(0, 8), end: at(0, 8, 30) },
{
summary: "Dentist",
start: at(0, 15, 30),
end: at(0, 16, 15),
location: "Main Street 12",
},
{ summary: "Grandma's birthday", ...allDay(1) },
{ summary: "Football practice", start: at(2, 17), end: at(2, 18, 30) },
{
summary: "Dinner with friends",
start: at(3, 19),
end: at(3, 22),
status: "tentative",
},
{ summary: "Summer cabin", ...allDay(5, 3) },
{ summary: "Parent-teacher meeting", start: at(9, 18), end: at(9, 19) },
{ summary: "Football practice", start: at(9, 17), end: at(9, 18, 30) },
{ summary: "Swimming lessons", start: at(12, 10), end: at(12, 11) },
{ summary: "Car service", start: at(-3, 9), end: at(-3, 11) },
{ summary: "Book club", start: at(-6, 19), end: at(-6, 21) },
];
const WORK_EVENTS: CalendarEventApiData[] = [
{ summary: "Stand-up", start: at(0, 9), end: at(0, 9, 15) },
{ summary: "Sprint planning", start: at(2, 10), end: at(2, 12) },
{ summary: "Team lunch", start: at(4, 12), end: at(4, 13) },
{ summary: "Conference", ...allDay(8, 2) },
{ summary: "Quarterly review", start: at(-2, 14), end: at(-2, 15, 30) },
];
const EVENTS: Record<string, CalendarEventApiData[] | null> = {
"calendar.family": FAMILY_EVENTS,
"calendar.work": WORK_EVENTS,
"calendar.empty": [],
// A calendar whose integration fails to return events
"calendar.holidays": null,
};
export const CALENDAR_ENTITIES = [
{
entity_id: "calendar.family",
state: "off",
attributes: {
friendly_name: "Family",
message: "School run",
all_day: false,
start_time: at(0, 8),
end_time: at(0, 8, 30),
location: "",
description: "",
supported_features:
CalendarEntityFeature.CREATE_EVENT +
CalendarEntityFeature.DELETE_EVENT +
CalendarEntityFeature.UPDATE_EVENT,
},
},
{
entity_id: "calendar.work",
state: "off",
attributes: {
friendly_name: "Work",
message: "Stand-up",
all_day: false,
start_time: at(0, 9),
end_time: at(0, 9, 15),
},
},
{
entity_id: "calendar.empty",
state: "off",
attributes: { friendly_name: "Empty calendar" },
},
{
entity_id: "calendar.holidays",
state: "off",
attributes: { friendly_name: "Holidays (failing)" },
},
];
const registryEntry = (
entityId: string,
color?: string
): EntityRegistryEntry => ({
id: entityId,
entity_id: entityId,
name: null,
icon: null,
platform: "local_calendar",
config_entry_id: null,
config_subentry_id: null,
device_id: null,
area_id: null,
labels: [],
disabled_by: null,
hidden_by: null,
entity_category: null,
has_entity_name: false,
unique_id: entityId,
options: color ? { calendar: { color } } : null,
categories: {},
created_at: 0,
modified_at: 0,
});
export const mockCalendars = (hass: MockHomeAssistant) => {
mockEntityRegistry(hass, [
registryEntry("calendar.family"),
// Shows a color picked in the entity settings
registryEntry("calendar.work", "deep-orange"),
registryEntry("calendar.empty"),
registryEntry("calendar.holidays"),
]);
hass.mockWS("calendar/event/subscribe", (msg, _hass, onChange) => {
const start = new Date(msg.start).getTime();
const end = new Date(msg.end).getTime();
const events = EVENTS[msg.entity_id];
onChange?.({
events:
events?.filter(
(event) =>
new Date(event.end as string).getTime() > start &&
new Date(event.start as string).getTime() < end
) ?? null,
});
return () => undefined;
});
// Changes made in the event dialogs are accepted but not stored
hass.mockWS("calendar/event/create", () => null);
hass.mockWS("calendar/event/update", () => null);
hass.mockWS("calendar/event/delete", () => null);
};
@@ -35,12 +35,6 @@ const ENTITIES = [
const triggers = [
{ trigger: "state", entity_id: "light.kitchen", from: "off", to: "on" },
{
trigger: "state",
entity_id: "light.kitchen",
not_from: ["unavailable", "unknown"],
not_to: "off",
},
{ trigger: "mqtt" },
{
trigger: "geo_location",
@@ -33,15 +33,7 @@ import "../../components/demo-black-white-row";
const SCHEMAS: { name: string; triggers: Trigger[] }[] = [
{
name: "State",
triggers: [
{ ...HaStateTrigger.defaultConfig },
{
trigger: "state",
entity_id: "light.kitchen",
not_from: ["unavailable", "unknown"],
to: "on",
},
],
triggers: [{ ...HaStateTrigger.defaultConfig }],
},
{
@@ -1,23 +0,0 @@
---
title: Target picker exclusions
subtitle: How the target picker shows and edits the exclude of a target
---
A target can leave things out again with `exclude`, which has the same shape as
the target itself. Excludes are global: an exclusion takes its entities out of
every included target, not only one.
The target selector shows exclusions as follows:
- **Exclude** sits next to **Add target**. It offers only what overlaps with the
included targets and hides what is already excluded. Child devices of an
included device can still be excluded on their own.
- Each included target lists, muted, the exclusions that take entities out of
it, with how many they remove. Its count says how many are excluded.
- An exclusion that takes nothing out of any target, like one of a deleted
area, is listed under **Exclusions with no effect**, where it can be removed.
- The target details show which entities are excluded and by what, and let
you tick or untick what the target holds.
Only the target selector offers **Exclude**. Other users of the target picker
set `allow-exclude` once they handle `exclude`.
@@ -1,302 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { mockConfigEntries } from "../../../../demo/src/stubs/config_entries";
import { mockDeviceRegistry } from "../../../../demo/src/stubs/device_registry";
import { mockEntityRegistry } from "../../../../demo/src/stubs/entity_registry";
import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervisor";
import { ensureArray } from "../../../../src/common/array/ensure-array";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import "../../../../src/components/ha-selector/ha-selector";
import "../../../../src/components/ha-settings-row";
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
import type { Selector } from "../../../../src/data/selector";
import {
showDialog,
type ShowDialogParams,
} from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
const device = (
id: string,
name: string,
areaId: string,
parentDeviceId: string | null = null
): DeviceRegistryEntry => ({
area_id: areaId,
configuration_url: null,
config_entries: [],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id,
identifiers: [["demo", id] as [string, string]],
manufacturer: null,
model: null,
model_id: null,
name_by_user: null,
name,
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: parentDeviceId,
});
// A power strip in the backyard with two outlets as child devices, and a lamp
// in the bedroom.
const DEVICES: DeviceRegistryEntry[] = [
device("power_strip", "Power strip", "backyard"),
device("outlet_1", "Outlet 1", "backyard", "power_strip"),
device("outlet_2", "Outlet 2", "backyard", "power_strip"),
device("bedroom_lamp", "Bedroom lamp", "bedroom"),
];
const ENTITY_REGISTRY: Record<string, EntityRegistryDisplayEntry> = {
"switch.outlet_1": {
entity_id: "switch.outlet_1",
name: "Outlet 1",
device_id: "outlet_1",
platform: "demo",
labels: [],
},
"switch.outlet_2": {
entity_id: "switch.outlet_2",
name: "Outlet 2",
device_id: "outlet_2",
platform: "demo",
labels: [],
},
"light.bedroom_lamp": {
entity_id: "light.bedroom_lamp",
name: "Bedroom lamp",
device_id: "bedroom_lamp",
platform: "demo",
labels: [],
},
"light.bedside": {
entity_id: "light.bedside",
name: "Bedside light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
"light.night_light": {
entity_id: "light.night_light",
name: "Night light",
area_id: "bedroom",
platform: "demo",
labels: [],
},
};
const ENTITIES = Object.values(ENTITY_REGISTRY).map((entity) => ({
entity_id: entity.entity_id,
state: entity.entity_id.startsWith("light.") ? "on" : "off",
attributes: { friendly_name: entity.name },
}));
const area = (areaId: string, name: string): AreaRegistryEntry => ({
area_id: areaId,
floor_id: null,
name,
icon: null,
picture: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
});
const AREAS: AreaRegistryEntry[] = [
area("backyard", "Backyard"),
area("bedroom", "Bedroom"),
];
// Resolves a target against the demo registries, like core's
// extract_from_target does: a device includes its child devices, and an area
// its devices and entities.
const extractFromTarget = ({ target }: { target: HassServiceTarget }) => {
const ids = (key: keyof HassServiceTarget): string[] =>
target[key] ? ensureArray(target[key]) : [];
const areaIds = ids("area_id").filter((id) =>
AREAS.some((a) => a.area_id === id)
);
const deviceIds = new Set([
...ids("device_id").filter((id) => DEVICES.some((d) => d.id === id)),
...DEVICES.filter((d) => d.area_id && areaIds.includes(d.area_id)).map(
(d) => d.id
),
]);
DEVICES.forEach((d) => {
if (d.parent_device_id && deviceIds.has(d.parent_device_id)) {
deviceIds.add(d.id);
}
});
const entityIds = new Set(ids("entity_id"));
Object.values(ENTITY_REGISTRY).forEach((entity) => {
if (
(entity.device_id && deviceIds.has(entity.device_id)) ||
(entity.area_id && areaIds.includes(entity.area_id))
) {
entityIds.add(entity.entity_id);
}
});
return {
referenced_entities: [...entityIds],
referenced_devices: [...deviceIds],
referenced_areas: areaIds,
missing_areas: [],
missing_devices: [],
missing_floors: [],
missing_labels: [],
};
};
interface Sample {
name: string;
description: string;
selector: Selector;
value: unknown;
}
const SAMPLES: Sample[] = [
{
name: "Target with exclusions",
description:
"Each target lists what is excluded from it. An exclusion of an area that no longer exists has no effect and is listed apart.",
selector: { target: {} },
value: {
area_id: ["bedroom", "backyard"],
exclude: {
entity_id: ["light.night_light"],
device_id: ["outlet_2"],
area_id: ["deleted_area"],
},
},
},
{
name: "Target without exclusions",
description:
"Exclude offers what the power strip includes, so its outlets can be excluded on their own.",
selector: { target: {} },
value: { device_id: ["power_strip"] },
},
];
@customElement("demo-components-ha-target-picker-exclusions")
class DemoHaTargetPickerExclusions
extends LitElement
implements ProvideHassElement
{
@state() public hass!: HomeAssistant;
@state() private _values = SAMPLES.map((sample) => sample.value);
constructor() {
super();
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("config", "en");
hass.addEntities(ENTITIES);
mockEntityRegistry(hass);
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockHassioSupervisor(hass);
// Set the areas and entity registry directly, typed against the real
// registry types, instead of the demo stubs whose types differ.
const areas: Record<string, AreaRegistryEntry> = {};
AREAS.forEach((a) => {
areas[a.area_id] = a;
});
hass.updateHass({ areas, entities: ENTITY_REGISTRY });
hass.mockWS("config/device_registry/list_composite_splits", () => ({}));
hass.mockWS("extract_from_target", extractFromTarget);
hass.mockWS("auth/sign_path", (params) => params);
}
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render(): TemplateResult {
return html`
${SAMPLES.map(
(sample, idx) => html`
<demo-black-white-row .title=${sample.name}>
${["light", "dark"].map(
(slot) => html`
<ha-settings-row narrow slot=${slot}>
<span slot="heading">${sample.name}</span>
<span slot="description">${sample.description}</span>
<ha-selector
.hass=${this.hass}
.selector=${sample.selector}
.value=${this._values[idx]}
.sampleIdx=${idx}
@value-changed=${this._handleValueChanged}
></ha-selector>
</ha-settings-row>
`
)}
</demo-black-white-row>
`
)}
`;
}
private _handleValueChanged(ev) {
const idx = ev.target.sampleIdx;
this._values = this._values.map((value, i) =>
i === idx ? ev.detail.value : value
);
}
static styles = css`
ha-settings-row {
--settings-row-content-width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-target-picker-exclusions": DemoHaTargetPickerExclusions;
}
}
+1 -1
View File
@@ -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>.
@@ -1,3 +0,0 @@
---
title: Calendar card
---
-114
View File
@@ -1,114 +0,0 @@
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import type { CalendarCardConfig } from "../../../../src/panels/lovelace/cards/types";
import type { HomeAssistant } from "../../../../src/types";
import type { DemoCardConfig } from "../../components/demo-card";
import "../../components/demo-cards";
import { CALENDAR_ENTITIES, mockCalendars } from "../../data/calendar";
const CONFIGS = [
{
heading: "Month view",
config: {
type: "calendar",
entities: ["calendar.family", "calendar.work"],
},
},
{
heading: "List view with title",
config: {
type: "calendar",
title: "This week",
initial_view: "listWeek",
entities: ["calendar.family", "calendar.work"],
},
},
{
heading: "Day view",
config: {
type: "calendar",
initial_view: "dayGridDay",
entities: ["calendar.family", "calendar.work"],
},
},
{
heading: "Add event button",
config: {
type: "calendar",
show_add_event: true,
entities: ["calendar.family"],
},
},
{
heading: "Failing calendar",
config: {
type: "calendar",
initial_view: "listWeek",
entities: ["calendar.family", "calendar.holidays"],
},
},
] satisfies DemoCardConfig<CalendarCardConfig>[];
@customElement("demo-lovelace-calendar-card")
class DemoCalendarCard extends LitElement {
@state() private hass?: HomeAssistant;
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
// Opens the event detail and editor dialogs of the calendar
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render() {
if (!this.hass) {
return nothing;
}
return html`<demo-cards
.hass=${this.hass}
.configs=${CONFIGS}
></demo-cards>`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
// Provided on this element, so the dialogs it opens get the mocks too
const hass = provideHass(this);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
mockCalendars(hass);
hass.addEntities(CALENDAR_ENTITIES);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-lovelace-calendar-card": DemoCalendarCard;
}
}
+1 -1
View File
@@ -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>
`;
}
@@ -1,3 +0,0 @@
---
title: Calendar
---
-74
View File
@@ -1,74 +0,0 @@
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
import type { ShowDialogParams } from "../../../../src/dialogs/make-dialog-manager";
import { showDialog } from "../../../../src/dialogs/make-dialog-manager";
import "../../../../src/components/ha-card";
import "../../../../src/dialogs/more-info/more-info-content";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-more-infos";
import { CALENDAR_ENTITIES, mockCalendars } from "../../data/calendar";
const ENTITIES = ["calendar.family", "calendar.empty", "calendar.holidays"];
@customElement("demo-more-info-calendar")
class DemoMoreInfoCalendar extends LitElement {
@property({ attribute: false }) public hass!: MockHomeAssistant;
public provideHass(el) {
el.hass = this.hass;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("show-dialog", this._dialogManager);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("show-dialog", this._dialogManager);
}
// Opens the event detail and editor dialogs of the calendar
private _dialogManager = (e: HASSDomEvent<ShowDialogParams<unknown>>) => {
const { dialogTag, dialogImport, dialogParams, addHistory, parentElement } =
e.detail;
showDialog(
this,
dialogTag,
dialogParams,
dialogImport,
parentElement,
addHistory
);
};
protected render() {
if (!this.hass) {
return nothing;
}
return html`
<demo-more-infos
.hass=${this.hass}
.entities=${ENTITIES}
></demo-more-infos>
`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
// Provided on this element, so the dialogs it opens get the mocks too
const hass = provideHass(this);
hass.updateTranslations(null, "en");
mockCalendars(hass);
hass.addEntities(CALENDAR_ENTITIES);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-more-info-calendar": DemoMoreInfoCalendar;
}
}
+1 -1
View File
@@ -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`
@@ -21,13 +21,10 @@ import {
getObserverLogs,
downloadUrl as observerLogsDownloadUrl,
} from "../data/observer";
import {
getSupervisorLogs,
getSupervisorLogsFollow,
isSupervisorLogError,
} from "../data/supervisor";
import { getSupervisorLogs, getSupervisorLogsFollow } from "../data/supervisor";
import { ASSUME_CORE_START_SECONDS } from "../ha-landing-page";
const ERROR_CHECK = /^[\d\s-:]+(ERROR|CRITICAL)(.*)/gm;
declare global {
interface HASSDomEvents {
"landing-page-error": undefined;
@@ -64,8 +61,6 @@ class LandingPageLogs extends LitElement {
@state() private _logLinesCount = 0;
private _errorFound = false;
protected render() {
return html`
<div class="actions">
@@ -165,28 +160,26 @@ class LandingPageLogs extends LitElement {
this._logLinesCount = 0;
}
const showError = ERROR_CHECK.test(logs);
const scrolledToBottom = this._scrolledToBottomController.value;
const lines = `${tempLogLine}${logs}`
.split("\n")
.filter((line) => line.trim() !== "");
// handle edge case where the last line is not complete
if (clear || logs.endsWith("\n")) {
if (logs.endsWith("\n")) {
tempLogLine = "";
} else {
tempLogLine = lines.splice(-1, 1)[0];
}
const showError = lines.some(isSupervisorLogError);
if (lines.length) {
this._ansiToHtmlElement?.parseLinesToColoredPre(lines);
this._logLinesCount += lines.length;
}
// only open the logs once, so they can be closed again
if (showError && !this._errorFound) {
this._errorFound = true;
if (showError) {
fireEvent(this, "landing-page-error");
this._show = true;
}
-16
View File
@@ -4,17 +4,8 @@ import type {
DockerNetwork,
NetworkInterface,
} from "../../../src/data/hassio/network";
import type { HassioResolution } from "../../../src/data/hassio/resolution";
import { handleFetchPromise } from "../../../src/util/hass-call-api";
// Supervisor log lines start with a color code and a timestamp, for example
// "\x1b[31m2026-10-21 22:27:32.135+02:00 ERROR (MainThread) ..."
// eslint-disable-next-line no-control-regex
const LOG_ERROR_LINE = /^(?:\x1b\[[0-9;]*m)*[\d\s.:+-]+(?:ERROR|CRITICAL)\b/;
export const isSupervisorLogError = (line: string): boolean =>
LOG_ERROR_LINE.test(line);
export interface NetworkInfo {
interfaces: NetworkInterface[];
docker: DockerNetwork;
@@ -88,13 +79,6 @@ export async function getSupervisorNetworkInfo(): Promise<NetworkInfo> {
return responseData?.data;
}
export async function getSupervisorResolutionInfo(): Promise<HassioResolution> {
const responseData = await handleFetchPromise<
HassioResponse<HassioResolution>
>(fetch("/supervisor-api/resolution/info"));
return responseData?.data;
}
export async function getSupervisorJobsInfo(): Promise<
HassioResponse<SupervisorJobInfo>
> {
+1 -20
View File
@@ -18,7 +18,6 @@ import "./components/landing-page-network";
import {
getSupervisorJobsInfo,
getSupervisorNetworkInfo,
getSupervisorResolutionInfo,
pingSupervisor,
type NetworkInfo,
} from "./data/supervisor";
@@ -50,9 +49,6 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
render() {
const networkIssue = this._networkInfo && !this._networkInfo.host_internet;
// Supervisor retries some failures, so keep showing a running install
const showProgress =
!networkIssue && (!this._supervisorError || this._progress >= 0);
if (!this.localize) {
return html`
@@ -67,7 +63,7 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
<div class="card-content">
<h1>${this.localize("header")}</h1>
${
showProgress
!networkIssue && !this._supervisorError
? html`
<p>${this.localize("subheader")}</p>
<ha-progress-bar
@@ -183,8 +179,6 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
this._networkInfo = await getSupervisorNetworkInfo();
this._networkInfoError = false;
this._coreStatusChecked = false;
await this._fetchSupervisorResolutionInfo();
} catch (err: any) {
if (await this._checkCoreAvailability()) {
// core is available, page reload in progress -> don't show an error
@@ -204,19 +198,6 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
}
}
private async _fetchSupervisorResolutionInfo() {
try {
const resolutionInfo = await getSupervisorResolutionInfo();
if (
resolutionInfo.issues.some((issue) => issue.type === "update_failed")
) {
this._supervisorError = true;
}
} catch (_err: any) {
// Ignore, the logs are checked for errors as well
}
}
private async _fetchSupervisorJobsInfo() {
try {
const jobsInfo = await getSupervisorJobsInfo();
+1 -1
View File
@@ -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",
+28 -25
View File
@@ -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.51.0",
"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"
}
}
+508 -506
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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

-5
View File
@@ -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",
+1 -1
View File
@@ -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",
-16
View File
@@ -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);
};
@@ -1,133 +0,0 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import type { SortingChangedEvent } from "../../components/data-table/ha-data-table";
import { getStorage } from "../decorators/storage";
import type { HASSDomEvent } from "../dom/fire_event";
import type { QueryParamConfig } from "../url/query-params";
import { QueryParamsController } from "./query-params-controller";
export interface DataTableSettingsKeys {
/** Session storage key for the search. */
search?: string;
/** Local storage key for the sorting. */
sorting?: string;
/** Local storage key for the grouping. */
grouping?: string;
}
export interface DataTableSettingsOptions {
/** The grouping when none is stored. */
defaultGrouping?: string;
}
/**
* Keeps the search, sorting and grouping a user picks on a data table page.
* What a link applies (`link` on the table's events) is shown, but not
* stored, as links often carry someone else's choices. Bind
* `searchChanged`, `sortingChanged` and `groupingChanged` to the table's
* events.
*/
export class DataTableSettingsController implements ReactiveController {
/** The search shown, which can be a link's. */
public search: string;
/** The grouping shown, which can be a link's. */
public grouping?: string;
// The search is kept for the session, sorting and grouping for good
private _sessionStorage = getStorage("sessionStorage");
private _localStorage = getStorage("localStorage");
// Only used to know when the page is opened again
private _queryParams: QueryParamsController<QueryParamConfig>;
constructor(
private _host: ReactiveControllerHost,
private _keys: DataTableSettingsKeys,
private _options: DataTableSettingsOptions = {}
) {
this._host.addController(this);
this._queryParams = new QueryParamsController(
this._host,
{},
{
enabled: () => true,
navigated: () => this._navigated(),
}
);
this.search = this._storedSearch();
this.grouping = this._storedGrouping();
}
/** The stored sorting, for the table to start with. */
public get sorting(): SortingChangedEvent | undefined {
return this._keys.sorting
? this._localStorage.getValue<SortingChangedEvent>(this._keys.sorting)
: undefined;
}
/** Show a search, like one a page reads from a link, without storing it. */
public showSearch(value: string) {
this.search = value;
this._host.requestUpdate();
}
public searchChanged = (
ev: HASSDomEvent<HASSDomEvents["search-changed"]>
) => {
this.showSearch(ev.detail.value);
if (this._keys.search && !ev.detail.link) {
this._sessionStorage.setValue(this._keys.search, ev.detail.value);
}
};
public sortingChanged = (ev: HASSDomEvent<SortingChangedEvent>) => {
if (this._keys.sorting && !ev.detail.link) {
const { column, direction } = ev.detail;
this._localStorage.setValue(this._keys.sorting, { column, direction });
}
};
public groupingChanged = (
ev: HASSDomEvent<HASSDomEvents["grouping-changed"]>
) => {
this.grouping = ev.detail.value;
this._host.requestUpdate();
if (this._keys.grouping && !ev.detail.link) {
this._localStorage.setValue(this._keys.grouping, ev.detail.value);
}
};
// A page opened again without params, like from the sidebar, shows what
// the user stored rather than what a link showed before
private _navigated() {
if (this._queryParams.searchParams()?.toString() !== "") {
return;
}
this.search = this._storedSearch();
this.grouping = this._storedGrouping();
this._host.requestUpdate();
}
private _storedSearch(): string {
return (
(this._keys.search
? this._sessionStorage.getValue<string>(this._keys.search)
: undefined) ?? ""
);
}
private _storedGrouping(): string | undefined {
return (
(this._keys.grouping
? this._localStorage.getValue<string>(this._keys.grouping)
: undefined) ?? this._options.defaultGrouping
);
}
}
+40 -139
View File
@@ -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");
+9 -11
View File
@@ -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 -21
View File
@@ -76,15 +76,11 @@ class StorageClass {
return storageKey in this._storage;
}
public getValue<T = any>(storageKey: string): T | undefined {
if (!(storageKey in this._storage)) {
this.addFromStorage(storageKey);
}
public getValue(storageKey: string): any {
return this._storage[storageKey];
}
public setValue<T = any>(storageKey: string, value: T): void {
public setValue(storageKey: string, value: any): any {
const oldValue = this._storage[storageKey];
this._storage[storageKey] = value;
try {
@@ -107,22 +103,9 @@ class StorageClass {
const storages: Record<string, StorageClass> = {};
export type StorageName = "localStorage" | "sessionStorage";
// Shared with `@storage`, so code that isn't a decorated field sees the same values
export const getStorage = (
storageName: StorageName = "localStorage"
): StorageClass => {
if (!(storageName in storages)) {
storages[storageName] = new StorageClass(window[storageName]);
}
return storages[storageName];
};
export function storage(options: {
key?: string;
storage?: StorageName;
storage?: "localStorage" | "sessionStorage";
subscribe?: boolean;
state?: boolean;
serializer?: (value: any) => any;
@@ -136,7 +119,15 @@ export function storage(options: {
throw new Error("This decorator does not support this compilation type.");
}
const storageInstance = getStorage(options.storage);
const storageName = options.storage || "localStorage";
let storageInstance: StorageClass;
if (storageName && storageName in storages) {
storageInstance = storages[storageName];
} else {
storageInstance = new StorageClass(window[storageName]);
storages[storageName] = storageInstance;
}
const storageKey = options.key || String(propertyKey);
-2
View File
@@ -5,8 +5,6 @@ const SWIPE_LOCKED_COMPONENTS = new Set([
"ha-control-circular-slider",
"ha-hs-color-picker",
"ha-map",
// Charts pan and pinch-zoom on touch
"ha-chart-base",
"ha-more-info-control-select-container",
"ha-filter-chip",
]);
+2 -2
View File
@@ -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);
};
-27
View File
@@ -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);
+7 -4
View File
@@ -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.
+1
View File
@@ -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}`
-87
View File
@@ -1,87 +0,0 @@
import type {
DataTableFiltersValue,
DataTableFiltersValues,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
createQueryString,
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
// The filter-pane keys of the backups table's filters
export const BACKUP_TYPE_FILTER = "ha-filter-states";
export const BACKUP_LOCATIONS_FILTER = "backup-locations";
// The filters of the backups table. Search, sorting and grouping are kept by
// the data table, see data-table-query-params.
export const backupsFilterQueryParamConfig = {
list: ["type", "location"],
} as const satisfies QueryParamConfig;
export type BackupsFilterQueryParams = QueryParamValues<
typeof backupsFilterQueryParamConfig
>;
const list = (value: DataTableFiltersValue): string[] | undefined =>
Array.isArray(value) ? value : undefined;
export const backupsFilterQueryParams = (
filters: DataTableFiltersValues
): BackupsFilterQueryParams => ({
type: list(filters[BACKUP_TYPE_FILTER]),
location: list(filters[BACKUP_LOCATIONS_FILTER]),
});
const sorted = (values?: string[]): string[] | undefined =>
values && [...values].sort();
// The filters as the URL holds them, so values that filter the same, such as
// no list and an empty one, or a list in another order, compare equal. A
// filter list sends back what it was given in its own order.
export const backupsFilterQueryString = (
filters: DataTableFiltersValues
): string => {
const { type, location } = backupsFilterQueryParams(filters);
return createQueryString(
{ type: sorted(type), location: sorted(location) },
backupsFilterQueryParamConfig
);
};
// 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 BackupsLink {
search: string;
filters: DataTableFiltersValues;
}
export const backupsLinkFromUrl = (
searchParams: SearchParamsSource
): BackupsLink | undefined => {
if (
!hasQueryParams(searchParams, backupsFilterQueryParamConfig) &&
!hasQueryParams(searchParams, dataTableQueryParamConfig)
) {
return undefined;
}
const params = decodeQueryParams(searchParams, backupsFilterQueryParamConfig);
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
return {
search: search ?? "",
filters: {
// `type=all` asks for every type, as the backups overview links do
[BACKUP_TYPE_FILTER]:
params.type?.filter((type) => type && type !== "all") ?? [],
[BACKUP_LOCATIONS_FILTER]: params.location?.filter(Boolean) ?? [],
},
};
};
-112
View File
@@ -1,112 +0,0 @@
import type {
DataTableFiltersValue,
DataTableFiltersValues,
} from "../../data/data_table_filters";
import { dataTableQueryParamConfig } from "./data-table-query-params";
import {
createQueryString,
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"]),
});
// The filters as the URL holds them, so values that filter the same, such as
// no list and an empty one, or a list in another order, compare equal. A
// filter list sends back what it was given in its own order.
export const helpersFilterQueryString = (
filters: DataTableFiltersValues
): string => {
const params = helpersFilterQueryParams(filters);
const sorted: HelpersFilterQueryParams = {};
for (const key of helpersFilterQueryParamConfig.list) {
sorted[key] = params[key] && [...params[key]].sort();
}
return createQueryString(sorted, helpersFilterQueryParamConfig);
};
// 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"),
},
};
};
-76
View File
@@ -1,76 +0,0 @@
import {
decodeQueryParams,
hasQueryParams,
type QueryParamConfig,
type QueryParamValues,
type SearchParamsSource,
} from "./query-params";
export const DEFAULT_LOG_PROVIDER = "core";
// `filter` is the name the search had before, still read until 2027.5. It is
// in the configs so writing the search replaces it.
export const logSearchQueryParamConfig = {
string: ["search", "filter"],
} as const satisfies QueryParamConfig;
// `raw` shows the full Home Assistant Core log instead of the errors.
export const logsQueryParamConfig = {
string: ["provider", "search", "filter"],
list: ["integration"],
boolean: [{ key: "raw", trueValue: "1" }],
} as const satisfies QueryParamConfig;
export type LogsQueryParams = QueryParamValues<typeof logsQueryParamConfig>;
export interface LogsState {
provider: string;
search: string;
// Only apply to the Home Assistant Core log
integrations: string[];
raw: boolean;
}
export const DEFAULT_LOGS_STATE: LogsState = {
provider: DEFAULT_LOG_PROVIDER,
search: "",
integrations: [],
raw: false,
};
export const logSearchFromUrl = (searchParams: SearchParamsSource): string => {
const { search, filter } = decodeQueryParams(
searchParams,
logSearchQueryParamConfig
);
return search ?? filter ?? "";
};
export const logsQueryParams = (state: LogsState): LogsQueryParams => ({
provider:
state.provider === DEFAULT_LOG_PROVIDER ? undefined : state.provider,
search: state.search,
integration:
state.provider === DEFAULT_LOG_PROVIDER ? state.integrations : undefined,
raw: state.provider === DEFAULT_LOG_PROVIDER && state.raw,
});
// What a link says to show, undefined when it has none of the page's params
// so what was picked before stays. A link replaces the search and the filter.
export const logsLinkFromUrl = (
searchParams: SearchParamsSource
): LogsState | undefined => {
if (!hasQueryParams(searchParams, logsQueryParamConfig)) {
return undefined;
}
const params = decodeQueryParams(searchParams, logsQueryParamConfig);
const provider = params.provider ?? DEFAULT_LOG_PROVIDER;
const core = provider === DEFAULT_LOG_PROVIDER;
return {
provider,
search: params.search ?? params.filter ?? "",
integrations: core ? (params.integration?.filter(Boolean) ?? []) : [],
raw: core && !!params.raw,
};
};
@@ -24,18 +24,3 @@ export const decodeMarketplaceFilterQueryParams = (
export const createMarketplaceFilterQueryString = (
values: MarketplaceFilterQueryParams
): string => createQueryString(values, marketplaceFilterQueryParamConfig);
// The filters as the URL holds them with each list sorted, so the same filters
// in another order compare equal. A filter list sends back what it was given
// in its own order.
export const sortedMarketplaceFilterQueryString = (
values: MarketplaceFilterQueryParams
): string => {
const sorted: MarketplaceFilterQueryParams = {};
for (const key of marketplaceFilterQueryParamConfig.list) {
sorted[key] = values[key] && [...values[key]].sort();
}
return createMarketplaceFilterQueryString(sorted);
};
+1 -11
View File
@@ -4,8 +4,6 @@
interface DeepEqualOptions {
/** Compare Symbol properties in addition to string keys */
compareSymbols?: boolean;
/** Treat objects with the same keys in a different order as not equal */
compareKeyOrder?: boolean;
}
export const deepEqual = (
@@ -92,18 +90,10 @@ export const deepEqual = (
}
const keys = Object.keys(a);
const keysB = Object.keys(b);
length = keys.length;
if (length !== keysB.length) {
if (length !== Object.keys(b).length) {
return false;
}
if (options?.compareKeyOrder) {
for (i = length; i-- !== 0;) {
if (keys[i] !== keysB[i]) {
return false;
}
}
}
for (i = length; i-- !== 0;) {
if (!Object.prototype.hasOwnProperty.call(b, keys[i])) {
return false;
+3 -9
View File
@@ -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;
}
File diff suppressed because it is too large. Load diff
@@ -1,294 +0,0 @@
import { mdiDragVerticalVariant } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { consume } from "../../common/decorators/consume";
import type { HASSDomCurrentTargetEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { preventDefault } from "../../common/dom/prevent_default";
import { formatNumber } from "../../common/number/format_number";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization } from "../../types";
import "../ha-svg-icon";
// Shaped like ha-switch: a track, and the visible range as a pill with a drag
// handle at each end
export const ZOOM_SLIDER_HEIGHT = 24;
const THUMB_SIZE = 20;
const THUMB_INSET = (ZOOM_SLIDER_HEIGHT - THUMB_SIZE) / 2;
// Leaves room to grab the range between its thumbs, however small it gets
const RANGE_MIN_WIDTH = 2 * (THUMB_INSET + THUMB_SIZE) + ZOOM_SLIDER_HEIGHT;
// The smallest visible range the thumbs can be dragged to, in percent
const MIN_RANGE = 1;
type SliderPart = "start" | "end" | "range";
@customElement("ha-chart-zoom-slider")
export class HaChartZoomSlider extends LitElement {
@property({ type: Number }) public start = 0;
@property({ type: Number }) public end = 100;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: HomeAssistantInternationalization;
private _drag?: {
pointerId: number;
part: SliderPart;
x: number;
range: [number, number];
};
protected render() {
const { start, end } = this;
const { localize, locale } = this._i18n;
const at = (percent: number) =>
`calc((100% - ${RANGE_MIN_WIDTH}px) * ${percent / 100})`;
const percent = (value: number) =>
formatNumber(value / 100, locale, {
style: "percent",
maximumFractionDigits: 1,
});
return html`
<div
class="track"
@pointerdown=${this._handleTrackPointerDown}
@selectstart=${preventDefault}
>
<div
class="range"
data-part="range"
role="slider"
aria-orientation="horizontal"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow=${Math.round(start)}
aria-valuetext=${`${percent(start)} – ${percent(end)}`}
aria-label=${localize("ui.components.history_charts.zoom_range")}
tabindex="0"
style=${styleMap({
left: at(start),
width: `calc(${at(end - start)} + ${RANGE_MIN_WIDTH}px)`,
})}
@pointerdown=${this._handlePointerDown}
@pointermove=${this._handlePointerMove}
@pointerup=${this._handlePointerUp}
@pointercancel=${this._handlePointerUp}
@keydown=${this._handleKeyDown}
></div>
${(["start", "end"] as const).map(
(part) =>
html`<div
class="thumb"
data-part=${part}
role="slider"
aria-orientation="horizontal"
aria-valuemin=${
part === "start"
? 0
: Math.round(Math.min(start + MIN_RANGE, end))
}
aria-valuemax=${
part === "start"
? Math.round(Math.max(end - MIN_RANGE, start))
: 100
}
aria-valuenow=${Math.round(part === "start" ? start : end)}
aria-valuetext=${percent(part === "start" ? start : end)}
aria-label=${localize(`ui.components.history_charts.zoom_${part}`)}
tabindex="0"
style=${styleMap({
left:
part === "start"
? `calc(${at(start)} + ${THUMB_INSET}px)`
: `calc(${at(end)} + ${RANGE_MIN_WIDTH - THUMB_INSET - THUMB_SIZE}px)`,
})}
@pointerdown=${this._handlePointerDown}
@pointermove=${this._handlePointerMove}
@pointerup=${this._handlePointerUp}
@pointercancel=${this._handlePointerUp}
@keydown=${this._handleKeyDown}
>
<ha-svg-icon .path=${mdiDragVerticalVariant}></ha-svg-icon>
</div>`
)}
</div>
`;
}
// Percent of the full range per pixel along the track
private _getScale() {
return 100 / Math.max(this.clientWidth - RANGE_MIN_WIDTH, 1);
}
// Pressing the track beside the visible range centers the range there
private _handleTrackPointerDown(
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
if (ev.button !== 0 || ev.target !== ev.currentTarget) {
return;
}
const position = (ev.offsetX - RANGE_MIN_WIDTH / 2) * this._getScale();
this._move(
"range",
[this.start, this.end],
position - (this.start + this.end) / 2
);
}
private _handlePointerDown(
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
if (ev.button !== 0 || !ev.isPrimary) {
return;
}
ev.currentTarget.setPointerCapture(ev.pointerId);
this._drag = {
pointerId: ev.pointerId,
part: ev.currentTarget.dataset.part as SliderPart,
x: ev.clientX,
range: [this.start, this.end],
};
}
private _handlePointerMove(ev: PointerEvent) {
if (ev.pointerId !== this._drag?.pointerId) {
return;
}
// The release can be missed, so a move without a pressed button ends it
if (!ev.buttons) {
this._drag = undefined;
return;
}
const { part, x, range } = this._drag;
this._move(part, range, (ev.clientX - x) * this._getScale());
}
private _handlePointerUp(ev: PointerEvent) {
if (ev.pointerId === this._drag?.pointerId) {
this._drag = undefined;
}
}
private _handleKeyDown(
ev: KeyboardEvent & HASSDomCurrentTargetEvent<HTMLElement>
) {
const part = ev.currentTarget.dataset.part as SliderPart;
const span = this.end - this.start;
const decrease = ev.key === "ArrowLeft" || ev.key === "ArrowDown";
// A ninth of a shrunk span undoes a tenth of the original one
const step =
part !== "range" && (part === "start") === decrease
? span / 9
: span / 10;
const shift = {
ArrowLeft: -step,
ArrowDown: -step,
ArrowRight: step,
ArrowUp: step,
Home: -100,
End: 100,
}[ev.key];
if (shift === undefined) {
return;
}
ev.preventDefault();
this._move(part, [this.start, this.end], shift);
}
// Moves the whole range, or one end of it, by a percentage of the full range
private _move(
part: SliderPart,
[start, end]: [number, number],
shift: number
) {
let newStart = start;
let newEnd = end;
if (part === "range") {
const clamped = Math.max(-start, Math.min(100 - end, shift));
newStart += clamped;
newEnd += clamped;
} else if (part === "start") {
newStart = Math.min(
Math.max(start + shift, 0),
Math.max(end - MIN_RANGE, start)
);
} else {
newEnd = Math.max(
Math.min(end + shift, 100),
Math.min(start + MIN_RANGE, end)
);
}
if (newStart === this.start && newEnd === this.end) {
return;
}
fireEvent(this, "zoom-slider-changed", { start: newStart, end: newEnd });
}
static styles = css`
:host {
display: block;
height: ${ZOOM_SLIDER_HEIGHT}px;
}
.track {
position: relative;
height: 100%;
border-radius: var(--ha-border-radius-pill);
background-color: var(--ha-color-fill-neutral-quiet-resting);
touch-action: none;
cursor: pointer;
}
.range {
position: absolute;
top: 0;
bottom: 0;
border-radius: var(--ha-border-radius-pill);
/* The ha-switch thumb color, which stands out in both themes */
background-color: var(--ha-color-on-neutral-normal);
cursor: grab;
}
.track,
.range {
transition: background-color var(--ha-animation-duration-fast) ease-in-out;
}
@media (hover: hover) {
.track:hover {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
.range:hover,
.track:has(.thumb:hover) .range {
background-color: var(--ha-color-on-neutral-quiet);
}
}
.range:active {
cursor: grabbing;
background-color: var(--ha-color-on-neutral-normal);
}
.thumb {
position: absolute;
top: ${THUMB_INSET}px;
display: flex;
width: ${THUMB_SIZE}px;
height: ${THUMB_SIZE}px;
border-radius: var(--ha-border-radius-sm);
color: var(--ha-color-on-neutral-loud);
--mdc-icon-size: ${THUMB_SIZE}px;
cursor: ew-resize;
}
.range:focus-visible,
.thumb:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
}
`;
}
declare global {
interface HASSDomEvents {
"zoom-slider-changed": { start: number; end: number };
}
interface HTMLElementTagNameMap {
"ha-chart-zoom-slider": HaChartZoomSlider;
}
}
-2
View File
@@ -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 -16
View File
@@ -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,
};
@@ -93,8 +93,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "hide-reset-button", type: Boolean })
public hideResetButton?: boolean;
@state() private _chartData: LineSeriesOption[] = [];
@@ -125,7 +125,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideZoomControls=${this.hideZoomControls}
.hideResetButton=${this.hideResetButton}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -60,8 +60,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "hide-reset-button", type: Boolean })
public hideResetButton?: boolean;
@state() private _chartData: CustomSeriesOption[] = [];
@@ -84,10 +84,9 @@ export class StateHistoryChartTimeline extends LitElement {
}px`}
.data=${this._chartData as HaECSeries}
small-controls
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.hideZoomControls=${this.hideZoomControls}
.hideResetButton=${this.hideResetButton}
></ha-chart-base>
`;
}
+43 -8
View File
@@ -1,6 +1,7 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiRestart } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import {
customElement,
eventOptions,
@@ -10,7 +11,6 @@ import {
} from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomEvent,
HASSDomTargetEvent,
@@ -22,6 +22,8 @@ import type {
} from "../../data/history";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-svg-icon";
import "./state-history-chart-line";
import type { StateHistoryChartLine } from "./state-history-chart-line";
import "./state-history-chart-timeline";
@@ -47,7 +49,6 @@ declare global {
end: number;
chartIndex: number;
};
"history-charts-zoom": { start: number; end: number };
}
}
@@ -116,6 +117,8 @@ export class StateHistoryCharts extends LitElement {
@state() private _chartCount = 0;
@state() private _hasZoomedCharts = false;
@queryAll("state-history-chart-line, state-history-chart-timeline")
private _chartComponents!: NodeListOf<
StateHistoryChartLine | StateHistoryChartTimeline
@@ -173,6 +176,18 @@ export class StateHistoryCharts extends LitElement {
this._renderHistoryItem(item, index)
)}`
}
${
this.syncCharts && this._hasZoomedCharts
? html`<ha-button
size="l"
class="reset-button"
@click=${this._handleGlobalZoomReset}
>
<ha-svg-icon slot="start" .path=${mdiRestart}></ha-svg-icon>
${this.hass.localize("ui.components.history_charts.zoom_reset")}
</ha-button>`
: nothing
}
`;
}
@@ -206,7 +221,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-zoom-controls=${this.syncCharts}
?hide-reset-button=${this.syncCharts}
></state-history-chart-line>
</div> `;
}
@@ -226,7 +241,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-zoom-controls=${this.syncCharts}
?hide-reset-button=${this.syncCharts}
></state-history-chart-timeline>
</div> `;
};
@@ -330,7 +345,7 @@ export class StateHistoryCharts extends LitElement {
const { start, end, chartIndex } = e.detail;
fireEvent(this, "history-charts-zoom", { start, end });
this._hasZoomedCharts = start !== 0 || end !== 100;
this._syncZoomToAllCharts(start, end, chartIndex);
}
@@ -356,8 +371,21 @@ export class StateHistoryCharts extends LitElement {
});
}
public zoom(start: number, end: number) {
this._syncZoomToAllCharts(start, end);
private _handleGlobalZoomReset() {
this._hasZoomedCharts = false;
this._isSyncing = true;
requestAnimationFrame(() => {
this._chartComponents.forEach((chartComponent: any) => {
const chartBase =
chartComponent.renderRoot?.querySelector("ha-chart-base");
if (chartBase) {
chartBase.zoom(0, 100);
}
});
this._isSyncing = false;
});
}
private _newestStateTime(): number {
@@ -464,6 +492,13 @@ export class StateHistoryCharts extends LitElement {
state-history-chart-line {
width: 100%;
}
.reset-button {
position: fixed;
bottom: calc(24px + var(--safe-area-inset-bottom));
right: calc(24px + var(--safe-area-inset-bottom));
z-index: 1;
--ha-button-box-shadow: var(--ha-box-shadow-l);
}
`;
}
+1 -1
View File
@@ -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);
+9 -31
View File
@@ -63,8 +63,6 @@ export interface CollapsedChangedEvent {
export interface SortingChangedEvent {
column: string;
direction: SortingDirection;
// Set when a link sorted the table, not the user
link?: boolean;
}
export type SortingDirection = "desc" | "asc" | null;
@@ -1506,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;
@@ -1767,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 */
@@ -1781,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 {
@@ -1839,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;
@@ -1863,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);
}
@@ -1931,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 {
Loaded 100 of 462 files, more files were not shown because too many files have changed in this diff. Show more