Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72c435359e | ||
|
|
4307d59177 | ||
|
|
1420885d00 | ||
|
|
e535c04b04 | ||
|
|
e262c9e4b1 | ||
|
|
aa4c86cf22 | ||
|
|
a349ad16ad | ||
|
|
88c7ba5ca5 | ||
|
|
b2f31c7cd5 | ||
|
|
7b1ad2ea81 | ||
|
|
d6b405c9e4 | ||
|
|
26bb29408a | ||
|
|
b21432c951 | ||
|
|
e961261c97 | ||
|
|
50d80da0b2 | ||
|
|
3c6f164f14 | ||
|
|
f91aebf8cf | ||
|
|
b30024f744 | ||
|
|
caea88e184 | ||
|
|
052cff9955 | ||
|
|
cb784a6b95 | ||
|
|
bffed854fb | ||
|
|
4b215481b8 | ||
|
|
09a6cb11fa | ||
|
|
8afb4f69bb | ||
|
|
09638965f8 |
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.
|
||||
@@ -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
|
||||
@@ -67,3 +67,4 @@ test/e2e/app/dist/
|
||||
.serena
|
||||
|
||||
test/benchmarks/results/
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -18,6 +18,17 @@ const polyfillSupport = {
|
||||
safari: 17.4,
|
||||
samsung: 15.0,
|
||||
},
|
||||
"element-getattributenames": {
|
||||
android: 61,
|
||||
chrome: 61,
|
||||
edge: 18,
|
||||
firefox: 45,
|
||||
ios: 10.3,
|
||||
opera: 48,
|
||||
opera_mobile: 45,
|
||||
safari: 10.1,
|
||||
samsung: 8.0,
|
||||
},
|
||||
"element-toggleattribute": {
|
||||
android: 69,
|
||||
chrome: 69,
|
||||
@@ -56,20 +67,39 @@ const polyfillSupport = {
|
||||
"intl-other": {
|
||||
// Not specified (i.e. always try polyfill) since compatibility depends on supported locales
|
||||
},
|
||||
"resize-observer": {
|
||||
android: 64,
|
||||
chrome: 64,
|
||||
edge: 79,
|
||||
firefox: 69,
|
||||
ios: 13.4,
|
||||
opera: 51,
|
||||
opera_mobile: 47,
|
||||
safari: 13.1,
|
||||
samsung: 9.0,
|
||||
},
|
||||
};
|
||||
|
||||
// Map of instance and static properties to the corresponding polyfill key
|
||||
// and actual module to import
|
||||
// Map of global variables and/or instance and static properties to the
|
||||
// corresponding polyfill key and actual module to import
|
||||
const polyfillMap = {
|
||||
global: {
|
||||
ResizeObserver: {
|
||||
key: "resize-observer",
|
||||
module: join(POLYFILL_DIR, "resize-observer.ts"),
|
||||
},
|
||||
},
|
||||
instance: {
|
||||
attachInternals: {
|
||||
key: "element-internals",
|
||||
module: "element-internals-polyfill",
|
||||
},
|
||||
toggleAttribute: {
|
||||
key: "element-toggleattribute",
|
||||
module: join(POLYFILL_DIR, "element-toggleattribute.ts"),
|
||||
},
|
||||
...Object.fromEntries(
|
||||
["getAttributeNames", "toggleAttribute"].map((prop) => {
|
||||
const key = `element-${prop.toLowerCase()}`;
|
||||
return [prop, { key, module: join(POLYFILL_DIR, `${key}.ts`) }];
|
||||
})
|
||||
),
|
||||
},
|
||||
static: {
|
||||
Intl: {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 627654,
|
||||
"core": 55147,
|
||||
"authorize": 593862,
|
||||
"onboarding": 709255
|
||||
"app": 624240,
|
||||
"core": 55144,
|
||||
"authorize": 594400,
|
||||
"onboarding": 705499
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 724225,
|
||||
"core": 157026,
|
||||
"authorize": 693913,
|
||||
"onboarding": 812415
|
||||
"app": 776696,
|
||||
"core": 188309,
|
||||
"authorize": 763316,
|
||||
"onboarding": 879807
|
||||
}
|
||||
}
|
||||
@@ -96,9 +96,8 @@ module.exports.htmlMinifierOptions = {
|
||||
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
|
||||
// Highest syntax the minifier may emit; it never downlevels. Every browser
|
||||
// in [modern] is well past ES2020 (universal since spring 2020); the
|
||||
// [legacy] floors (Chrome 67 / Safari 15) top out at ES2019: optional
|
||||
// catch binding is in, `?.` and `??` (Chrome 80) are not.
|
||||
ecma: latestBuild ? 2020 : 2019,
|
||||
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
|
||||
ecma: latestBuild ? 2020 : 2017,
|
||||
module: latestBuild,
|
||||
format: { comments: false },
|
||||
sourceMap: !isTestBuild,
|
||||
@@ -186,6 +185,7 @@ module.exports.babelOptions = ({ latestBuild, isTestBuild, sw, worker }) => ({
|
||||
path.join(paths.root_dir, "src/resources/polyfills"),
|
||||
...[
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
"@lit-labs/virtualizer/polyfills",
|
||||
"@webcomponents/scoped-custom-element-registry",
|
||||
"element-internals-polyfill",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
|
||||
@@ -2,14 +2,10 @@
|
||||
|
||||
import fs from "fs-extra";
|
||||
import gulp from "gulp";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
import { ensureMapAssets, mapAssetsDir } from "./map-assets.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
const npmPath = (...parts) =>
|
||||
path.resolve(paths.root_dir, "node_modules", ...parts);
|
||||
const polyPath = (...parts) => path.resolve(paths.root_dir, ...parts);
|
||||
@@ -87,24 +83,9 @@ async function copyMapPanel(staticDir) {
|
||||
|
||||
function copyZXingWasm(staticDir) {
|
||||
const staticPath = genStaticPath(staticDir);
|
||||
// Resolve through barcode-detector, the wasm must match the zxing-wasm JS it bundles
|
||||
const zxingWasm = createRequire(require.resolve("barcode-detector")).resolve(
|
||||
"zxing-wasm/reader/zxing_reader.wasm"
|
||||
);
|
||||
const {
|
||||
ZXING_WASM_SHA256,
|
||||
ZXING_WASM_VERSION,
|
||||
} = require("barcode-detector/pure");
|
||||
const wasm = fs.readFileSync(zxingWasm);
|
||||
if (createHash("sha256").update(wasm).digest("hex") !== ZXING_WASM_SHA256) {
|
||||
throw new Error(
|
||||
`${zxingWasm} does not match the zxing-wasm ${ZXING_WASM_VERSION} bundled in barcode-detector`
|
||||
);
|
||||
}
|
||||
// Versioned file name, see locateFile in ha-qr-scanner
|
||||
fs.outputFileSync(
|
||||
staticPath("js", `zxing_reader-${ZXING_WASM_VERSION}.wasm`),
|
||||
wasm
|
||||
copyFileDir(
|
||||
npmPath("zxing-wasm/dist/reader/zxing_reader.wasm"),
|
||||
staticPath("js")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -153,9 +153,7 @@ gulp.task("rspack-watch-app", () => {
|
||||
: createAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
).watch({ poll: isWsl }, doneHandler());
|
||||
gulp.watch(
|
||||
// Watch the folder via a glob; git replaces en.json on checkout/stash,
|
||||
// which can permanently drop a watch on the file itself
|
||||
path.join(paths.translations_src, "*.json"),
|
||||
path.join(paths.translations_src, "en.json"),
|
||||
gulp.series("build-translations", "copy-translations-app")
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ const fs = require("fs");
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
const browserslist = require("browserslist");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const { RsdoctorRspackPlugin } = require("@rsdoctor/rspack-plugin");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -41,21 +40,6 @@ const TOOLCHAIN_PACKAGES = [
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Not direct dependencies: read the copy the package that loads them uses.
|
||||
const TOOLCHAIN_PACKAGE_PARENTS = {
|
||||
terser: "terser-webpack-plugin",
|
||||
"caniuse-lite": "browserslist",
|
||||
};
|
||||
|
||||
const toolchainVersion = (pkg) => {
|
||||
const parent = TOOLCHAIN_PACKAGE_PARENTS[pkg];
|
||||
const lookup = parent && [
|
||||
path.dirname(require.resolve(`${parent}/package.json`)),
|
||||
];
|
||||
return require(require.resolve(`${pkg}/package.json`, { paths: lookup }))
|
||||
.version;
|
||||
};
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
@@ -68,8 +52,43 @@ const CONFIG_FILES = [
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
// Its exports define the components source aliases below
|
||||
path.join(__dirname, "..", "packages", "components", "package.json"),
|
||||
];
|
||||
|
||||
// Resolve @home-assistant/components entry points to their sources, so the package
|
||||
// needs no build step during development. The entry points are read from the
|
||||
// "ha-source" export condition of the package; subpath patterns are expanded
|
||||
// for every element, i.e. every directory with a global.ts, like "button" and
|
||||
// "button/close".
|
||||
const COMPONENTS_PACKAGE_DIR = path.join(
|
||||
paths.root_dir,
|
||||
"packages",
|
||||
"components"
|
||||
);
|
||||
const COMPONENTS_SRC_DIR = path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
"src",
|
||||
"components"
|
||||
);
|
||||
const componentElements = fs
|
||||
.readdirSync(COMPONENTS_SRC_DIR, { recursive: true })
|
||||
.filter((file) => path.basename(file) === "global.ts")
|
||||
.map((file) => path.dirname(file).split(path.sep).join("/"));
|
||||
const componentsSourceAliases = Object.fromEntries(
|
||||
Object.entries(require("../packages/components/package.json").exports)
|
||||
.filter(([, target]) => target["ha-source"])
|
||||
.flatMap(([subpath, target]) =>
|
||||
(subpath.includes("*") ? componentElements : [undefined]).map((name) => [
|
||||
`${path.posix.join("@home-assistant/components", name ? subpath.replace("*", name) : subpath)}$`,
|
||||
path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
name ? target["ha-source"].replace("*", name) : target["ha-source"]
|
||||
),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Patches change a package's files but not its version, which is all the
|
||||
// node_modules snapshot checks, so their contents go into the version too.
|
||||
const PATCHES_DIR = path.join(paths.root_dir, "patches");
|
||||
@@ -79,7 +98,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 +250,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 +415,7 @@ 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"
|
||||
),
|
||||
}),
|
||||
...componentsSourceAliases,
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
@@ -434,6 +432,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$":
|
||||
|
||||
@@ -301,6 +301,14 @@ export default tseslint.config(
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
// Published as native ESM, where package subpath imports such as
|
||||
// "lit/decorators.js" need their extension.
|
||||
files: ["packages/*/src/**/*.ts"],
|
||||
rules: {
|
||||
"import-x/extensions": ["error", "ignorePackages", { ts: "never" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,3 +0,0 @@
|
||||
---
|
||||
title: Calendar card
|
||||
---
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
---
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"lint:prettier": "prettier . --cache --check",
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "node ./node_modules/@typescript/native/bin/tsc && node ./node_modules/@typescript/native/bin/tsc -p .github/scripts",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:lit": "lit-analyzer \"{.,*,packages/*}/src/**/*.ts\"",
|
||||
"lint:element-imports": "eslint \"**/src/**/*.{js,ts,html}\" --ignore-pattern=.gitignore --rule '{\"ha/no-unused-element-import\": \"warn\"}'",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "pnpm run lint:eslint && pnpm run lint:prettier && pnpm run lint:types && pnpm run lint:lit",
|
||||
@@ -42,7 +42,7 @@
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.7",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-python": "6.2.1",
|
||||
@@ -51,19 +51,19 @@
|
||||
"@codemirror/lint": "6.9.7",
|
||||
"@codemirror/search": "6.7.2",
|
||||
"@codemirror/state": "6.7.6",
|
||||
"@codemirror/view": "6.43.15",
|
||||
"@codemirror/view": "6.43.14",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.8.2",
|
||||
"@formatjs/intl-datetimeformat": "7.8.1",
|
||||
"@formatjs/intl-displaynames": "7.3.15",
|
||||
"@formatjs/intl-durationformat": "0.11.1",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.12",
|
||||
"@formatjs/intl-listformat": "8.3.15",
|
||||
"@formatjs/intl-locale": "5.3.12",
|
||||
"@formatjs/intl-numberformat": "9.4.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.16",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.16",
|
||||
"@home-assistant/webawesome": "3.14.0-ha.1",
|
||||
"@formatjs/intl-pluralrules": "6.3.15",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.15",
|
||||
"@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,19 +80,18 @@
|
||||
"@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.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.201.1",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.3",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.51.0",
|
||||
"core-js": "3.50.0",
|
||||
"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",
|
||||
@@ -103,13 +102,13 @@
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.7.1",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "12.1.4",
|
||||
"intl-messageformat": "12.1.3",
|
||||
"js-yaml": "5.5.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"maplibre-gl": "6.14.0",
|
||||
"marked": "18.1.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
@@ -132,21 +131,21 @@
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"xss": "1.0.16",
|
||||
"zrender": "6.1.0"
|
||||
"xss": "1.0.15",
|
||||
"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",
|
||||
"@home-assistant/components": "workspace:*",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@lokalise/node-api": "16.6.0",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
@@ -173,9 +172,9 @@
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.4",
|
||||
"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-config-prettier": "10.1.8",
|
||||
@@ -214,7 +213,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"vite": "8.3.4",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
@@ -224,6 +223,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.22.1"
|
||||
"node": "24.22.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
# @home-assistant/components
|
||||
|
||||
Home Assistant Components (`hac-*`): framework-agnostic web components built
|
||||
with [Lit](https://lit.dev).
|
||||
|
||||
> Work in progress, not published yet.
|
||||
|
||||
## Loading modes
|
||||
|
||||
Every element is imported on its own, in one of two ways:
|
||||
|
||||
| Import | Registers in | Uses |
|
||||
| --------------------------------------------- | -------------------- | ------------------------------------------------ |
|
||||
| `@home-assistant/components/<element>` | Global registry | The bundled copy. Used by Home Assistant itself. |
|
||||
| `@home-assistant/components/<element>/scoped` | Your scoped registry | The bundled copy, isolated from other copies. |
|
||||
|
||||
`<element>` is the group, like `button` for `hac-button`, or the group and a
|
||||
variant, like `button/close` for `hac-button-close`.
|
||||
|
||||
Global registration skips tag names that are already registered, so the first
|
||||
registration wins and nothing throws.
|
||||
|
||||
### Global
|
||||
|
||||
```ts
|
||||
import "@home-assistant/components/button";
|
||||
import "@home-assistant/components/button/close";
|
||||
```
|
||||
|
||||
### Scoped
|
||||
|
||||
Pass the component's elements to `ScopedElementsMixin` for Lit, or to
|
||||
`createScopedElementRegistry` for plain custom elements. Forgetting the
|
||||
elements is a TypeScript error, and a runtime error in plain JavaScript.
|
||||
|
||||
Lit:
|
||||
|
||||
```ts
|
||||
import { LitElement, html } from "lit";
|
||||
import { ScopedElementsMixin } from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {
|
||||
render() {
|
||||
return html`<hac-card>…</hac-card>`;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Plain custom elements:
|
||||
|
||||
```js
|
||||
import {
|
||||
attachScopedShadow,
|
||||
createScopedElement,
|
||||
createScopedElementRegistry,
|
||||
} from "@home-assistant/components/scoped";
|
||||
import { hacCard } from "@home-assistant/components/card/scoped";
|
||||
|
||||
// Create the registry once, outside the class: all instances of the card share it
|
||||
const registry = createScopedElementRegistry([hacCard]);
|
||||
|
||||
class MyCard extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
const root = attachScopedShadow(this, { mode: "open" }, registry);
|
||||
root.innerHTML = "<hac-card>…</hac-card>";
|
||||
// or: root.append(createScopedElement(root, "hac-card"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`innerHTML`, `insertAdjacentHTML` and `createScopedElement` use the scoped
|
||||
registry. `document.createElement` uses the global one, so avoid it for
|
||||
scoped elements.
|
||||
|
||||
Scoped registries need native support (Chrome 146+, Safari 26) or
|
||||
`@webcomponents/scoped-custom-element-registry`, which Home Assistant loads.
|
||||
Without either, elements are registered globally instead, and an already
|
||||
registered tag name keeps its existing class.
|
||||
|
||||
Only elements rendered inside the shadow root use the scoped registry. Light
|
||||
DOM content, such as slotted elements, uses the registry of the tree it is
|
||||
written in.
|
||||
|
||||
`lit` is a peer dependency.
|
||||
|
||||
### TypeScript
|
||||
|
||||
Each element's entry points declare its tag in `HTMLElementTagNameMap`, so
|
||||
`document.querySelector("hac-card")` and templates are typed once the element
|
||||
is imported. For the class itself, import its type from the `scoped` entry
|
||||
point; `import type` loads nothing at runtime:
|
||||
|
||||
```ts
|
||||
import type { HacCard } from "@home-assistant/components/card/scoped";
|
||||
```
|
||||
|
||||
The package has no root entry point: each element and the scoped helpers
|
||||
(`@home-assistant/components/scoped`) have their own.
|
||||
|
||||
## Theming
|
||||
|
||||
Components read the Home Assistant theme variables when they are available
|
||||
and fall back to built-in defaults otherwise. Each component also exposes its
|
||||
own `--hac-*` custom properties, documented in its source.
|
||||
|
||||
## Structure
|
||||
|
||||
Elements are organized in groups. A group's directory holds its default
|
||||
element, and variants live in subdirectories, so the directories mirror the
|
||||
import paths:
|
||||
|
||||
```
|
||||
src/components/button/ <hac-button> -> @home-assistant/components/button
|
||||
hac-button.ts class, does not register itself
|
||||
info.ts tag name and version
|
||||
global.ts registers globally
|
||||
scoped.ts elements for a scoped registry -> …/scoped
|
||||
close/ <hac-button-close> -> @home-assistant/components/button/close
|
||||
hac-button-close.ts
|
||||
info.ts, global.ts, scoped.ts
|
||||
```
|
||||
|
||||
Shared code lives in `src/utils/`: `registration/` for defining elements
|
||||
(global and scoped) and `element/` for helpers that components use
|
||||
themselves, like slot checks.
|
||||
|
||||
A variant's tag name is `hac-<group>-<variant>`. `scoped` and `info` are
|
||||
reserved and cannot be variant names.
|
||||
|
||||
Every element has its own version in `info.ts`.
|
||||
|
||||
### Dependencies
|
||||
|
||||
A component that renders other hac components in its shadow root lists them
|
||||
in `static dependencies`, with the tag name from its `info.ts`, and imports
|
||||
only their classes:
|
||||
|
||||
```ts
|
||||
export class HacHeader extends LitElement {
|
||||
static readonly dependencies = [
|
||||
{ tagName: BUTTON_CLOSE_TAG_NAME, element: HacButtonClose },
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Never register a dependency yourself. The entry point decides where it goes:
|
||||
|
||||
- `global` registers dependencies globally, like the component. Inside Home
|
||||
Assistant everything stays in the global registry.
|
||||
- `scoped` registers a variant of the component whose shadow root has its own
|
||||
registry with the bundled dependencies. Its internals never pick up another
|
||||
copy from the page.
|
||||
|
||||
Entry points are mapped with subpath patterns in `package.json`, so a new
|
||||
element directory needs no `package.json` change. Only `global.ts` files have
|
||||
side effects, which `sideEffects` relies on for tree shaking.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
pnpm --filter @home-assistant/components build # ESM + type declarations to dist/
|
||||
pnpm --filter @home-assistant/components dev # rebuild on change
|
||||
```
|
||||
|
||||
Inside this repository the app uses the sources directly through
|
||||
the `ha-source` export condition, so no build is needed during development.
|
||||
The package is built with [Rslib](https://rslib.rs) in bundleless mode: every
|
||||
source file becomes its own ES module.
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"name": "@home-assistant/components",
|
||||
"version": "0.0.0",
|
||||
"description": "Home Assistant web components (hac-* elements) built with Lit",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/home-assistant/frontend.git",
|
||||
"directory": "packages/components"
|
||||
},
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"./dist/components/**/global.js",
|
||||
"./src/components/**/global.ts"
|
||||
],
|
||||
"exports": {
|
||||
"./scoped": {
|
||||
"ha-source": "./src/scoped.ts",
|
||||
"types": "./dist/scoped.d.ts",
|
||||
"import": "./dist/scoped.js"
|
||||
},
|
||||
"./package.json": "./package.json",
|
||||
"./*/scoped": {
|
||||
"ha-source": "./src/components/*/scoped.ts",
|
||||
"types": "./dist/components/*/scoped.d.ts",
|
||||
"import": "./dist/components/*/scoped.js"
|
||||
},
|
||||
"./*/info": {
|
||||
"ha-source": "./src/components/*/info.ts",
|
||||
"types": "./dist/components/*/info.d.ts",
|
||||
"import": "./dist/components/*/info.js"
|
||||
},
|
||||
"./*": {
|
||||
"ha-source": "./src/components/*/global.ts",
|
||||
"types": "./dist/components/*/global.d.ts",
|
||||
"import": "./dist/components/*/global.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "rslib build",
|
||||
"dev": "rslib build --watch",
|
||||
"prepublishOnly": "rslib build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rslib/core": "1.0.3",
|
||||
"lit": "3.3.3",
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@rslib/core";
|
||||
|
||||
// Bundleless build: every source file becomes its own ESM file, so consumers'
|
||||
// bundlers only include the entry points they import. Only global.ts files
|
||||
// register elements; everything else is free of side effects.
|
||||
export default defineConfig({
|
||||
source: {
|
||||
entry: { index: ["./src/**/*.ts"] },
|
||||
decorators: { version: "legacy" },
|
||||
tsconfigPath: "./tsconfig.json",
|
||||
},
|
||||
tools: {
|
||||
swc: { jsc: { transform: { decoratorMetadata: false } } },
|
||||
},
|
||||
lib: [
|
||||
{
|
||||
format: "esm",
|
||||
bundle: false,
|
||||
syntax: "es2021",
|
||||
dts: true,
|
||||
output: { target: "web", distPath: { root: "./dist" } },
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineGlobal } from "../../../utils/registration/dependencies";
|
||||
import { HacButtonClose } from "./hac-button-close";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-button-close> and its dependencies in the global registry, as used by
|
||||
// Home Assistant
|
||||
defineGlobal(TAG_NAME, HacButtonClose);
|
||||
|
||||
export { HacButtonClose };
|
||||
@@ -0,0 +1,62 @@
|
||||
import { css, html } from "lit";
|
||||
import { HacButton } from "../hac-button";
|
||||
|
||||
// mdiClose from Material Design Icons (Apache-2.0)
|
||||
const CLOSE_ICON =
|
||||
"M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z";
|
||||
|
||||
/**
|
||||
* Icon button with a close icon.
|
||||
*
|
||||
* The accessible label defaults to "Close". Set `label` to a translated
|
||||
* text when the page is not in English.
|
||||
*
|
||||
* @csspart base - The native button.
|
||||
* @cssprop --hac-button-color - Icon color.
|
||||
* @cssprop --hac-button-height - Width and height.
|
||||
*/
|
||||
export class HacButtonClose extends HacButton {
|
||||
constructor() {
|
||||
super();
|
||||
this.appearance = "plain";
|
||||
}
|
||||
|
||||
protected get accessibleLabel() {
|
||||
return this.label ?? "Close";
|
||||
}
|
||||
|
||||
protected renderContent() {
|
||||
return html`
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d=${CLOSE_ICON}></path>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
HacButton.styles,
|
||||
css`
|
||||
:host([appearance="plain"]) {
|
||||
--_color: var(--hac-button-color, var(--secondary-text-color, #727272));
|
||||
}
|
||||
|
||||
button {
|
||||
width: var(--hac-button-height, 40px);
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: currentColor;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-button-close": HacButtonClose;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-button-close";
|
||||
|
||||
/** Version of <hac-button-close>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ElementDefinition } from "../../../utils/registration/definition";
|
||||
import { scopedVariant } from "../../../utils/registration/dependencies";
|
||||
import { HacButtonClose } from "./hac-button-close";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacButtonClose };
|
||||
|
||||
/**
|
||||
* The bundled <hac-button-close> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacButtonClose])` or
|
||||
* `createScopedElementRegistry([hacButtonClose])`.
|
||||
*/
|
||||
export const hacButtonClose: ElementDefinition<typeof HacButtonClose> = {
|
||||
tagName: TAG_NAME,
|
||||
element: scopedVariant(HacButtonClose),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineGlobal } from "../../utils/registration/dependencies";
|
||||
import { HacButton } from "./hac-button";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-button> and its dependencies in the global registry, as used by
|
||||
// Home Assistant
|
||||
defineGlobal(TAG_NAME, HacButton);
|
||||
|
||||
export { HacButton };
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
/**
|
||||
* Button.
|
||||
*
|
||||
* @slot - Label of the button.
|
||||
* @slot icon - Icon displayed before the label.
|
||||
*
|
||||
* @csspart base - The native button.
|
||||
*
|
||||
* @cssprop --hac-button-color - Text and icon color.
|
||||
* @cssprop --hac-button-background - Background of filled buttons.
|
||||
* @cssprop --hac-button-border-radius - Border radius.
|
||||
* @cssprop --hac-button-height - Height.
|
||||
*/
|
||||
export class HacButton extends LitElement {
|
||||
static shadowRootOptions: ShadowRootInit = {
|
||||
...LitElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
/** Visual style of the button. */
|
||||
@property({ reflect: true }) public appearance:
|
||||
"filled" | "outlined" | "plain" = "filled";
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
/** Accessible label, required for buttons without a visible label. */
|
||||
@property() public label?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<button
|
||||
type="button"
|
||||
part="base"
|
||||
?disabled=${this.disabled}
|
||||
aria-label=${ifDefined(this.accessibleLabel)}
|
||||
>
|
||||
${this.renderContent()}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Accessible label of the native button, overridden by icon buttons. */
|
||||
protected get accessibleLabel(): string | undefined {
|
||||
return this.label;
|
||||
}
|
||||
|
||||
/** Content of the button, overridden by specialized buttons. */
|
||||
protected renderContent(): TemplateResult {
|
||||
return html`<slot name="icon"></slot><slot></slot>`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
--_color: var(--hac-button-color, var(--primary-color, #03a9f4));
|
||||
}
|
||||
|
||||
:host([appearance="filled"]) {
|
||||
--_color: var(--hac-button-color, var(--text-primary-color, #fff));
|
||||
}
|
||||
|
||||
:host([disabled]) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: var(--hac-button-height, 40px);
|
||||
padding: 0 var(--ha-space-4, 16px);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(
|
||||
--hac-button-border-radius,
|
||||
var(--ha-border-radius-pill, 9999px)
|
||||
);
|
||||
background: transparent;
|
||||
color: var(--_color);
|
||||
font: inherit;
|
||||
font-weight: var(--ha-font-weight-medium, 500);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
:host([appearance="filled"]) button {
|
||||
background: var(--hac-button-background, var(--primary-color, #03a9f4));
|
||||
}
|
||||
|
||||
:host([appearance="outlined"]) button {
|
||||
border-color: currentColor;
|
||||
}
|
||||
|
||||
/* State layer for hover and press feedback */
|
||||
button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: currentColor;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-out;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
button:hover::before {
|
||||
opacity: 0.08;
|
||||
}
|
||||
}
|
||||
|
||||
button:active::before {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #03a9f4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
::slotted([slot="icon"]) {
|
||||
display: flex;
|
||||
flex: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-button": HacButton;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-button";
|
||||
|
||||
/** Version of <hac-button>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
import { scopedVariant } from "../../utils/registration/dependencies";
|
||||
import { HacButton } from "./hac-button";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacButton };
|
||||
|
||||
/**
|
||||
* The bundled <hac-button> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacButton])` or
|
||||
* `createScopedElementRegistry([hacButton])`.
|
||||
*/
|
||||
export const hacButton: ElementDefinition<typeof HacButton> = {
|
||||
tagName: TAG_NAME,
|
||||
element: scopedVariant(HacButton),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineGlobal } from "../../utils/registration/dependencies";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-card> and its dependencies in the global registry, as used by
|
||||
// Home Assistant
|
||||
defineGlobal(TAG_NAME, HacCard);
|
||||
|
||||
export { HacCard };
|
||||
@@ -0,0 +1,121 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../../utils/element/slot";
|
||||
|
||||
/**
|
||||
* Container that groups related content.
|
||||
*
|
||||
* @slot header - Header of the card, usually a `hac-header`.
|
||||
* @slot - Content of the card. Its padding is omitted when empty.
|
||||
* @slot footer - Footer of the card, e.g. actions. Hidden when empty.
|
||||
*
|
||||
* @cssprop --hac-card-background - Background of the card.
|
||||
* @cssprop --hac-card-color - Text color of the card.
|
||||
* @cssprop --hac-card-border-color - Border color of the card.
|
||||
* @cssprop --hac-card-border-width - Border width of the card.
|
||||
* @cssprop --hac-card-border-radius - Border radius of the card.
|
||||
* @cssprop --hac-card-box-shadow - Box shadow of the card.
|
||||
* @cssprop --hac-card-content-padding - Padding of the content.
|
||||
* @cssprop --hac-card-footer-padding - Padding of the footer.
|
||||
*/
|
||||
export class HacCard extends LitElement {
|
||||
/** Visual style of the card. */
|
||||
@property({ reflect: true }) public appearance: "outlined" | "raised" =
|
||||
"outlined";
|
||||
|
||||
@state() private _hasHeader = false;
|
||||
|
||||
@state() private _hasFooter = false;
|
||||
|
||||
@state() private _hasContent = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<slot name="header" @slotchange=${this._headerChanged}></slot>
|
||||
<div
|
||||
class="content ${this._hasHeader ? "below-header" : ""}"
|
||||
?hidden=${!this._hasContent}
|
||||
>
|
||||
<slot @slotchange=${this._contentChanged}></slot>
|
||||
</div>
|
||||
<div class="footer" ?hidden=${!this._hasFooter}>
|
||||
<slot name="footer" @slotchange=${this._footerChanged}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _headerChanged(ev: Event) {
|
||||
this._hasHeader = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
private _contentChanged(ev: Event) {
|
||||
this._hasContent = hasAssignedContent(ev);
|
||||
}
|
||||
|
||||
private _footerChanged(ev: Event) {
|
||||
this._hasFooter = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(
|
||||
--hac-card-background,
|
||||
var(--card-background-color, #fff)
|
||||
);
|
||||
color: var(--hac-card-color, var(--primary-text-color, #141414));
|
||||
border: var(--hac-card-border-width, 1px) solid
|
||||
var(--hac-card-border-color, var(--divider-color, #e0e0e0));
|
||||
border-radius: var(
|
||||
--hac-card-border-radius,
|
||||
var(--ha-border-radius-lg, 12px)
|
||||
);
|
||||
box-shadow: var(--hac-card-box-shadow, none);
|
||||
}
|
||||
|
||||
:host([appearance="raised"]) {
|
||||
border-color: transparent;
|
||||
box-shadow: var(
|
||||
--hac-card-box-shadow,
|
||||
0 2px 1px -1px rgba(0, 0, 0, 0.2),
|
||||
0 1px 1px 0 rgba(0, 0, 0, 0.14),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.12)
|
||||
);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: var(--hac-card-content-padding, var(--ha-space-4, 16px));
|
||||
}
|
||||
|
||||
/* The header already provides the spacing above the content */
|
||||
.content.below-header {
|
||||
padding-block-start: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
padding: var(--hac-card-footer-padding, var(--ha-space-2, 8px));
|
||||
border-block-start: 1px solid var(--divider-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.content[hidden],
|
||||
.footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-card": HacCard;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-card";
|
||||
|
||||
/** Version of <hac-card>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
import { scopedVariant } from "../../utils/registration/dependencies";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacCard };
|
||||
|
||||
/**
|
||||
* The bundled <hac-card> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacCard])` or
|
||||
* `createScopedElementRegistry([hacCard])`.
|
||||
*/
|
||||
export const hacCard: ElementDefinition<typeof HacCard> = {
|
||||
tagName: TAG_NAME,
|
||||
element: scopedVariant(HacCard),
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineGlobal } from "../../utils/registration/dependencies";
|
||||
import { HacHeader } from "./hac-header";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-header> and its dependencies in the global registry, as used by
|
||||
// Home Assistant
|
||||
defineGlobal(TAG_NAME, HacHeader);
|
||||
|
||||
export { HacHeader };
|
||||
@@ -0,0 +1,164 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import { hasAssignedElements } from "../../utils/element/slot";
|
||||
import { HacButtonClose } from "../button/close/hac-button-close";
|
||||
import { TAG_NAME as BUTTON_CLOSE_TAG_NAME } from "../button/close/info";
|
||||
|
||||
/**
|
||||
* Header with an optional icon, a title, a subtitle and actions.
|
||||
*
|
||||
* Reusable in any container, e.g. `hac-card`. The title is exposed to
|
||||
* assistive technology as a heading of the given `level`.
|
||||
*
|
||||
* @slot - Title of the header.
|
||||
* @slot icon - Icon displayed before the title.
|
||||
* @slot subtitle - Secondary text displayed below the title.
|
||||
* @slot actions - Actions displayed at the end of the header.
|
||||
*
|
||||
* @fires hac-close - The close button was clicked, see `showClose`.
|
||||
*
|
||||
* @cssprop --hac-header-color - Title color.
|
||||
* @cssprop --hac-header-secondary-color - Color of the icon and subtitle.
|
||||
* @cssprop --hac-header-font-family - Title font family.
|
||||
* @cssprop --hac-header-font-size - Title font size.
|
||||
* @cssprop --hac-header-padding - Padding of the header.
|
||||
*/
|
||||
export class HacHeader extends LitElement {
|
||||
static readonly dependencies = [
|
||||
{ tagName: BUTTON_CLOSE_TAG_NAME, element: HacButtonClose },
|
||||
];
|
||||
|
||||
/** Heading level of the title for assistive technology. */
|
||||
@property({ type: Number }) public level: 1 | 2 | 3 | 4 | 5 | 6 = 2;
|
||||
|
||||
/** Shows a close button at the end, which fires `hac-close`. */
|
||||
@property({ type: Boolean, reflect: true, attribute: "show-close" })
|
||||
public showClose = false;
|
||||
|
||||
/** Accessible label of the close button, "Close" by default. */
|
||||
@property({ attribute: "close-label" }) public closeLabel?: string;
|
||||
|
||||
@state() private _hasIcon = false;
|
||||
|
||||
@state() private _hasSubtitle = false;
|
||||
|
||||
@state() private _hasActions = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="icon" ?hidden=${!this._hasIcon}>
|
||||
<slot name="icon" @slotchange=${this._iconChanged}></slot>
|
||||
</div>
|
||||
<div class="text">
|
||||
<div class="title" role="heading" aria-level=${this.level}>
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="subtitle" ?hidden=${!this._hasSubtitle}>
|
||||
<slot name="subtitle" @slotchange=${this._subtitleChanged}></slot>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" ?hidden=${!this._hasActions}>
|
||||
<slot name="actions" @slotchange=${this._actionsChanged}></slot>
|
||||
</div>
|
||||
${
|
||||
this.showClose
|
||||
? html`<hac-button-close
|
||||
.label=${this.closeLabel}
|
||||
@click=${this._close}
|
||||
></hac-button-close>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
private _close() {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("hac-close", { bubbles: true, composed: true })
|
||||
);
|
||||
}
|
||||
|
||||
private _iconChanged(ev: Event) {
|
||||
this._hasIcon = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
private _subtitleChanged(ev: Event) {
|
||||
this._hasSubtitle = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
private _actionsChanged(ev: Event) {
|
||||
this._hasActions = hasAssignedElements(ev);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3, 12px);
|
||||
box-sizing: border-box;
|
||||
padding: var(
|
||||
--hac-header-padding,
|
||||
var(--ha-space-4, 16px) var(--ha-space-4, 16px) var(--ha-space-3, 12px)
|
||||
);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
flex: none;
|
||||
color: var(
|
||||
--hac-header-secondary-color,
|
||||
var(--secondary-text-color, #727272)
|
||||
);
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--hac-header-color, var(--primary-text-color, #141414));
|
||||
font-family: var(--hac-header-font-family, inherit);
|
||||
font-size: var(--hac-header-font-size, var(--ha-font-size-2xl, 24px));
|
||||
font-weight: var(--ha-font-weight-normal, 400);
|
||||
line-height: var(--ha-line-height-condensed, 1.2);
|
||||
letter-spacing: -0.012em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-block-start: var(--ha-space-1, 4px);
|
||||
color: var(
|
||||
--hac-header-secondary-color,
|
||||
var(--secondary-text-color, #727272)
|
||||
);
|
||||
font-size: var(--ha-font-size-m, 14px);
|
||||
line-height: var(--ha-line-height-normal, 1.6);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2, 8px);
|
||||
}
|
||||
|
||||
hac-button-close {
|
||||
flex: none;
|
||||
margin-inline-end: calc(var(--ha-space-2, 8px) * -1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"hac-header": HacHeader;
|
||||
}
|
||||
|
||||
interface HTMLElementEventMap {
|
||||
"hac-close": CustomEvent<undefined>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Tag name of the element. */
|
||||
export const TAG_NAME = "hac-header";
|
||||
|
||||
/** Version of <hac-header>. */
|
||||
export const VERSION = "0.1.0";
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
import { scopedVariant } from "../../utils/registration/dependencies";
|
||||
import { HacHeader } from "./hac-header";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
export { HacHeader };
|
||||
|
||||
/**
|
||||
* The bundled <hac-header> for a scoped registry:
|
||||
* `ScopedElementsMixin(LitElement, [hacHeader])` or
|
||||
* `createScopedElementRegistry([hacHeader])`.
|
||||
*/
|
||||
export const hacHeader: ElementDefinition<typeof HacHeader> = {
|
||||
tagName: TAG_NAME,
|
||||
element: scopedVariant(HacHeader),
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { LitElement } from "lit";
|
||||
import { define } from "./utils/registration/define";
|
||||
import type { ElementDefinition } from "./utils/registration/definition";
|
||||
import {
|
||||
createScopedRegistry,
|
||||
scopedCreationScope,
|
||||
scopedRegistrySupport,
|
||||
scopedShadowRootInit,
|
||||
} from "./utils/registration/scoped-registry";
|
||||
|
||||
export type { ElementDefinition } from "./utils/registration/definition";
|
||||
|
||||
/**
|
||||
* Creates a registry with the given elements, for `attachScopedShadow`:
|
||||
* definitions from `scoped` entry points, e.g. `[hacCard]`.
|
||||
* Create it once, outside the class, so all instances of a card share it.
|
||||
*
|
||||
* Without scoped registry support (native or polyfill), the elements are
|
||||
* registered in the global registry instead, unless their tag name is already
|
||||
* taken there, and the global registry is returned.
|
||||
*/
|
||||
export const createScopedElementRegistry = (
|
||||
elements: readonly ElementDefinition[]
|
||||
): CustomElementRegistry => {
|
||||
// Checked as unknown: Array.isArray would narrow the list to any[]
|
||||
if (!Array.isArray(elements as unknown)) {
|
||||
throw new TypeError(
|
||||
"@home-assistant/components: pass the elements to register, e.g. [hacCard]"
|
||||
);
|
||||
}
|
||||
const registry = createScopedRegistry() ?? customElements;
|
||||
for (const { tagName, element } of elements) {
|
||||
define(tagName, element, registry);
|
||||
}
|
||||
return registry;
|
||||
};
|
||||
|
||||
const shadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
registry === customElements ? init : scopedShadowRootInit(init, registry);
|
||||
|
||||
const registries = new WeakMap<ShadowRoot, CustomElementRegistry>();
|
||||
|
||||
/**
|
||||
* Attaches a shadow root that uses `registry` for custom elements in it.
|
||||
*
|
||||
* Content added with `innerHTML` or `insertAdjacentHTML` uses the registry.
|
||||
* Create single elements with `createScopedElement`; `document.createElement`
|
||||
* would use the global registry.
|
||||
*
|
||||
* Light DOM content, like slotted elements, keeps using the registry of the
|
||||
* tree it is written in.
|
||||
*/
|
||||
export const attachScopedShadow = (
|
||||
host: HTMLElement,
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRoot => {
|
||||
const root = host.attachShadow(shadowRootInit(init, registry));
|
||||
registries.set(root, registry);
|
||||
return root;
|
||||
};
|
||||
|
||||
/** Creates an element from the registry of a shadow root. */
|
||||
export const createScopedElement = <K extends keyof HTMLElementTagNameMap>(
|
||||
root: ShadowRoot,
|
||||
tagName: K
|
||||
): HTMLElementTagNameMap[K] => {
|
||||
const registry = registries.get(root);
|
||||
if (!registry || registry === customElements) {
|
||||
return document.createElement(tagName);
|
||||
}
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds createElement to shadow roots
|
||||
return (
|
||||
root as unknown as {
|
||||
createElement(tagName: K): HTMLElementTagNameMap[K];
|
||||
}
|
||||
).createElement(tagName);
|
||||
}
|
||||
return (
|
||||
document.createElement as (
|
||||
tagName: K,
|
||||
options: { customElementRegistry: CustomElementRegistry }
|
||||
) => HTMLElementTagNameMap[K]
|
||||
)(tagName, { customElementRegistry: registry });
|
||||
};
|
||||
|
||||
type LitElementConstructor = abstract new (...args: any[]) => LitElement;
|
||||
|
||||
/**
|
||||
* Renders a Lit element into a shadow root with its own custom element
|
||||
* registry, containing `elements`:
|
||||
*
|
||||
* ```ts
|
||||
* class MyCard extends ScopedElementsMixin(LitElement, [hacCard]) {}
|
||||
* ```
|
||||
*
|
||||
* See `createScopedElementRegistry` and `attachScopedShadow` for the details.
|
||||
*/
|
||||
export const ScopedElementsMixin = <T extends LitElementConstructor>(
|
||||
superClass: T,
|
||||
elements: readonly ElementDefinition[]
|
||||
) => {
|
||||
// Created on first use, so defining the class has no side effects
|
||||
let registry: CustomElementRegistry | undefined;
|
||||
const getRegistry = () => {
|
||||
registry ??= createScopedElementRegistry(elements);
|
||||
return registry;
|
||||
};
|
||||
|
||||
abstract class ScopedElementsHost extends superClass {
|
||||
public attachShadow(init: ShadowRootInit): ShadowRoot {
|
||||
const root = super.attachShadow(shadowRootInit(init, getRegistry()));
|
||||
registries.set(root, getRegistry());
|
||||
return root;
|
||||
}
|
||||
|
||||
protected createRenderRoot() {
|
||||
const root = super.createRenderRoot();
|
||||
const scoped = getRegistry();
|
||||
if (scoped !== customElements && root instanceof ShadowRoot) {
|
||||
this.renderOptions.creationScope = scopedCreationScope(root, scoped);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
}
|
||||
// Typed as the base class: the overrides add no public API
|
||||
return ScopedElementsHost as unknown as T;
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Whether the slot of a `slotchange` event has elements assigned. */
|
||||
export const hasAssignedElements = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
|
||||
/**
|
||||
* Whether the slot of a `slotchange` event has content assigned: elements or
|
||||
* text. Whitespace between tags is not content.
|
||||
*/
|
||||
export const hasAssignedContent = (ev: Event): boolean =>
|
||||
(ev.target as HTMLSlotElement)
|
||||
.assignedNodes()
|
||||
.some(
|
||||
(node) =>
|
||||
node.nodeType === Node.ELEMENT_NODE || Boolean(node.textContent?.trim())
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Registers a custom element unless the tag name is already taken in the
|
||||
* registry.
|
||||
*
|
||||
* Home Assistant and custom cards can each ship a copy of this library, so
|
||||
* registering the same tag name twice must not throw. The first registration
|
||||
* wins.
|
||||
*/
|
||||
export const define = (
|
||||
tagName: string,
|
||||
elementClass: CustomElementConstructor,
|
||||
registry: CustomElementRegistry = customElements
|
||||
): void => {
|
||||
if (!registry.get(tagName)) {
|
||||
registry.define(tagName, elementClass);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LitElement } from "lit";
|
||||
|
||||
/**
|
||||
* An element to register: its tag name and its class. The `scoped` entry
|
||||
* point of an element exports one, e.g. `hacCard`.
|
||||
*/
|
||||
export interface ElementDefinition<
|
||||
E extends CustomElementConstructor = CustomElementConstructor,
|
||||
> {
|
||||
readonly tagName: string;
|
||||
readonly element: E;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hac component class. Components list the elements they render in their
|
||||
* shadow root in `dependencies`, without registering them. The entry point a
|
||||
* component is loaded through decides where those are registered:
|
||||
*
|
||||
* - global: in the global registry, like the component itself
|
||||
* - scoped: in a scoped registry of the component's shadow root, so its
|
||||
* internals always use the bundled, same-version dependencies
|
||||
*/
|
||||
export type ComponentClass = (new () => LitElement) & {
|
||||
readonly dependencies?: readonly ElementDefinition<ComponentClass>[];
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ScopedElementsMixin } from "../../scoped";
|
||||
import { define } from "./define";
|
||||
import type { ComponentClass } from "./definition";
|
||||
|
||||
/** Registers a component and its dependencies in the global registry. */
|
||||
export const defineGlobal = (
|
||||
tagName: string,
|
||||
elementClass: ComponentClass
|
||||
): void => {
|
||||
for (const dependency of elementClass.dependencies ?? []) {
|
||||
defineGlobal(dependency.tagName, dependency.element);
|
||||
}
|
||||
define(tagName, elementClass);
|
||||
};
|
||||
|
||||
const variants = new WeakMap<ComponentClass, ComponentClass>();
|
||||
|
||||
/**
|
||||
* The class to register in a scoped registry: the component itself if it has
|
||||
* no dependencies, otherwise a subclass whose shadow root has its own
|
||||
* registry with scoped variants of its dependencies. Created once per class.
|
||||
*/
|
||||
export const scopedVariant = <T extends ComponentClass>(elementClass: T): T => {
|
||||
const dependencies = elementClass.dependencies ?? [];
|
||||
if (dependencies.length === 0) {
|
||||
return elementClass;
|
||||
}
|
||||
let variant = variants.get(elementClass);
|
||||
if (!variant) {
|
||||
// The mixin returns a new subclass, the variant
|
||||
variant = ScopedElementsMixin(
|
||||
elementClass,
|
||||
dependencies.map(({ tagName, element }) => ({
|
||||
tagName,
|
||||
element: scopedVariant(element),
|
||||
}))
|
||||
);
|
||||
variants.set(elementClass, variant);
|
||||
}
|
||||
return variant as T;
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Scoped custom element registries come in two flavors: the standard API
|
||||
* (Chrome 146+, Safari 26) and the older API of
|
||||
* `@webcomponents/scoped-custom-element-registry`, which Home Assistant loads
|
||||
* and which replaces the native implementation when present.
|
||||
*/
|
||||
export type ScopedRegistrySupport = "polyfill" | "native" | "none";
|
||||
|
||||
let support: ScopedRegistrySupport | undefined;
|
||||
|
||||
export const scopedRegistrySupport = (): ScopedRegistrySupport => {
|
||||
if (support) {
|
||||
return support;
|
||||
}
|
||||
if ("CustomElementRegistryPolyfill" in globalThis) {
|
||||
support = "polyfill";
|
||||
} else {
|
||||
try {
|
||||
new CustomElementRegistry();
|
||||
support = "native";
|
||||
} catch (_err) {
|
||||
support = "none";
|
||||
}
|
||||
}
|
||||
return support;
|
||||
};
|
||||
|
||||
/** A new scoped registry, or `undefined` if the browser has no support. */
|
||||
export const createScopedRegistry = (): CustomElementRegistry | undefined =>
|
||||
scopedRegistrySupport() === "none" ? undefined : new CustomElementRegistry();
|
||||
|
||||
/** Shadow root options that make the shadow root use `registry`. */
|
||||
export const scopedShadowRootInit = (
|
||||
init: ShadowRootInit,
|
||||
registry: CustomElementRegistry
|
||||
): ShadowRootInit =>
|
||||
scopedRegistrySupport() === "polyfill"
|
||||
? ({ ...init, registry } as ShadowRootInit)
|
||||
: ({ ...init, customElementRegistry: registry } as ShadowRootInit);
|
||||
|
||||
interface CreationScope {
|
||||
importNode(node: Node, deep?: boolean): Node;
|
||||
}
|
||||
|
||||
/**
|
||||
* The object Lit should create template content with, so custom elements in
|
||||
* it are created from the scoped registry (Lit's `creationScope`).
|
||||
*/
|
||||
export const scopedCreationScope = (
|
||||
root: ShadowRoot,
|
||||
registry: CustomElementRegistry
|
||||
): CreationScope => {
|
||||
if (scopedRegistrySupport() === "polyfill") {
|
||||
// The polyfill adds importNode to shadow roots
|
||||
return root as unknown as CreationScope;
|
||||
}
|
||||
// Standard API: the registry is passed to importNode
|
||||
return {
|
||||
importNode: (node, deep) =>
|
||||
(
|
||||
document.importNode as (
|
||||
node: Node,
|
||||
options: { selfOnly: boolean; customElementRegistry: unknown }
|
||||
) => Node
|
||||
)(node, { selfOnly: !deep, customElementRegistry: registry }),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { define } from "../src/utils/registration/define";
|
||||
|
||||
// Home Assistant and custom cards can each ship a copy of the library, so a
|
||||
// second registration of the same tag must be ignored instead of throwing.
|
||||
describe("define", () => {
|
||||
it("registers an element that is not registered yet", () => {
|
||||
class First extends HTMLElement {}
|
||||
define("hac-test-first", First);
|
||||
expect(customElements.get("hac-test-first")).toBe(First);
|
||||
});
|
||||
|
||||
it("keeps the first registration of a tag name", () => {
|
||||
class Original extends HTMLElement {}
|
||||
class Duplicate extends HTMLElement {}
|
||||
define("hac-test-duplicate", Original);
|
||||
expect(() => define("hac-test-duplicate", Duplicate)).not.toThrow();
|
||||
expect(customElements.get("hac-test-duplicate")).toBe(Original);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { LitElement } from "lit";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
defineGlobal,
|
||||
scopedVariant,
|
||||
} from "../src/utils/registration/dependencies";
|
||||
|
||||
// Entry points decide where a component's dependencies are registered. These
|
||||
// cases guard that global mode registers them globally and scoped mode never
|
||||
// shares classes with the global registry.
|
||||
class Leaf extends LitElement {}
|
||||
class Parent extends LitElement {
|
||||
static readonly dependencies = [{ tagName: "hac-test-leaf", element: Leaf }];
|
||||
}
|
||||
|
||||
describe("defineGlobal", () => {
|
||||
it("registers the dependencies with the component", () => {
|
||||
defineGlobal("hac-test-parent", Parent);
|
||||
expect(customElements.get("hac-test-parent")).toBe(Parent);
|
||||
expect(customElements.get("hac-test-leaf")).toBe(Leaf);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scopedVariant", () => {
|
||||
it("returns components without dependencies unchanged", () => {
|
||||
expect(scopedVariant(Leaf)).toBe(Leaf);
|
||||
});
|
||||
|
||||
it("returns one subclass per component with dependencies", () => {
|
||||
const variant = scopedVariant(Parent);
|
||||
expect(variant).not.toBe(Parent);
|
||||
expect(variant.prototype).toBeInstanceOf(Parent);
|
||||
expect(scopedVariant(Parent)).toBe(variant);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../src/utils/element/slot";
|
||||
|
||||
// Components hide wrappers of empty slots, e.g. the padding of an empty card.
|
||||
// Whitespace between tags must not count as content.
|
||||
const slotEvent = (light: string, name?: string) => {
|
||||
const host = document.createElement("div");
|
||||
host.innerHTML = light;
|
||||
const slot = document.createElement("slot");
|
||||
if (name) {
|
||||
slot.name = name;
|
||||
}
|
||||
host.attachShadow({ mode: "open" }).append(slot);
|
||||
return { target: slot } as unknown as Event;
|
||||
};
|
||||
|
||||
describe("hasAssignedContent", () => {
|
||||
it("ignores whitespace", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n \n"))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts text", () => {
|
||||
expect(hasAssignedContent(slotEvent(" Hello "))).toBe(true);
|
||||
});
|
||||
|
||||
it("counts elements", () => {
|
||||
expect(hasAssignedContent(slotEvent("\n <p></p>\n"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasAssignedElements", () => {
|
||||
it("only counts elements assigned to the slot", () => {
|
||||
const light = '<span slot="icon"></span> text';
|
||||
expect(hasAssignedElements(slotEvent(light, "icon"))).toBe(true);
|
||||
expect(hasAssignedElements(slotEvent(light, "actions"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "."
|
||||
- "packages/*"
|
||||
|
||||
savePrefix: ""
|
||||
autoInstallPeers: false
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,6 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 408 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,5 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 330 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,4 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 252 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -1,3 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 174 B |
@@ -98,11 +98,6 @@
|
||||
"groupName": "date-fns",
|
||||
"matchPackageNames": ["date-fns", "date-fns-tz"]
|
||||
},
|
||||
{
|
||||
"description": "Group ECharts with zrender, which we import directly and must match the version echarts depends on",
|
||||
"groupName": "echarts",
|
||||
"matchPackageNames": ["echarts", "zrender"]
|
||||
},
|
||||
{
|
||||
"description": "Group formatjs monorepo package",
|
||||
"groupName": "formatjs",
|
||||
|
||||
@@ -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,51 +0,0 @@
|
||||
/**
|
||||
* Measures the volume of an audio track with the Web Audio API.
|
||||
*
|
||||
* Calls `onLevel` with a level between 0 and 1, rounded to steps of 0.05 and
|
||||
* only when it changed, so silence does not cause work every frame.
|
||||
* Returns a function that stops measuring and releases the audio context.
|
||||
*/
|
||||
export const watchAudioLevel = (
|
||||
track: MediaStreamTrack,
|
||||
onLevel: (level: number) => void
|
||||
): (() => void) => {
|
||||
const context = new AudioContext();
|
||||
// Browsers can start an audio context suspended when it is not created in a
|
||||
// click handler, like here. Resume without waiting: until it runs the level
|
||||
// is 0, and resume() can stay pending in browsers that need a new click.
|
||||
context.resume().catch(() => undefined);
|
||||
const analyser = context.createAnalyser();
|
||||
analyser.fftSize = 512;
|
||||
context.createMediaStreamSource(new MediaStream([track])).connect(analyser);
|
||||
|
||||
const samples = new Float32Array(analyser.fftSize);
|
||||
let level = 0;
|
||||
let reported = -1;
|
||||
let frame = 0;
|
||||
|
||||
const tick = () => {
|
||||
analyser.getFloatTimeDomainData(samples);
|
||||
let sum = 0;
|
||||
for (const sample of samples) {
|
||||
sum += sample * sample;
|
||||
}
|
||||
// Root mean square is the loudness, in decibels it matches what we hear
|
||||
const decibels = 20 * Math.log10(Math.sqrt(sum / samples.length));
|
||||
// Map silence (-60 dB) to 0 and loud speech (-10 dB) to 1
|
||||
const current = Math.min(1, Math.max(0, (decibels + 60) / 50));
|
||||
// Rise immediately but fall slowly (about a second), so it fades out
|
||||
level = Math.max(current, level * 0.95);
|
||||
const rounded = Math.round(level * 20) / 20;
|
||||
if (rounded !== reported) {
|
||||
reported = rounded;
|
||||
onLevel(rounded);
|
||||
}
|
||||
frame = requestAnimationFrame(tick);
|
||||
};
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
context.close();
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,8 @@ import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type { HASSDomEvent } from "../dom/fire_event";
|
||||
import { mainWindow } from "../dom/get_main_window";
|
||||
import { replaceCurrentUrl, type NavigateOptions } from "../navigate";
|
||||
import { replaceCurrentUrl } from "../navigate";
|
||||
import { constructUrlCurrentPath } from "../url/construct-url";
|
||||
import { currentPath } from "../url/current-path";
|
||||
import {
|
||||
@@ -30,8 +29,8 @@ export interface QueryParamsControllerOptions {
|
||||
* Keeps part of a page's state in the query string of the URL, so it can be
|
||||
* shared, bookmarked and reloaded. Writes replace the current history entry,
|
||||
* so Back leaves the page instead of undoing each change, and only touch the
|
||||
* config's params. The controller only acts while the host is connected and
|
||||
* the URL is still the page it was first connected on.
|
||||
* config's params. The controller only acts while the URL is still the
|
||||
* page the host was connected on.
|
||||
*/
|
||||
export class QueryParamsController<
|
||||
C extends QueryParamConfig,
|
||||
@@ -44,8 +43,6 @@ export class QueryParamsController<
|
||||
|
||||
private _hostUpdated = false;
|
||||
|
||||
private _connected = false;
|
||||
|
||||
constructor(
|
||||
private _host: ReactiveControllerHost,
|
||||
private _config: C,
|
||||
@@ -55,10 +52,7 @@ export class QueryParamsController<
|
||||
}
|
||||
|
||||
public hostConnected() {
|
||||
// A cached page is reconnected on the way to another page, before its
|
||||
// router has moved on, so it keeps the page it was first connected on
|
||||
this._path ??= currentPath();
|
||||
this._connected = true;
|
||||
this._path = currentPath();
|
||||
mainWindow.addEventListener("location-changed", this._navigated);
|
||||
mainWindow.addEventListener("popstate", this._navigated);
|
||||
|
||||
@@ -74,9 +68,6 @@ export class QueryParamsController<
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
// A page that was left can still finish a lookup, while a new page on the
|
||||
// same path is showing
|
||||
this._connected = false;
|
||||
mainWindow.removeEventListener("location-changed", this._navigated);
|
||||
mainWindow.removeEventListener("popstate", this._navigated);
|
||||
this._cancelPending();
|
||||
@@ -126,25 +117,11 @@ export class QueryParamsController<
|
||||
}
|
||||
|
||||
private _isActive(): boolean {
|
||||
return (
|
||||
this._connected && this._options.enabled() && currentPath() === this._path
|
||||
);
|
||||
return this._options.enabled() && currentPath() === this._path;
|
||||
}
|
||||
|
||||
private _navigated = (ev?: Event | HASSDomEvent<NavigateOptions>) => {
|
||||
private _navigated = () => {
|
||||
this._cancelPending();
|
||||
// A router sends a path without a page, like /config/automation, on to
|
||||
// its default page after the host is connected, so it follows that
|
||||
const path = currentPath();
|
||||
|
||||
if (
|
||||
ev &&
|
||||
"detail" in ev &&
|
||||
ev.detail?.replace &&
|
||||
path.startsWith(`${this._path}/`)
|
||||
) {
|
||||
this._path = path;
|
||||
}
|
||||
// Leaving the page gets here too, before the router swaps the page
|
||||
if (this._isActive()) {
|
||||
this._options.navigated();
|
||||
|
||||
@@ -45,168 +45,69 @@ export const formatNumericDuration = (
|
||||
return null;
|
||||
};
|
||||
|
||||
const DURATION_FIELDS = [
|
||||
["days", "day"],
|
||||
["hours", "hour"],
|
||||
["minutes", "minute"],
|
||||
["seconds", "second"],
|
||||
["milliseconds", "millisecond"],
|
||||
] as const satisfies readonly (readonly [keyof HaDurationData, string])[];
|
||||
|
||||
type DurationFormatOptions = {
|
||||
style: "long" | "narrow" | "digital";
|
||||
} & Partial<Record<`${keyof HaDurationData}Display`, "always" | "auto">>;
|
||||
|
||||
type DurationFormatter = (duration: HaDurationData) => string;
|
||||
|
||||
// Throws like Intl.DurationFormat on fractions, NaN and mixed signs
|
||||
const isNegativeDuration = (duration: HaDurationData) => {
|
||||
const values = DURATION_FIELDS.map(([field]) => Number(duration[field] ?? 0));
|
||||
const negative = values.some((value) => value < 0);
|
||||
if (
|
||||
!values.every((value) => Number.isInteger(value)) ||
|
||||
(negative && values.some((value) => value > 0))
|
||||
) {
|
||||
throw new RangeError("Invalid duration");
|
||||
}
|
||||
return negative;
|
||||
};
|
||||
|
||||
const fallbackUnitsFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const style = options.style === "narrow" ? "narrow" : "long";
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style });
|
||||
const unitFormats = DURATION_FIELDS.map(
|
||||
([, unit]) =>
|
||||
new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit,
|
||||
unitDisplay: style,
|
||||
})
|
||||
);
|
||||
return (duration) => {
|
||||
const negative = isNegativeDuration(duration);
|
||||
const parts: string[] = [];
|
||||
DURATION_FIELDS.forEach(([field], index) => {
|
||||
const value = Math.abs(duration[field] ?? 0);
|
||||
if (value || options[`${field}Display`] === "always") {
|
||||
// Like Intl.DurationFormat, only the first unit shows the sign
|
||||
parts.push(
|
||||
unitFormats[index].format(negative && !parts.length ? -value : value)
|
||||
);
|
||||
}
|
||||
});
|
||||
return list.format(parts);
|
||||
};
|
||||
};
|
||||
|
||||
const fallbackDigitalFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
const list = new Intl.ListFormat(language, { type: "unit", style: "short" });
|
||||
const dayFormat = new Intl.NumberFormat(language, {
|
||||
style: "unit",
|
||||
unit: "day",
|
||||
unitDisplay: "short",
|
||||
});
|
||||
const hourFormat = new Intl.NumberFormat(language, { useGrouping: false });
|
||||
const twoDigitFormat = new Intl.NumberFormat(language, {
|
||||
minimumIntegerDigits: 2,
|
||||
maximumFractionDigits: 3,
|
||||
useGrouping: false,
|
||||
});
|
||||
return (duration) => {
|
||||
const sign = isNegativeDuration(duration) ? "-" : "";
|
||||
const days = Math.abs(duration.days ?? 0);
|
||||
const hours = Math.abs(duration.hours ?? 0);
|
||||
const minutes = Math.abs(duration.minutes ?? 0);
|
||||
const seconds =
|
||||
Math.abs(duration.seconds ?? 0) +
|
||||
Math.abs(duration.milliseconds ?? 0) / 1000;
|
||||
let time = `${twoDigitFormat.format(minutes)}:${twoDigitFormat.format(seconds)}`;
|
||||
if (hours || options.hoursDisplay !== "auto") {
|
||||
time = `${hourFormat.format(hours)}:${time}`;
|
||||
}
|
||||
return days
|
||||
? list.format([sign + dayFormat.format(days), time])
|
||||
: sign + time;
|
||||
};
|
||||
};
|
||||
|
||||
// The Intl.DurationFormat polyfill needs BigInt, which some legacy browsers lack
|
||||
const createDurationFormat = (
|
||||
language: string,
|
||||
options: DurationFormatOptions
|
||||
): DurationFormatter => {
|
||||
if ("DurationFormat" in Intl) {
|
||||
const durationFormat = new Intl.DurationFormat(language, options);
|
||||
return (duration) => durationFormat.format(duration);
|
||||
}
|
||||
return options.style === "digital"
|
||||
? fallbackDigitalFormat(language, options)
|
||||
: fallbackUnitsFormat(language, options);
|
||||
};
|
||||
|
||||
const formatDurationLongMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
const formatDurationLongMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "long",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationLong = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationLongMem(locale)(duration);
|
||||
) => formatDurationLongMem(locale).format(duration);
|
||||
|
||||
const formatDurationNarrowMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
const formatDurationNarrowMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationNarrow = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDurationNarrowMem(locale)(duration);
|
||||
) => formatDurationNarrowMem(locale).format(duration);
|
||||
|
||||
const formatDigitalDurationMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
const formatDigitalDurationMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "digital",
|
||||
hoursDisplay: "auto",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDurationDigital = (
|
||||
locale: FrontendLocaleData,
|
||||
duration: HaDurationData
|
||||
) => formatDigitalDurationMem(locale)(duration);
|
||||
) => formatDigitalDurationMem(locale).format(duration);
|
||||
|
||||
export const DURATION_UNITS = ["min", "h", "d"] as const;
|
||||
|
||||
type DurationUnit = (typeof DURATION_UNITS)[number];
|
||||
|
||||
const formatDurationDayMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
const formatDurationDayMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
daysDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationHourMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
const formatDurationHourMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
hoursDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
const formatDurationMinuteMem = memoizeOne((locale: FrontendLocaleData) =>
|
||||
createDurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
const formatDurationMinuteMem = memoizeOne(
|
||||
(locale: FrontendLocaleData) =>
|
||||
new Intl.DurationFormat(locale.language, {
|
||||
style: "narrow",
|
||||
minutesDisplay: "always",
|
||||
})
|
||||
);
|
||||
|
||||
export const formatDuration = (
|
||||
@@ -228,7 +129,7 @@ export const formatDuration = (
|
||||
days,
|
||||
hours,
|
||||
};
|
||||
return formatDurationDayMem(locale)(input);
|
||||
return formatDurationDayMem(locale).format(input);
|
||||
}
|
||||
case "h": {
|
||||
const hours = Math.floor(value);
|
||||
@@ -237,7 +138,7 @@ export const formatDuration = (
|
||||
hours,
|
||||
minutes,
|
||||
};
|
||||
return formatDurationHourMem(locale)(input);
|
||||
return formatDurationHourMem(locale).format(input);
|
||||
}
|
||||
case "min": {
|
||||
const minutes = Math.floor(value);
|
||||
@@ -246,7 +147,7 @@ export const formatDuration = (
|
||||
minutes,
|
||||
seconds,
|
||||
};
|
||||
return formatDurationMinuteMem(locale)(input);
|
||||
return formatDurationMinuteMem(locale).format(input);
|
||||
}
|
||||
default:
|
||||
throw new Error("Invalid duration unit");
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { timeZonesNames } from "@vvo/tzdb";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
|
||||
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
|
||||
// instead of an IANA zone name. Only accept values that are known IANA zones,
|
||||
// matching the list used by ha-timezone-picker.
|
||||
const isKnownTimeZone = (zone: string | undefined): zone is string =>
|
||||
!!zone &&
|
||||
(zone === "UTC" || zone === "Etc/UTC" || timeZonesNames.includes(zone));
|
||||
|
||||
// The FormatJS polyfill reports the canonical zone (Europe/Amsterdam becomes
|
||||
// Europe/Brussels), so the name read before it loaded comes first. Its own
|
||||
// name still covers old aliases the list lacks, like Asia/Calcutta.
|
||||
const RESOLVED_TIME_ZONE = [
|
||||
globalThis.__haNativeTimeZone,
|
||||
Intl.DateTimeFormat?.().resolvedOptions?.().timeZone,
|
||||
].find(isKnownTimeZone);
|
||||
const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
timeZonesNames.includes(RESOLVED_RAW))
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -2,23 +2,8 @@
|
||||
const URL_ATTRIBUTES = [
|
||||
["a", "href"],
|
||||
["img", "src"],
|
||||
["source", "src"],
|
||||
] as const;
|
||||
|
||||
// The markdown worker drops a srcset unless every part between commas and
|
||||
// spaces is a safe URL or a descriptor, so a plain split is enough: the srcset
|
||||
// is written back from the rewritten URLs.
|
||||
const splitSrcsetCandidates = (srcset: string) =>
|
||||
srcset
|
||||
.split(",")
|
||||
.map((candidate) => candidate.trim().split(/\s+/))
|
||||
.filter(([url]) => url);
|
||||
|
||||
const rewriteSrcset = (srcset: string, rewrite: (url: string) => string) =>
|
||||
splitSrcsetCandidates(srcset)
|
||||
.map(([url, ...descriptors]) => [rewrite(url), ...descriptors].join(" "))
|
||||
.join(", ");
|
||||
|
||||
/**
|
||||
* Rewrite the address of every link and image in a piece of HTML.
|
||||
*
|
||||
@@ -44,13 +29,5 @@ export const rewriteHtmlUrls = (
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite each address in a <picture> source like an image
|
||||
for (const element of template.content.querySelectorAll("source[srcset]")) {
|
||||
element.setAttribute(
|
||||
"srcset",
|
||||
rewriteSrcset(element.getAttribute("srcset")!, rewrite)
|
||||
);
|
||||
}
|
||||
|
||||
return template.innerHTML;
|
||||
};
|
||||
@@ -70,10 +70,13 @@ export const supportsWebGL2 = (): boolean => {
|
||||
|
||||
// The one gate for MapLibre, whether through the Leaflet adapter or the
|
||||
// engine `ha-map` drives directly. Its worker is bundled per build target
|
||||
// like the rest of it, so wherever the page runs, the worker runs too: the
|
||||
// legacy floor (Chrome 67 / Safari 15) guarantees the BigInt literals MapLibre
|
||||
// uses, which Babel cannot transpile.
|
||||
export const supportsVectorMaps = (): boolean => supportsWebGL2();
|
||||
// like the rest of it, so wherever the page runs, the worker runs too - with
|
||||
// one exception. Babel transpiles everything MapLibre is written in except
|
||||
// BigInt literals, which have no ES2017 spelling. A browser without BigInt
|
||||
// (Chromium below 67, such as Fire OS 5 tablets) cannot parse the chunk, and
|
||||
// a worker that cannot parse never reports back, hence the check up front.
|
||||
export const supportsVectorMaps = (): boolean =>
|
||||
supportsWebGL2() && typeof BigInt === "function";
|
||||
|
||||
// The demo has no core to proxy through. OSM sets CORS on its tiles but not on
|
||||
// its glyphs, which is why those come from VersaTiles.
|
||||
|
||||
@@ -14,6 +14,7 @@ export type LocalizeKeys =
|
||||
| `ui.card.weather.attributes.${string}`
|
||||
| `ui.card.weather.cardinal_direction.${string}`
|
||||
| `ui.card.lawn_mower.actions.${string}`
|
||||
| `ui.common.${string}`
|
||||
| `ui.components.calendar.event.rrule.${string}`
|
||||
| `ui.components.selectors.file.${string}`
|
||||
| `ui.dialogs.entity_registry.editor.${string}`
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} 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 automations table. Search, sorting and grouping are kept
|
||||
// by the data table, see data-table-query-params.
|
||||
export const automationsFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
"blueprint",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type AutomationsFilterQueryParams = QueryParamValues<
|
||||
typeof automationsFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
Array.isArray(filter?.value) ? filter.value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const automationsFilterQueryParams = (
|
||||
filters: DataTableFilters
|
||||
): AutomationsFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
entity: list(filters["ha-filter-entities"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
blueprint: list(filters["ha-filter-blueprints"]),
|
||||
});
|
||||
|
||||
// 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 automationsFilterQueryString = (
|
||||
filters: DataTableFilters
|
||||
): string => {
|
||||
const params = automationsFilterQueryParams(filters);
|
||||
const sorted: AutomationsFilterQueryParams = {};
|
||||
|
||||
for (const key of automationsFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, automationsFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// 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. The filters work out their items from the values.
|
||||
export interface AutomationsLink {
|
||||
search: string;
|
||||
filters: DataTableFilters;
|
||||
}
|
||||
|
||||
export const automationsLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): AutomationsLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, automationsFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(
|
||||
searchParams,
|
||||
automationsFilterQueryParamConfig
|
||||
);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof AutomationsFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
),
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
"ha-filter-blueprints": filter(values("blueprint")),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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) ?? [],
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,114 +0,0 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} 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 devices table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const devicesFilterQueryParamConfig = {
|
||||
list: [
|
||||
"status",
|
||||
"floor",
|
||||
"area",
|
||||
"domain",
|
||||
"label",
|
||||
"config_entry",
|
||||
"sub_entry",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type DevicesFilterQueryParams = QueryParamValues<
|
||||
typeof devicesFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
Array.isArray(filter?.value) ? filter.value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const devicesFilterQueryParams = (
|
||||
filters: DataTableFilters
|
||||
): DevicesFilterQueryParams => ({
|
||||
status: list(filters["ha-filter-states"]),
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
domain: list(filters["ha-filter-integrations"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
config_entry: list(filters.config_entry),
|
||||
sub_entry: list(filters.sub_entry),
|
||||
});
|
||||
|
||||
// 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 devicesFilterQueryString = (filters: DataTableFilters): string => {
|
||||
const params = devicesFilterQueryParams(filters);
|
||||
const sorted: DevicesFilterQueryParams = {};
|
||||
|
||||
for (const key of devicesFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, devicesFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// 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. The filters work out their items from the values.
|
||||
export interface DevicesLink {
|
||||
search: string;
|
||||
filters: DataTableFilters;
|
||||
}
|
||||
|
||||
export const devicesLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): DevicesLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, devicesFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, devicesFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof DevicesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-states": filter(values("status")),
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
),
|
||||
"ha-filter-integrations": filter(values("domain")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
config_entry: filter(values("config_entry")),
|
||||
sub_entry: filter(values("sub_entry")),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,127 +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 entities table. Search, sorting and grouping are kept
|
||||
// by the data table, see data-table-query-params.
|
||||
export const entitiesFilterQueryParamConfig = {
|
||||
list: [
|
||||
"status",
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"domain",
|
||||
"entity_domain",
|
||||
"label",
|
||||
"assistants",
|
||||
"config_entry",
|
||||
"sub_entry",
|
||||
// Read until 2027.5, replaced with `assistants` on the next write
|
||||
"voice_assistant",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type EntitiesFilterQueryParams = QueryParamValues<
|
||||
typeof entitiesFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
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 entitiesFilterQueryParams = (
|
||||
filters: DataTableFiltersValues
|
||||
): EntitiesFilterQueryParams => ({
|
||||
status: list(filters["ha-filter-states"]),
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
domain: list(filters["ha-filter-integrations"]),
|
||||
entity_domain: list(filters["ha-filter-domains"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
config_entry: list(filters.config_entry),
|
||||
sub_entry: list(filters.sub_entry),
|
||||
});
|
||||
|
||||
// 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 entitiesFilterQueryString = (
|
||||
filters: DataTableFiltersValues
|
||||
): string => {
|
||||
const params = entitiesFilterQueryParams(filters);
|
||||
const sorted: EntitiesFilterQueryParams = {};
|
||||
|
||||
for (const key of entitiesFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, entitiesFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// 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 EntitiesLink {
|
||||
search: string;
|
||||
filters: DataTableFiltersValues;
|
||||
}
|
||||
|
||||
export const entitiesLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): EntitiesLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, entitiesFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(
|
||||
searchParams,
|
||||
entitiesFilterQueryParamConfig
|
||||
);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof EntitiesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
const assistants = values("assistants");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-states": values("status"),
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-integrations": values("domain"),
|
||||
"ha-filter-domains": values("entity_domain"),
|
||||
"ha-filter-labels": values("label"),
|
||||
"ha-filter-voice-assistants": assistants.length
|
||||
? assistants
|
||||
: values("voice_assistant"),
|
||||
config_entry: values("config_entry"),
|
||||
sub_entry: values("sub_entry"),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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"),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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,115 +0,0 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} 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 scenes table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const scenesFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type ScenesFilterQueryParams = QueryParamValues<
|
||||
typeof scenesFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
Array.isArray(filter?.value) ? filter.value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const scenesFilterQueryParams = (
|
||||
filters: DataTableFilters
|
||||
): ScenesFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
entity: list(filters["ha-filter-entities"]),
|
||||
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 scenesFilterQueryString = (filters: DataTableFilters): string => {
|
||||
const params = scenesFilterQueryParams(filters);
|
||||
const sorted: ScenesFilterQueryParams = {};
|
||||
|
||||
for (const key of scenesFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, scenesFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// 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. The filters work out their items from the values.
|
||||
export interface ScenesLink {
|
||||
search: string;
|
||||
filters: DataTableFilters;
|
||||
}
|
||||
|
||||
export const scenesLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): ScenesLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, scenesFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, scenesFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof ScenesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
),
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,118 +0,0 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
} 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 scripts table. Search, sorting and grouping are kept by
|
||||
// the data table, see data-table-query-params.
|
||||
export const scriptsFilterQueryParamConfig = {
|
||||
list: [
|
||||
"floor",
|
||||
"area",
|
||||
"device",
|
||||
"entity",
|
||||
"label",
|
||||
"category",
|
||||
"assistants",
|
||||
"blueprint",
|
||||
],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type ScriptsFilterQueryParams = QueryParamValues<
|
||||
typeof scriptsFilterQueryParamConfig
|
||||
>;
|
||||
|
||||
const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
Array.isArray(filter?.value) ? filter.value : undefined;
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const scriptsFilterQueryParams = (
|
||||
filters: DataTableFilters
|
||||
): ScriptsFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
entity: list(filters["ha-filter-entities"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
assistants: list(filters["ha-filter-voice-assistants"]),
|
||||
blueprint: list(filters["ha-filter-blueprints"]),
|
||||
});
|
||||
|
||||
// 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 scriptsFilterQueryString = (filters: DataTableFilters): string => {
|
||||
const params = scriptsFilterQueryParams(filters);
|
||||
const sorted: ScriptsFilterQueryParams = {};
|
||||
|
||||
for (const key of scriptsFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
}
|
||||
|
||||
return createQueryString(sorted, scriptsFilterQueryParamConfig);
|
||||
};
|
||||
|
||||
// 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. The filters work out their items from the values.
|
||||
export interface ScriptsLink {
|
||||
search: string;
|
||||
filters: DataTableFilters;
|
||||
}
|
||||
|
||||
export const scriptsLinkFromUrl = (
|
||||
searchParams: SearchParamsSource
|
||||
): ScriptsLink | undefined => {
|
||||
if (
|
||||
!hasQueryParams(searchParams, scriptsFilterQueryParamConfig) &&
|
||||
!hasQueryParams(searchParams, dataTableQueryParamConfig)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const params = decodeQueryParams(searchParams, scriptsFilterQueryParamConfig);
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof ScriptsFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
items: undefined,
|
||||
});
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
|
||||
return {
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
),
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
// The category filter picks one category
|
||||
"ha-filter-categories": filter(values("category").slice(0, 1)),
|
||||
"ha-filter-voice-assistants": filter(values("assistants")),
|
||||
"ha-filter-blueprints": filter(values("blueprint")),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { DataZoomComponentOption } from "echarts/components";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type {
|
||||
ECElementEvent,
|
||||
ElementEvent,
|
||||
LegendComponentOption,
|
||||
LineSeriesOption,
|
||||
TooltipOption,
|
||||
@@ -63,7 +64,6 @@ import type {
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button";
|
||||
import { ZOOM_SLIDER_HEIGHT } from "./ha-chart-zoom-slider";
|
||||
import { formatTimeLabel } from "./axis-label";
|
||||
import type { ChartSonification } from "./chart-sonification";
|
||||
import { canSonifyChart, sonifyChart } from "./chart-sonification";
|
||||
@@ -76,15 +76,6 @@ const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
// echarts' own default, restored when switching back from touch input
|
||||
const DEFAULT_TOOLTIP_TRIGGER_ON = "mousemove|click|mousewheel";
|
||||
const ZOOM_SLIDER_BOTTOM = 4;
|
||||
// Room the plot gives up at its bottom while the zoom slider shows
|
||||
const ZOOM_SLIDER_SPACE = ZOOM_SLIDER_BOTTOM + ZOOM_SLIDER_HEIGHT + 8;
|
||||
// Grid bottom that just fits the x-axis labels, as in line charts
|
||||
const X_AXIS_LABEL_SPACE = 20;
|
||||
const ZOOM_RESET_SIZE = 24;
|
||||
const ZOOM_RESET_OFFSET = ZOOM_RESET_SIZE + 4;
|
||||
// A narrower drag selection is a sloppy click rather than a zoom
|
||||
const MIN_DRAG_ZOOM_WIDTH = 8;
|
||||
export const DEFAULT_CHART_WIDTH = 500;
|
||||
// Slack so a chart is up to date before a scroll can reach it. A phone screen
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
@@ -164,14 +155,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: "small-controls", type: Boolean })
|
||||
public smallControls?: boolean;
|
||||
|
||||
// For charts zoomed from outside, like the synced History charts
|
||||
@property({ attribute: "hide-zoom-controls", type: Boolean })
|
||||
public hideZoomControls = false;
|
||||
|
||||
// For charts sized to their content, like timelines, which grow to fit the
|
||||
// zoom slider instead of giving up plot height
|
||||
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
|
||||
public growForZoomSlider = false;
|
||||
@property({ attribute: "hide-reset-button", type: Boolean })
|
||||
public hideResetButton?: boolean;
|
||||
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
@@ -201,6 +186,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
// Separate from _isZoomed, whose touch handling reruns graph layouts
|
||||
@state() private _isGraphRoamed = false;
|
||||
|
||||
@state() private _zoomRatio = 1;
|
||||
|
||||
@state() private _minutesDifference = 24 * 60;
|
||||
|
||||
@state() private _hiddenDatasets = new Set<string>();
|
||||
@@ -218,6 +205,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
@state() private _sonificationFocusHeld = false;
|
||||
|
||||
private _modifierPressed = false;
|
||||
|
||||
private _isTouchDevice = "ontouchstart" in window;
|
||||
|
||||
// Whether the chart was last used with touch rather than a mouse. With touch
|
||||
@@ -227,9 +216,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
private _lastTapTime?: number;
|
||||
|
||||
private _doubleTapped = false;
|
||||
// A mouse button is held on the chart, so zooming now is a drag that pans it
|
||||
private _mouseDown = false;
|
||||
|
||||
private _dragToZoom = false;
|
||||
private _tooltipHiddenWhilePanning = false;
|
||||
|
||||
private _longPressTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
@@ -292,30 +282,17 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
private _legendItemCount?: number;
|
||||
|
||||
// Zoom window in percent
|
||||
@state() private _zoomRange: [number, number] = [0, 100];
|
||||
|
||||
// Keeps the zoom slider while in use, also once it reaches the full range
|
||||
@state() private _zoomSliderHeld = false;
|
||||
|
||||
// Whether the grid currently leaves room for the zoom slider
|
||||
private _zoomSliderRoom = false;
|
||||
|
||||
private _wheelPanPixels = 0;
|
||||
|
||||
private _wheelPanFrame?: number;
|
||||
private _pendingZoom?: [number, number, boolean];
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._removeOutsideTapListener();
|
||||
this._legendPointerCancel();
|
||||
this._mouseDown = false;
|
||||
this._tooltipHiddenWhilePanning = false;
|
||||
this._pendingSetup = false;
|
||||
this._pendingUpdate = undefined;
|
||||
if (this._wheelPanFrame !== undefined) {
|
||||
cancelAnimationFrame(this._wheelPanFrame);
|
||||
this._wheelPanFrame = undefined;
|
||||
}
|
||||
this._wheelPanPixels = 0;
|
||||
this._pendingZoom = undefined;
|
||||
// The observers are about to be torn down, so nothing would correct a stale
|
||||
// value if this element is reattached inside a hidden container.
|
||||
this._intersecting = false;
|
||||
@@ -368,6 +345,52 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
})
|
||||
);
|
||||
|
||||
if (!this.options?.dataZoom) {
|
||||
// Add keyboard event listeners
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (
|
||||
!this._modifierPressed &&
|
||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||
) {
|
||||
this._modifierPressed = true;
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
this._updateSankeyRoam();
|
||||
// drag to zoom
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
dataZoomSelectActive: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (ev: KeyboardEvent) => {
|
||||
if (
|
||||
this._modifierPressed &&
|
||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||
) {
|
||||
this._modifierPressed = false;
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
this._updateSankeyRoam();
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
dataZoomSelectActive: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
this._listeners.push(
|
||||
() => window.removeEventListener("keydown", handleKeyDown),
|
||||
() => window.removeEventListener("keyup", handleKeyUp)
|
||||
);
|
||||
}
|
||||
|
||||
const handleLayoutTransition: EventListener = (ev) => {
|
||||
const event = ev as HASSDomEvent<HASSDomEvents["hass-layout-transition"]>;
|
||||
this._layoutTransitionActive = Boolean(event.detail?.active);
|
||||
@@ -494,6 +517,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
this._disposeSonification();
|
||||
} else if (this._isTouchDevice && changedProps.has("_isZoomed")) {
|
||||
chartOptions.dataZoom = this._getDataZoomConfig();
|
||||
}
|
||||
let legendChanged = false;
|
||||
if (changedProps.has("options") || changedProps.has("data")) {
|
||||
@@ -503,16 +528,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
if (Object.keys(chartOptions).length > 0) {
|
||||
this._setChartOptions(chartOptions);
|
||||
if (changedProps.has("options")) {
|
||||
this._updateDragToZoom();
|
||||
if (chartOptions.series || changedProps.has("_isZoomed")) {
|
||||
this._updateSankeyRoam();
|
||||
}
|
||||
}
|
||||
if (
|
||||
chartOptions.series ||
|
||||
(this._isTouchDevice && changedProps.has("_isZoomed"))
|
||||
) {
|
||||
this._updateSankeyRoam();
|
||||
}
|
||||
if (legendChanged) {
|
||||
// Within a fixed height the host does not resize when the legend grows.
|
||||
// Without animation, setOption has already fired "finished" by now.
|
||||
@@ -522,38 +541,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
const room = this._showZoomSlider();
|
||||
if (this.chart && room !== this._zoomSliderRoom) {
|
||||
this._zoomSliderRoom = room;
|
||||
if (this.growForZoomSlider) {
|
||||
// The host grows by the room the grid gives up, so apply both in one
|
||||
// render to keep the plot still instead of shrinking and regrowing it
|
||||
this.chart.setOption(
|
||||
{ grid: this._getGridOption() },
|
||||
{ lazyUpdate: true }
|
||||
);
|
||||
this.chart.resize();
|
||||
} else {
|
||||
this.chart.setOption({ grid: this._getGridOption() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const sonifiable =
|
||||
!this._sonificationUnavailable &&
|
||||
canSonifyChart(this.data, this._hiddenDatasets);
|
||||
const showZoomSlider = this._showZoomSlider();
|
||||
return html`
|
||||
<div
|
||||
class="container ${classMap({ "has-height": !!this.height })}"
|
||||
style=${styleMap({
|
||||
height:
|
||||
showZoomSlider && this.growForZoomSlider && this.height
|
||||
? `calc(${this.height} + ${this._getZoomSliderSpace()}px)`
|
||||
: this.height,
|
||||
})}
|
||||
style=${styleMap({ height: this.height })}
|
||||
>
|
||||
<div
|
||||
class="chart-container"
|
||||
@@ -562,9 +557,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
})}
|
||||
>
|
||||
<div
|
||||
class="chart ${classMap({
|
||||
"touch-scrub": this._isTouchDevice && this._hasZoomableXAxis(),
|
||||
})}"
|
||||
class="chart"
|
||||
role=${ifDefined(sonifiable ? "application" : undefined)}
|
||||
tabindex=${ifDefined(
|
||||
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
|
||||
@@ -579,11 +572,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@blur=${this._handleChartBlur}
|
||||
@pointerdown=${this._handleChartPointer}
|
||||
@pointermove=${this._handleChartPointer}
|
||||
@pointercancel=${this._handleChartPointerCancel}
|
||||
@mousedown=${this._handleChartMouseDown}
|
||||
@wheel=${this._handleWheel}
|
||||
></div>
|
||||
${showZoomSlider ? this._renderZoomSlider() : nothing}
|
||||
</div>
|
||||
<div class="sonification-output"></div>
|
||||
${this._renderLegend()}
|
||||
@@ -593,9 +582,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
class="chart-controls ${classMap({ small: this.smallControls })}"
|
||||
>
|
||||
${
|
||||
(this._isZoomed || this._isGraphRoamed) &&
|
||||
!this.hideZoomControls &&
|
||||
!showZoomSlider
|
||||
(this._isZoomed || this._isGraphRoamed) && !this.hideResetButton
|
||||
? html`<ha-icon-button
|
||||
class="zoom-reset"
|
||||
.path=${mdiRestart}
|
||||
@@ -827,8 +814,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
value,
|
||||
this._i18n.locale,
|
||||
this._hassConfig,
|
||||
(this._minutesDifference * (this._zoomRange[1] - this._zoomRange[0])) /
|
||||
100
|
||||
this._minutesDifference * this._zoomRatio
|
||||
);
|
||||
|
||||
private async _setupChart() {
|
||||
@@ -843,7 +829,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
// The connection holds a reference to the chart instance, so it cannot
|
||||
// outlive it. Focusing the chart again reconnects.
|
||||
this._disposeSonification();
|
||||
// the new chart starts with its tooltip hidden, so nothing would remove it
|
||||
// the new chart starts with its handle hidden, so nothing would remove it
|
||||
this._removeOutsideTapListener();
|
||||
if (this.chart) {
|
||||
this.chart.dispose();
|
||||
@@ -860,18 +846,26 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this._dragToZoom = false;
|
||||
// Restored once the new chart is set up
|
||||
const [zoomStart, zoomEnd] = this._zoomRange;
|
||||
this._zoomRange = [0, 100];
|
||||
if (this._isZoomed) {
|
||||
this._isZoomed = false;
|
||||
this._zoomRatio = 1;
|
||||
fireEvent(this, "chart-sankeyroam", { zoom: 1 });
|
||||
}
|
||||
this._isGraphRoamed = false;
|
||||
this.chart.on("datazoom", (e: any) => {
|
||||
this._handleDataZoomEvent(e);
|
||||
});
|
||||
this.chart.getZr().on("mousedown", (e: ElementEvent) => {
|
||||
// Only the primary button pans. zrender does not mark touch and pen as
|
||||
// touch when it listens to pointer events, as on Edge, so check that too.
|
||||
const ev = e.event;
|
||||
const isMouse = !("pointerType" in ev) || ev.pointerType === "mouse";
|
||||
if (!e.zrByTouch && isMouse && "button" in ev && ev.button === 0) {
|
||||
this._mouseDown = true;
|
||||
}
|
||||
});
|
||||
// zrender also fires this when a drag is released outside the chart
|
||||
this.chart.getZr().on("mouseup", this._handleMouseUp);
|
||||
this.chart.on("click", (e: ECElementEvent) => {
|
||||
fireEvent(this, "chart-click", e);
|
||||
});
|
||||
@@ -905,30 +899,94 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
this.chart.on("finished", this._handleChartRenderFinished);
|
||||
if (this._isTouchDevice) {
|
||||
// Timed from the first tap's release, as on Android
|
||||
const zr = this.chart.getZr();
|
||||
zr.on("mousedown", (e: ECElementEvent) => {
|
||||
// The second tap's click is often lost, so this cannot wait for it
|
||||
this._doubleTapped = false;
|
||||
this.chart.getZr().on("click", (e: ECElementEvent) => {
|
||||
if (!e.zrByTouch) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.zrByTouch &&
|
||||
(e.event as unknown as TouchEvent).touches?.length === 1 &&
|
||||
this._lastTapTime &&
|
||||
Date.now() - this._lastTapTime < DOUBLE_TAP_TIME
|
||||
) {
|
||||
this._lastTapTime = undefined;
|
||||
this._doubleTapped = true;
|
||||
this._handleClickZoom(e);
|
||||
} else {
|
||||
this._lastTapTime = Date.now();
|
||||
}
|
||||
});
|
||||
zr.on("click", (e: ECElementEvent) => {
|
||||
if (e.zrByTouch) {
|
||||
this._lastTapTime = this._doubleTapped ? undefined : Date.now();
|
||||
// show axis pointer handle on touch devices
|
||||
let dragJustEnded = false;
|
||||
let handleShown = false;
|
||||
let lastTipX: number | undefined;
|
||||
let lastTipY: number | undefined;
|
||||
// showTip fires on every pointer move, so only touch the chart options
|
||||
// when the handle state changes. The update is a partial xAxis merge
|
||||
// built from this.options: getOption() would deep clone all series data.
|
||||
const setAxisPointerHandle = (show: boolean) => {
|
||||
handleShown = show;
|
||||
// the tooltip only opens on a tap, so a tap anywhere else closes it
|
||||
if (show) {
|
||||
document.addEventListener("pointerdown", this._handleOutsideTap, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
} else {
|
||||
this._removeOutsideTapListener();
|
||||
}
|
||||
this.chart?.setOption({
|
||||
xAxis: ensureArray(this.options?.xAxis ?? []).map(
|
||||
(axis: XAXisOption) =>
|
||||
axis.show === false
|
||||
? {}
|
||||
: {
|
||||
axisPointer: show
|
||||
? {
|
||||
status: "show",
|
||||
handle: {
|
||||
color: style.getPropertyValue("--primary-color"),
|
||||
margin: 0,
|
||||
size: 20,
|
||||
...axis.axisPointer?.handle,
|
||||
show: true,
|
||||
},
|
||||
label: { show: false },
|
||||
}
|
||||
: { status: "hide", handle: { show: false } },
|
||||
}
|
||||
),
|
||||
});
|
||||
};
|
||||
this.chart.on("showTip", (e: any) => {
|
||||
lastTipX = e.x;
|
||||
lastTipY = e.y;
|
||||
if (!handleShown) {
|
||||
setAxisPointerHandle(true);
|
||||
}
|
||||
});
|
||||
this.chart.on("hideTip", (e: any) => {
|
||||
// the drag end event doesn't have a `from` property
|
||||
if (e.from) {
|
||||
if (dragJustEnded) {
|
||||
// hideTip is fired twice when the drag ends, so we need to ignore the second one
|
||||
dragJustEnded = false;
|
||||
return;
|
||||
}
|
||||
// hiding the handle makes echarts fire hideTip again from inside
|
||||
// setOption; the flag is already cleared, so that one is skipped
|
||||
if (handleShown) {
|
||||
setAxisPointerHandle(false);
|
||||
}
|
||||
this.chart?.dispatchAction({
|
||||
type: "downplay",
|
||||
});
|
||||
} else if (lastTipX != null && lastTipY != null) {
|
||||
// echarts hides the tip as soon as the drag ends, so we need to show it again
|
||||
dragJustEnded = true;
|
||||
this.chart?.dispatchAction({
|
||||
type: "showTip",
|
||||
x: lastTipX,
|
||||
y: lastTipY,
|
||||
});
|
||||
}
|
||||
});
|
||||
// the tooltip only opens on a tap, so a tap anywhere else closes it
|
||||
this.chart.on("showTip", this._addOutsideTapListener);
|
||||
this.chart.on("hideTip", this._removeOutsideTapListener);
|
||||
}
|
||||
|
||||
this._updateHiddenStatsFromOptions(this.options);
|
||||
@@ -939,9 +997,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
this._legendItemCount = this._getLegendItems()?.length;
|
||||
this._updateSankeyRoam();
|
||||
this._updateDragToZoom();
|
||||
if (zoomStart !== 0 || zoomEnd !== 100) {
|
||||
this.zoom(zoomStart, zoomEnd, true);
|
||||
if (this._pendingZoom) {
|
||||
const [start, end, silent] = this._pendingZoom;
|
||||
this._pendingZoom = undefined;
|
||||
this.chart.dispatchAction({ type: "dataZoom", start, end, silent });
|
||||
}
|
||||
} finally {
|
||||
this._loading = false;
|
||||
@@ -988,140 +1047,25 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
// A chart can switch type (e.g. pie and bar) without being rebuilt
|
||||
private _updateDragToZoom() {
|
||||
const active = !this._touchInput && this._hasZoomableXAxis();
|
||||
if (!this.chart || active === this._dragToZoom) {
|
||||
return;
|
||||
}
|
||||
this._dragToZoom = active;
|
||||
this.chart.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
dataZoomSelectActive: active,
|
||||
});
|
||||
}
|
||||
|
||||
private _hasZoomableXAxis() {
|
||||
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
|
||||
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
XAXisOption | undefined;
|
||||
const yAxis = ensureArray(this.options?.yAxis)?.[0] as
|
||||
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
|
||||
YAXisOption | undefined;
|
||||
return Boolean(
|
||||
xAxis &&
|
||||
xAxis.show !== false &&
|
||||
!this.options?.dataZoom &&
|
||||
if (xAxis?.type === "value" && yAxis?.type === "category") {
|
||||
// vertical data zoom doesn't work well in this case and horizontal is pointless
|
||||
!(xAxis.type === "value" && yAxis?.type === "category")
|
||||
);
|
||||
}
|
||||
|
||||
private _getDataZoomConfig(): DataZoomComponentOption[] | undefined {
|
||||
if (!this._hasZoomableXAxis()) {
|
||||
return undefined;
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "dataZoom",
|
||||
type: "inside",
|
||||
orient: "horizontal",
|
||||
filterMode: this._getDataZoomFilterMode() as any,
|
||||
xAxisIndex: 0,
|
||||
// Panning is left to the slider and horizontal scrolling
|
||||
moveOnMouseMove: false,
|
||||
preventDefaultMouseMove: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private _showZoomSlider() {
|
||||
const [start, end] = this._zoomRange;
|
||||
return (
|
||||
!this.hideZoomControls &&
|
||||
this._hasZoomableXAxis() &&
|
||||
(this._zoomSliderHeld || start !== 0 || end !== 100)
|
||||
);
|
||||
}
|
||||
|
||||
private _renderZoomSlider() {
|
||||
const resetLabel = this._i18n.localize(
|
||||
"ui.components.history_charts.zoom_reset"
|
||||
);
|
||||
return html`
|
||||
${
|
||||
this._isZoomed
|
||||
? html`<ha-icon-button
|
||||
class="slider-reset"
|
||||
.path=${mdiRestart}
|
||||
.label=${resetLabel}
|
||||
@click=${this._handleZoomReset}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
<ha-chart-zoom-slider
|
||||
class="zoom-slider"
|
||||
.start=${this._zoomRange[0]}
|
||||
.end=${this._zoomRange[1]}
|
||||
@zoom-slider-changed=${this._handleZoomSliderChanged}
|
||||
@zoom-slider-reset=${this._handleZoomSliderReset}
|
||||
@pointerdown=${this._holdZoomSlider}
|
||||
@focusin=${this._holdZoomSlider}
|
||||
@pointerup=${this._releaseZoomSlider}
|
||||
@pointercancel=${this._releaseZoomSlider}
|
||||
@focusout=${this._releaseZoomSlider}
|
||||
></ha-chart-zoom-slider>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleZoomSliderChanged(
|
||||
ev: HASSDomEvent<HASSDomEvents["zoom-slider-changed"]>
|
||||
) {
|
||||
this._setZoomWindow(ev.detail.start, ev.detail.end);
|
||||
}
|
||||
|
||||
// A held slider stays at the full range, so release it to hide it
|
||||
private _handleZoomSliderReset() {
|
||||
this._zoomSliderHeld = false;
|
||||
this._handleZoomReset();
|
||||
}
|
||||
|
||||
private _holdZoomSlider() {
|
||||
this._zoomSliderHeld = true;
|
||||
}
|
||||
|
||||
private _releaseZoomSlider(
|
||||
ev: Event & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
) {
|
||||
// A mouse press focuses the slider, so it is kept until it loses focus
|
||||
this._zoomSliderHeld =
|
||||
ev.type !== "focusout" && ev.currentTarget.matches(":focus-within");
|
||||
}
|
||||
|
||||
private _getGridOption() {
|
||||
const grid = this.options?.grid;
|
||||
if (!grid || !this._zoomSliderRoom) {
|
||||
return grid;
|
||||
}
|
||||
const [first, ...rest] = ensureArray(grid);
|
||||
return [
|
||||
{ ...first, bottom: this._getGridBottom() + this._getZoomSliderSpace() },
|
||||
...rest,
|
||||
];
|
||||
}
|
||||
|
||||
private _getGridBottom() {
|
||||
const bottom =
|
||||
this.options?.grid && ensureArray(this.options.grid)[0].bottom;
|
||||
return typeof bottom === "number" ? bottom : 0;
|
||||
}
|
||||
|
||||
// Charts that grow for the slider let it take their padding below the
|
||||
// x-axis labels, so it sits as close to the labels as in other charts
|
||||
private _getZoomSliderSpace() {
|
||||
return this.growForZoomSlider
|
||||
? ZOOM_SLIDER_SPACE -
|
||||
Math.max(0, this._getGridBottom() - X_AXIS_LABEL_SPACE)
|
||||
: ZOOM_SLIDER_SPACE;
|
||||
return {
|
||||
id: "dataZoom",
|
||||
type: "inside",
|
||||
orient: "horizontal",
|
||||
filterMode: this._getDataZoomFilterMode() as any,
|
||||
xAxisIndex: 0,
|
||||
moveOnMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
preventDefaultMouseMove: !this._isTouchDevice || this._isZoomed,
|
||||
zoomLock: !this._isTouchDevice && !this._modifierPressed,
|
||||
};
|
||||
}
|
||||
|
||||
// "boundaryFilter" is a custom mode added via axis-proxy-patch.ts.
|
||||
@@ -1205,7 +1149,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
iconStyle: { opacity: 0 },
|
||||
},
|
||||
...this.options,
|
||||
grid: this._getGridOption(),
|
||||
legend,
|
||||
xAxis,
|
||||
};
|
||||
@@ -1366,9 +1309,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
},
|
||||
},
|
||||
timeAxis: {
|
||||
axisPointer: {
|
||||
lineStyle: { color: style.getPropertyValue("--info-color") },
|
||||
},
|
||||
axisLine: {
|
||||
show: true,
|
||||
lineStyle: { color: style.getPropertyValue("--divider-color") },
|
||||
@@ -1525,15 +1465,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!this._hasZoomableXAxis()) {
|
||||
return;
|
||||
}
|
||||
// Kept within the axis, as ECharts does, so the datazoom event matches it
|
||||
const start = Math.min(
|
||||
Math.max((e.offsetX / this.chart.getWidth()) * 100 - 15, 0),
|
||||
70
|
||||
);
|
||||
const range = this._isZoomed ? [0, 100] : [start, start + 30];
|
||||
const range = this._isZoomed
|
||||
? [0, 100]
|
||||
: [
|
||||
(e.offsetX / this.chart.getWidth()) * 100 - 15,
|
||||
(e.offsetX / this.chart.getWidth()) * 100 + 15,
|
||||
];
|
||||
this.chart.dispatchAction({
|
||||
type: "dataZoom",
|
||||
start: range[0],
|
||||
@@ -1543,66 +1480,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
public zoom(start: number, end: number, silent = false) {
|
||||
if (!this.chart || this._pendingSetup) {
|
||||
// Replayed by _setupChart
|
||||
this._setZoomRange(start, end);
|
||||
// Sibling charts sync their zoom imperatively, so a range that arrives
|
||||
// before a deferred setup or rebuild has to be replayed rather than
|
||||
// dropped. A reset to the full range is what a fresh chart is built with,
|
||||
// so it just clears.
|
||||
this._pendingZoom =
|
||||
start === 0 && end === 100 ? undefined : [start, end, silent];
|
||||
return;
|
||||
}
|
||||
this._setZoomWindow(start, end, silent);
|
||||
}
|
||||
|
||||
// Captured: ECharts cancels every wheel event over the plot, even one it
|
||||
// ignores. Only a zoom gesture (Ctrl/Cmd, or a trackpad pinch) goes on to it.
|
||||
@eventOptions({ capture: true })
|
||||
private _handleWheel(ev: WheelEvent) {
|
||||
if (this.options?.dataZoom || ev.ctrlKey || ev.metaKey) {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
// Not _isZoomed, which a Sankey roam sets without an axis to pan
|
||||
const [start, end] = this._zoomRange;
|
||||
if (!this.chart || (start === 0 && end === 100)) {
|
||||
return;
|
||||
}
|
||||
const delta = ev.shiftKey ? ev.deltaX || ev.deltaY : ev.deltaX;
|
||||
if (!delta || (!ev.shiftKey && Math.abs(ev.deltaX) < Math.abs(ev.deltaY))) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this._wheelPanPixels +=
|
||||
ev.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||
? delta * this.chart.getWidth()
|
||||
: ev.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? delta * 16
|
||||
: delta;
|
||||
// Trackpads fire many wheel events per frame
|
||||
this._wheelPanFrame ??= requestAnimationFrame(this._flushWheelPan);
|
||||
}
|
||||
|
||||
private _flushWheelPan = () => {
|
||||
this._wheelPanFrame = undefined;
|
||||
const pixels = this._wheelPanPixels;
|
||||
this._wheelPanPixels = 0;
|
||||
if (!this.chart || !pixels) {
|
||||
return;
|
||||
}
|
||||
const [start, end] = this._zoomRange;
|
||||
const shift = (pixels * (end - start)) / this._getXAxisPixelWidth();
|
||||
const clamped = Math.max(-start, Math.min(100 - end, shift));
|
||||
if (clamped) {
|
||||
this._setZoomWindow(start + clamped, end + clamped);
|
||||
}
|
||||
};
|
||||
|
||||
private _setZoomWindow(start: number, end: number, silent = false) {
|
||||
// An update animation would restart on every move and lag the gesture
|
||||
this.chart?.dispatchAction(
|
||||
{ type: "dataZoom", start, end, animation: { duration: 0 } },
|
||||
{ silent }
|
||||
);
|
||||
if (silent) {
|
||||
// Otherwise recorded by the datazoom handler
|
||||
this._setZoomRange(start, end);
|
||||
}
|
||||
this.chart.dispatchAction({
|
||||
type: "dataZoom",
|
||||
start,
|
||||
end,
|
||||
silent,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleZoomReset() {
|
||||
@@ -1677,174 +1568,125 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
// On touch devices a drag pans only once zoomed, so an unzoomed chart
|
||||
// does not swallow page scrolling. Pinch still zooms via "scale".
|
||||
// A plain wheel never reaches ECharts, see _handleWheel.
|
||||
private _getSankeyRoam(): boolean | "scale" {
|
||||
private _getSankeyRoam(): boolean | "move" | "scale" {
|
||||
if (this._modifierPressed) {
|
||||
return true;
|
||||
}
|
||||
if (this._isTouchDevice) {
|
||||
return this._isZoomed ? true : "scale";
|
||||
}
|
||||
return true;
|
||||
return "move";
|
||||
}
|
||||
|
||||
// Captured, so the trigger and drag to zoom are switched before echarts
|
||||
// handles the same event: before it acts on a tap, and before the first
|
||||
// mouse move after touch input. Only on touch devices. A pen counts as
|
||||
// touch, as it does for echarts.
|
||||
// Captured, so the trigger is switched before echarts handles the same event:
|
||||
// before it acts on a tap, and before the first mouse move after touch input.
|
||||
// Only on touch devices, which install the handle and outside tap handlers. A
|
||||
// pen counts as touch, as it does for echarts.
|
||||
@eventOptions({ capture: true })
|
||||
private _handleChartPointer(ev: PointerEvent) {
|
||||
const touchInput = this._isTouchDevice && ev.pointerType !== "mouse";
|
||||
if (ev.type === "pointerdown") {
|
||||
// zrender listens to pointer events instead of mouse events on Edge
|
||||
this._handleChartMouseDown(ev);
|
||||
// Drag to zoom cancels the press, so a focused slider would never blur
|
||||
this._zoomSliderHeld = false;
|
||||
if (touchInput === this._touchInput) {
|
||||
return;
|
||||
}
|
||||
if (touchInput !== this._touchInput) {
|
||||
this._touchInput = touchInput;
|
||||
this._updateDragToZoom();
|
||||
if (this.chart && this.options?.tooltip) {
|
||||
const tooltip = this._createOptions().tooltip;
|
||||
this.chart.setOption({
|
||||
tooltip: ensureArray(tooltip ?? []).map((t) => ({
|
||||
triggerOn: t.triggerOn ?? DEFAULT_TOOLTIP_TRIGGER_ON,
|
||||
})),
|
||||
});
|
||||
}
|
||||
this._touchInput = touchInput;
|
||||
if (!this.chart || !this.options?.tooltip) {
|
||||
return;
|
||||
}
|
||||
// zrender only follows a drag outside the chart once it has moved inside
|
||||
// it, so a quick drag out would lose the release and its selection
|
||||
if (
|
||||
ev.type === "pointerdown" &&
|
||||
this._dragToZoom &&
|
||||
this._isZoomPress(ev)
|
||||
) {
|
||||
this.chart
|
||||
?.getZr()
|
||||
.painter.getViewportRoot()
|
||||
.setPointerCapture(ev.pointerId);
|
||||
}
|
||||
// A finger drag along a time axis moves the tooltip, which touch opens on tap
|
||||
if (
|
||||
touchInput &&
|
||||
ev.type === "pointermove" &&
|
||||
ev.isPrimary &&
|
||||
this.chart &&
|
||||
this._hasZoomableXAxis()
|
||||
) {
|
||||
const rect = this._chartContainer!.getBoundingClientRect();
|
||||
this.chart.dispatchAction({
|
||||
type: "showTip",
|
||||
x: ev.clientX - rect.left,
|
||||
y: ev.clientY - rect.top,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// The browser took the touch to scroll the page
|
||||
private _handleChartPointerCancel(ev: PointerEvent) {
|
||||
if (ev.pointerType !== "mouse") {
|
||||
this._hideTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
// Only a primary press starts a drag to zoom; a context menu leaves it stuck
|
||||
@eventOptions({ capture: true })
|
||||
private _handleChartMouseDown(ev: MouseEvent) {
|
||||
if (!this._isZoomPress(ev)) {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private _isZoomPress(ev: MouseEvent) {
|
||||
return ev.button === 0 && !(isMac && ev.ctrlKey);
|
||||
}
|
||||
|
||||
private _addOutsideTapListener = () => {
|
||||
document.addEventListener("pointerdown", this._handleOutsideTap, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
const tooltip = this._createOptions().tooltip;
|
||||
this.chart.setOption({
|
||||
tooltip: ensureArray(tooltip ?? []).map((t) => ({
|
||||
triggerOn: t.triggerOn ?? DEFAULT_TOOLTIP_TRIGGER_ON,
|
||||
})),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
private _removeOutsideTapListener = () => {
|
||||
private _removeOutsideTapListener() {
|
||||
document.removeEventListener("pointerdown", this._handleOutsideTap, {
|
||||
capture: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// A pen does not reliably fire touch events, so this listens to pointer
|
||||
// events. The mouse is left out: echarts already hides the tooltip when it
|
||||
// leaves the chart, even when the tooltip only opens on click.
|
||||
private _handleOutsideTap = (ev: PointerEvent) => {
|
||||
if (ev.pointerType !== "mouse" && !ev.composedPath().includes(this)) {
|
||||
this._hideTooltip();
|
||||
this.chart?.dispatchAction({ type: "hideTip", from: "outside" });
|
||||
}
|
||||
};
|
||||
|
||||
private _hideTooltip() {
|
||||
this.chart?.dispatchAction({ type: "hideTip" });
|
||||
this.chart?.dispatchAction({
|
||||
type: "updateAxisPointer",
|
||||
currTrigger: "leave",
|
||||
});
|
||||
}
|
||||
|
||||
private _getXAxisPixelWidth(): number {
|
||||
const axisModel = this.chart
|
||||
// @ts-ignore private method but no public way to get the axis extent
|
||||
?.getModel()
|
||||
.getComponent("xAxis", 0) as
|
||||
{ axis?: { getExtent(): [number, number] } } | undefined;
|
||||
const extent = axisModel?.axis?.getExtent();
|
||||
const width = extent ? Math.abs(extent[1] - extent[0]) : 0;
|
||||
return width || this.chart!.getWidth();
|
||||
}
|
||||
|
||||
private _setZoomRange(start: number, end: number) {
|
||||
this._zoomRange = [start, end];
|
||||
this._isZoomed = start !== 0 || end !== 100;
|
||||
}
|
||||
|
||||
private _handleDataZoomEvent(e: any) {
|
||||
const zoomData = e.batch?.[0] ?? e;
|
||||
let start: number = zoomData.start;
|
||||
let end: number = zoomData.end;
|
||||
if (typeof start !== "number" || typeof end !== "number") {
|
||||
// A drag selection reports axis values; ECharts already moved the window
|
||||
const dataZoom = this.chart
|
||||
// @ts-ignore private method, getOption() would clone all series data
|
||||
?.getModel()
|
||||
.queryComponents({ mainType: "dataZoom", id: "dataZoom" })[0] as
|
||||
{ getPercentRange(): [number, number] | undefined } | undefined;
|
||||
[start, end] = dataZoom?.getPercentRange() ?? [0, 100];
|
||||
const [prevStart, prevEnd] = this._zoomRange;
|
||||
const pixels =
|
||||
((end - start) / (prevEnd - prevStart)) * this._getXAxisPixelWidth();
|
||||
if (pixels < MIN_DRAG_ZOOM_WIDTH) {
|
||||
this.chart!.dispatchAction(
|
||||
{
|
||||
type: "dataZoom",
|
||||
start: prevStart,
|
||||
end: prevEnd,
|
||||
animation: { duration: 0 },
|
||||
},
|
||||
{ silent: true }
|
||||
let start = typeof zoomData.start === "number" ? zoomData.start : 0;
|
||||
let end = typeof zoomData.end === "number" ? zoomData.end : 100;
|
||||
|
||||
if (
|
||||
start === 0 &&
|
||||
end === 100 &&
|
||||
zoomData.startValue !== undefined &&
|
||||
zoomData.endValue !== undefined
|
||||
) {
|
||||
const option = this.chart!.getOption();
|
||||
const xAxis = option.xAxis?.[0] ?? option.xAxis;
|
||||
|
||||
if (xAxis?.min && xAxis?.max) {
|
||||
const axisMin = new Date(xAxis.min).getTime();
|
||||
const axisMax = new Date(xAxis.max).getTime();
|
||||
const axisRange = axisMax - axisMin;
|
||||
|
||||
start = Math.max(
|
||||
0,
|
||||
Math.min(100, ((zoomData.startValue - axisMin) / axisRange) * 100)
|
||||
);
|
||||
end = Math.max(
|
||||
0,
|
||||
Math.min(100, ((zoomData.endValue - axisMin) / axisRange) * 100)
|
||||
);
|
||||
return;
|
||||
}
|
||||
// In percent, like every other zoom, so it stays in step with _zoomRange
|
||||
this.chart!.dispatchAction(
|
||||
{ type: "dataZoom", start, end },
|
||||
{ silent: true }
|
||||
);
|
||||
}
|
||||
|
||||
this._setZoomRange(start, end);
|
||||
this._isZoomed = start !== 0 || end !== 100;
|
||||
this._zoomRatio = (end - start) / 100;
|
||||
// the tooltip would follow the pointer across the moving data; a modifier
|
||||
// drag only zooms once, on release
|
||||
if (
|
||||
this._mouseDown &&
|
||||
!this._modifierPressed &&
|
||||
!this._tooltipHiddenWhilePanning
|
||||
) {
|
||||
this._tooltipHiddenWhilePanning = true;
|
||||
this._setPanTooltipsHidden(true);
|
||||
}
|
||||
if (this._isTouchDevice) {
|
||||
this.chart?.dispatchAction({ type: "hideTip" });
|
||||
this.chart?.dispatchAction({
|
||||
type: "hideTip",
|
||||
from: "datazoom",
|
||||
});
|
||||
}
|
||||
fireEvent(this, "chart-zoom", { start, end });
|
||||
}
|
||||
|
||||
private _handleMouseUp = () => {
|
||||
this._mouseDown = false;
|
||||
if (this._tooltipHiddenWhilePanning) {
|
||||
this._tooltipHiddenWhilePanning = false;
|
||||
this._setPanTooltipsHidden(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Restores the configured visibility of each tooltip rather than forcing it
|
||||
// on, so a chart without a tooltip, or with a hidden one, stays that way.
|
||||
private _setPanTooltipsHidden(hidden: boolean) {
|
||||
if (!this.options?.tooltip) {
|
||||
return;
|
||||
}
|
||||
this.chart?.setOption({
|
||||
tooltip: ensureArray(this.options.tooltip).map((tooltip) => ({
|
||||
show: hidden ? false : (tooltip.show ?? true),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Long-press to solo on touch/pen devices (500ms, consistent with action-handler-directive)
|
||||
private _legendPointerDown(
|
||||
ev: PointerEvent & HASSDomCurrentTargetEvent<HTMLElement>
|
||||
@@ -2050,37 +1892,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
max-height: var(--chart-max-height, 350px);
|
||||
}
|
||||
.chart-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-height: var(--chart-max-height, 350px);
|
||||
overflow: visible;
|
||||
}
|
||||
/* The chart is not mirrored in RTL, so neither is the slider */
|
||||
.slider-reset {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: ${ZOOM_SLIDER_BOTTOM + ZOOM_SLIDER_HEIGHT / 2 - ZOOM_RESET_SIZE / 2}px;
|
||||
--ha-icon-button-size: ${ZOOM_RESET_SIZE}px;
|
||||
--mdc-icon-size: 18px;
|
||||
--ha-icon-button-padding-inline: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.zoom-slider {
|
||||
position: absolute;
|
||||
left: ${ZOOM_RESET_OFFSET}px;
|
||||
right: 0;
|
||||
bottom: ${ZOOM_SLIDER_BOTTOM}px;
|
||||
}
|
||||
.slider-reset,
|
||||
.zoom-slider {
|
||||
animation: zoom-slider-fade-in var(--ha-animation-duration-normal)
|
||||
ease-out;
|
||||
}
|
||||
@keyframes zoom-slider-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.has-height .chart-container {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -2088,11 +1903,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
/* Vertical drags scroll the page; horizontal drags and pinches go to the
|
||||
chart */
|
||||
.chart.touch-scrub {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.chart:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
|
||||