mirror of
https://github.com/home-assistant/frontend.git
synced 2026-11-11 07:24:44 +00:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fab7881ccc | ||
|
|
353d628da6 | ||
|
|
d09ccd1c60 | ||
|
|
5b1ecdb0b0 | ||
|
|
ee35dd8cf6 | ||
|
|
42ce434a94 | ||
|
|
06d564dbd8 | ||
|
|
93f0c2c45a | ||
|
|
fb40d07621 | ||
|
|
a6073e44f7 | ||
|
|
df69f3c7ee | ||
|
|
95a009641f | ||
|
|
36f59f8495 | ||
|
|
8a13bba521 | ||
|
|
cc7b1952c0 | ||
|
|
2edf6f91d0 | ||
|
|
9d52517ae9 | ||
|
|
27a7f6335f | ||
|
|
cd44bfcebc | ||
|
|
8819844740 | ||
|
|
008adf1533 | ||
|
|
8c3d0f2f1e | ||
|
|
3f4f89480f | ||
|
|
432762a2ea | ||
|
|
013cf8d7e6 | ||
|
|
1de489cca9 | ||
|
|
97af661779 | ||
|
|
f702e369fc | ||
|
|
78e0a92ddd | ||
|
|
d742259444 | ||
|
|
623ca14356 | ||
|
|
49e5220634 | ||
|
|
ccd2a4dead | ||
|
|
fead96eb9c | ||
|
|
4e480e7e8c | ||
|
|
fc4d83195b | ||
|
|
0a6adf5290 | ||
|
|
3f8c806095 | ||
|
|
283c335878 | ||
|
|
54d42767b5 | ||
|
|
9cfb6ce1bd | ||
|
|
56a4dee395 | ||
|
|
8c49159b2f | ||
|
|
1754b1087f | ||
|
|
5a4ac066ed | ||
|
|
85398e787c | ||
|
|
8f5a45c673 | ||
|
|
3461566576 | ||
|
|
2a49102fae | ||
|
|
594e46a7b3 | ||
|
|
b013970dcd | ||
|
|
c05f3a66c1 | ||
|
|
a62448bc9a | ||
|
|
024cd8687e | ||
|
|
bb53939e14 | ||
|
|
19294a3e1f | ||
|
|
a2186a3a73 | ||
|
|
54abec98dc | ||
|
|
ccdac789fd | ||
|
|
87471c164d | ||
|
|
cbd8653a88 | ||
|
|
f7a23f5c47 | ||
|
|
afcf7d4e08 | ||
|
|
ef62989c77 | ||
|
|
faa1844ca3 | ||
|
|
ff1c7fc106 | ||
|
|
18d660d29f | ||
|
|
ac6928622e | ||
|
|
4fde55b03e | ||
|
|
7ccc4826ea | ||
|
|
eebc57d89a | ||
|
|
63b474f43c | ||
|
|
d5cc973c22 | ||
|
|
00e76021ce | ||
|
|
fb88d66421 | ||
|
|
23b161d257 | ||
|
|
123a32dd81 | ||
|
|
4dc713abe6 | ||
|
|
b9182e3ea6 | ||
|
|
9bd57fa122 | ||
|
|
4af7503112 | ||
|
|
984e63dcd0 | ||
|
|
f90ed06080 | ||
|
|
b176b8226b | ||
|
|
3f3e9297f7 | ||
|
|
83028028e8 | ||
|
|
37d7c6144c | ||
|
|
f469f3eeae | ||
|
|
069e87c913 | ||
|
|
46bdbee8f3 | ||
|
|
68def911a6 | ||
|
|
a8fdb99317 | ||
|
|
3d1bf61683 | ||
|
|
5cfdff0e21 | ||
|
|
54cefeb816 | ||
|
|
4458205273 | ||
|
|
0c2de46f11 | ||
|
|
6bef900455 | ||
|
|
438bfa2710 | ||
|
|
96864e951a | ||
|
|
a1355e5116 | ||
|
|
ae4366020c | ||
|
|
7f73d29b2f | ||
|
|
ebf453ccc7 | ||
|
|
a0d9afe695 |
No files matched your search
@@ -10,6 +10,7 @@ Use this skill when editing CSS, layout, visual hierarchy, theme integration, re
|
||||
## Theme And Layout Basics
|
||||
|
||||
- Use Home Assistant CSS custom properties instead of hardcoded colors.
|
||||
- CSS in the static HTML templates under `src/html/` must give custom properties a fallback value (for example `var(--ha-space-4, 16px)`), because the global tokens only exist once the JS-appended global stylesheet from `append-ha-style` has loaded.
|
||||
- Use `--ha-space-*` spacing tokens instead of hardcoded spacing where possible.
|
||||
- Keep components mobile-first and enhance for desktop.
|
||||
- Keep layouts RTL-safe. Prefer logical properties when they fit.
|
||||
|
||||
@@ -35,11 +35,12 @@ runs:
|
||||
NETLIFY_AUTH_TOKEN: ${{ inputs.auth-token }}
|
||||
NETLIFY_SITE_ID: ${{ inputs.site-id }}
|
||||
run: |
|
||||
# Execute the deployment
|
||||
# Execute the deployment (--filter picks the root workspace project,
|
||||
# --no-build because we deploy an already built directory)
|
||||
if [ -n "$ALIAS" ]; then
|
||||
npx -y netlify-cli deploy --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
npx -y netlify-cli deploy --filter home-assistant-frontend --no-build --dir="$DIR" --alias "$ALIAS" --json > deploy_output.json
|
||||
else
|
||||
npx -y netlify-cli deploy --dir="$DIR" --prod --json > deploy_output.json
|
||||
npx -y netlify-cli deploy --filter home-assistant-frontend --no-build --dir="$DIR" --prod --json > deploy_output.json
|
||||
fi
|
||||
|
||||
# Collect the urls from the deployment output
|
||||
|
||||
@@ -16,6 +16,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
@@ -35,6 +36,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -28,6 +28,7 @@ runs:
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
packages/*/node_modules
|
||||
~/.local/share/pnpm/store
|
||||
key: >-
|
||||
${{ inputs.node-modules-cache-key }}-${{ runner.os }}-${{ runner.arch }}-${{
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
name: Prepare container dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
|
||||
options: --user 1001
|
||||
defaults:
|
||||
run:
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -207,7 +207,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
- prepare-container-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.64.1-noble@sha256:47b510dd9230bf0b9bfc9b20102241e577ea7a99334ab4c3ca19f19770e9b8ea
|
||||
image: mcr.microsoft.com/playwright:v1.65.0-noble@sha256:6e3fb709361521eb27161606e6da6d2674e5e89fa16516cb37d6fdd91372273d
|
||||
options: --user 1001 --ipc=host
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -28,10 +28,18 @@ AI can be a helpful tool for writing code and documentation. However, due to the
|
||||
|
||||
All contributions must be reviewed and understood by the contributor before submission. You should be able to explain every change in a pull request you submit. Pull requests that appear to be unreviewed AI output will be closed without review.
|
||||
|
||||
## Design contributions
|
||||
|
||||
AI can be a helpful tool to explore ideas and build mockups and prototypes. But AI tends to give the most common answer, not the one that fits Home Assistant and its users. So we require a human who brings the point of view and owns the design decisions.
|
||||
|
||||
You should be able to explain the problem your design solves and why you made your choices. If a mockup or prototype was mostly generated with AI, say so when you share it. Check your work against the [design guidelines](https://contributors.home-assistant.io/design/), as AI tools often make up components, colors, or spacing that don't exist in Home Assistant. Design proposals that look like unreviewed AI output may be closed or hidden.
|
||||
|
||||
## Our use of AI
|
||||
|
||||
Some of our projects use AI tools to assist with code reviews, issue triaging, reporting, and other project management tasks. These tools may leave comments on pull requests or issues. As with any automated tooling, these comments are not always correct.
|
||||
|
||||
We also use AI to help keep the design system, the Figma kit, and the code in sync. The design decisions behind them are made by people.
|
||||
|
||||
If an AI tool leaves a comment on your contribution, treat it as you would any other review comment. If you believe it is incorrect, say so; a brief explanation is sufficient. Maintainers always have the final say. If in doubt, ask a maintainer.
|
||||
|
||||
## Enforcement
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* global module, require */
|
||||
// rspack/webpack loader that minifies the HTML, SVG, and CSS inside lit
|
||||
// tagged template literals using `minify-literals` (html-minifier-next +
|
||||
// lightningcss). Replaces the unmaintained babel-plugin-template-html-minifier.
|
||||
@@ -36,6 +35,14 @@ const initLoader = (browserslistEnv) => {
|
||||
return {
|
||||
minifyHTMLLiterals: minifyModule.minifyHTMLLiterals,
|
||||
lightningcssTargets,
|
||||
// With the legacy floor at Chrome 67, lightningcss rewrites rgba()
|
||||
// to 8-digit hex (#rrggbbaa). Some kiosk webviews served the legacy
|
||||
// build (e.g. Shelly Wall Display) can't parse that, so any
|
||||
// var(--divider-color) border falls back to currentColor. Always
|
||||
// compile hex alpha colors to rgba() in legacy builds.
|
||||
lightningcssInclude: browserslistEnv.startsWith("legacy")
|
||||
? lightningcssModule.Features.HexAlphaColors
|
||||
: 0,
|
||||
};
|
||||
})
|
||||
);
|
||||
@@ -67,12 +74,13 @@ module.exports = function minifyTemplateLiteralsLoader(source, map, meta) {
|
||||
const { browserslistEnv } = this.getOptions();
|
||||
|
||||
initLoader(browserslistEnv)
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets }) =>
|
||||
.then(({ minifyHTMLLiterals, lightningcssTargets, lightningcssInclude }) =>
|
||||
minifyHTMLLiterals(source, {
|
||||
fileName: this.resourcePath,
|
||||
html: htmlOptions,
|
||||
css: {
|
||||
targets: lightningcssTargets,
|
||||
include: lightningcssInclude,
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
@@ -68,8 +68,42 @@ 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 "card".
|
||||
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.replaceAll("*", name) : subpath)}$`,
|
||||
path.join(
|
||||
COMPONENTS_PACKAGE_DIR,
|
||||
name ? target["ha-source"].replaceAll("*", 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");
|
||||
@@ -244,6 +278,9 @@ const createRspackConfig = ({
|
||||
path: paths.root_dir,
|
||||
env: "legacy",
|
||||
}),
|
||||
// Keep rgba() instead of #rrggbbaa, see
|
||||
// minify-template-literals-loader.cjs
|
||||
include: { hexAlphaColors: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -428,6 +465,7 @@ const createRspackConfig = ({
|
||||
"src/resources/polyfills/bigdecimal.ts"
|
||||
),
|
||||
}),
|
||||
...componentsSourceAliases,
|
||||
"lit/static-html$": "lit/static-html.js",
|
||||
"lit/decorators$": "lit/decorators.js",
|
||||
"lit/directive$": "lit/directive.js",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Feature-Policy: vibrate 'none'; geolocation 'none'; midi 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; vibrate 'none'; payment 'none'
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
@@ -8,13 +8,13 @@
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/images/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/manifest.json
|
||||
Cache-Control: public, max-age: 3600, s-maxage=3600
|
||||
Cache-Control: public, max-age=3600, s-maxage=3600
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"background_color": "#FFFFFF",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Assistant devices with a screen.",
|
||||
"description": "Show Home Assistant on your Chromecast or Google Home devices with a screen.",
|
||||
"dir": "ltr",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta property="og:type" content="website" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
@@ -16,7 +16,7 @@
|
||||
<meta name="twitter:title" content="Home Assistant Cast" />
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Show Home Assistant on your Chromecast or Google Assistant devices with a screen."
|
||||
content="Show Home Assistant on your Chromecast or Google Home devices with a screen."
|
||||
/>
|
||||
<meta
|
||||
name="twitter:image"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<title>Home Assistant Cast - FAQ</title>
|
||||
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
|
||||
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
|
||||
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
|
||||
<style>
|
||||
body {
|
||||
@@ -52,7 +53,7 @@
|
||||
<p>
|
||||
Home Assistant Cast allows you to show your Home Assistant data on a
|
||||
Chromecast device and allows you to interact with Home Assistant on
|
||||
Google Assistant devices with a screen.
|
||||
Google Home devices with a screen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<title>Home Assistant Cast</title>
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="icon" href="/images/ha-cast-icon.png" type="image/png" />
|
||||
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
|
||||
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<% for (const entry of latestEntryJS) { %>
|
||||
<script type="module" src="<%= entry %>"></script>
|
||||
<% } %>
|
||||
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
|
||||
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
|
||||
<style>
|
||||
body {
|
||||
|
||||
@@ -48,7 +48,7 @@ const translateErr = (err) =>
|
||||
const INTRO = html`
|
||||
<p>
|
||||
Home Assistant Cast allows you to cast your Home Assistant installation to
|
||||
Chromecast video devices and to Google Assistant devices with a screen.
|
||||
Chromecast video devices and to Google Home devices with a screen.
|
||||
</p>
|
||||
<p>
|
||||
For more information, see the
|
||||
|
||||
@@ -6,7 +6,7 @@ const playerManager = castContext.getPlayerManager();
|
||||
|
||||
playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const castDemoLovelace: () => LovelaceConfig = () => {
|
||||
{
|
||||
type: "markdown",
|
||||
title: "Home Assistant Cast",
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Assistant devices with a screen.${
|
||||
content: `With Home Assistant you can easily create interfaces (just like this one) which can be shown on Chromecast devices connected to TVs or Google Home devices with a screen.${
|
||||
touchSupported
|
||||
? "\n\nYou are able to interact with this demo using the touch screen."
|
||||
: "\n\nOn a Google Nest Hub you are able to interact with Home Assistant Cast via the touch screen."
|
||||
|
||||
@@ -117,7 +117,7 @@ playerManager.setMessageInterceptor("LOAD", (loadRequestData) => {
|
||||
// We received a play media command, hide Lovelace and show media player
|
||||
showMediaPlayer();
|
||||
const media = loadRequestData.media;
|
||||
// Special handling if it came from Google Assistant
|
||||
// Special handling if it came from Google Home
|
||||
if (media.entity) {
|
||||
media.contentId = media.entity;
|
||||
media.streamType = "LIVE";
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
/*
|
||||
Cache-Control: public, max-age: 0, s-maxage=3600, must-revalidate
|
||||
Cache-Control: public, max-age=0, s-maxage=3600, must-revalidate
|
||||
Content-Security-Policy: form-action https:
|
||||
Referrer-Policy: no-referrer-when-downgrade
|
||||
X-Content-Type-Options: nosniff
|
||||
X-XSS-Protection: 1; mode=block
|
||||
|
||||
/api/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/assets/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_es5/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
|
||||
/frontend_latest/*
|
||||
Cache-Control: public, max-age: 604800, s-maxage=604800
|
||||
Cache-Control: public, max-age=604800, s-maxage=604800
|
||||
Access-Control-Allow-Origin: *
|
||||
|
||||
/embed.js
|
||||
|
||||
@@ -168,7 +168,8 @@ export class HaEmbedProvider extends LitElement {
|
||||
hass.mockWS("config/device_registry/list", (_msg, currentHass) =>
|
||||
Object.values(currentHass.devices)
|
||||
);
|
||||
// The registry of the entities, without settings such as calendar colors
|
||||
// The registry of the entities, with the options each entity sets, such
|
||||
// as timer presets. Other settings, such as calendar colors, are not set.
|
||||
const registryEntries = (currentHass: MockHomeAssistant) =>
|
||||
Object.values(currentHass.entities).map(
|
||||
(entity): EntityRegistryEntry => ({
|
||||
@@ -179,6 +180,9 @@ export class HaEmbedProvider extends LitElement {
|
||||
}),
|
||||
area_id: entity.area_id ?? null,
|
||||
device_id: entity.device_id ?? null,
|
||||
options:
|
||||
this.entities.find((item) => item.entity_id === entity.entity_id)
|
||||
?.options ?? null,
|
||||
})
|
||||
);
|
||||
hass.mockWS("config/entity_registry/list", (_msg, currentHass) =>
|
||||
|
||||
@@ -35,13 +35,12 @@
|
||||
/>
|
||||
<meta name="theme-color" content="#03a9f4" />
|
||||
<%= renderTemplate("_social_meta.html.template") %>
|
||||
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
|
||||
<style>
|
||||
html {
|
||||
background-color: var(--primary-background-color, #fafafa);
|
||||
color: var(--primary-text-color, #212121);
|
||||
/* Prevent iOS Safari from inflating text when rotating to landscape */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html {
|
||||
@@ -49,94 +48,8 @@
|
||||
color: var(--primary-text-color, #e1e1e1);
|
||||
}
|
||||
}
|
||||
body {
|
||||
font-family: Roboto, Noto, sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: 400;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#ha-launch-screen {
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
transition: opacity var(--ha-animation-duration-normal, 250ms) ease-out;
|
||||
}
|
||||
#ha-launch-screen.removing {
|
||||
opacity: 0;
|
||||
}
|
||||
@keyframes launch-lockup-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
#ha-launch-screen .ha-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
|
||||
}
|
||||
#ha-launch-screen-info-box > *,
|
||||
#ha-launch-screen .ohf-logo {
|
||||
animation: launch-lockup-in var(--ha-animation-duration-normal, 250ms) ease-out both;
|
||||
}
|
||||
#ha-launch-screen .ha-logo {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
flex: 1;
|
||||
margin-top: calc( 2 * max(var(--safe-area-inset-top, 0px), 48px) + 46px );
|
||||
padding-top: 48px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
flex: 1;
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom, 0px), 48px) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
opacity: .66;
|
||||
}
|
||||
.ohf-logo span {
|
||||
font-size: 12px;
|
||||
line-height: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ohf-logo picture {
|
||||
display: flex;
|
||||
}
|
||||
.ohf-logo img {
|
||||
width: 237px;
|
||||
aspect-ratio: 237 / 24;
|
||||
height: auto;
|
||||
}
|
||||
@media (max-height: 560px) {
|
||||
#ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
margin-top: 24px;
|
||||
padding-top: 24px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
padding-top: 16px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin-block: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<%= renderTemplate("../../../src/html/_style_launch_screen.html.template") %>
|
||||
</head>
|
||||
<body>
|
||||
<div id="ha-launch-screen">
|
||||
|
||||
@@ -44,14 +44,21 @@ export const lorawanFixtures: ConnectivityFixtures = {
|
||||
],
|
||||
commands: ["lorawan/"],
|
||||
manifests: [
|
||||
manifest("lorawan", "LoRaWAN", { integration_type: "helper" }),
|
||||
manifest("lorawan", "LoRaWAN", {
|
||||
config_flow: false,
|
||||
integration_type: "service",
|
||||
}),
|
||||
manifest("sensecap_lorawan", "SenseCAP", {
|
||||
integration_type: "device",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("chirpstack", "ChirpStack", { integration_type: "hub" }),
|
||||
manifest("chirpstack", "ChirpStack", {
|
||||
integration_type: "hub",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
manifest("thethingsnetwork_lorawan", "The Things Network", {
|
||||
integration_type: "hub",
|
||||
dependencies: ["lorawan"],
|
||||
}),
|
||||
],
|
||||
configEntries: [
|
||||
|
||||
@@ -10,19 +10,26 @@ export const FRONIUS_ENTRY_ID = "mock-fronius";
|
||||
export const RS485_PORT = "/dev/ttyUSB1";
|
||||
|
||||
export const modbusFixtures: ConnectivityFixtures = {
|
||||
components: ["modbus"],
|
||||
components: ["modbus", "flexit", "solaredge_modbus", "fronius"],
|
||||
commands: ["modbus/"],
|
||||
manifests: [
|
||||
manifest("modbus", "Modbus", {
|
||||
config_flow: false,
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("flexit", "Flexit", {
|
||||
integration_type: "device",
|
||||
dependencies: ["modbus"],
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("solaredge_modbus", "SolarEdge Modbus", {
|
||||
integration_type: "device",
|
||||
dependencies: ["modbus"],
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
manifest("fronius", "Fronius", {
|
||||
integration_type: "device",
|
||||
dependencies: ["modbus"],
|
||||
iot_class: "local_polling",
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -97,6 +97,14 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
stat_consumption: "sensor.water_garden",
|
||||
},
|
||||
],
|
||||
device_consumption_gas: [
|
||||
{
|
||||
stat_consumption: "sensor.gas_kitchen",
|
||||
},
|
||||
{
|
||||
stat_consumption: "sensor.gas_heat_pump",
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("energy/info", (): EnergyInfo => ({
|
||||
cost_sensors: {},
|
||||
@@ -106,6 +114,7 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
device_consumption_gas: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
|
||||
@@ -160,6 +160,26 @@ export const energyEntities = () =>
|
||||
unit_of_measurement: "L",
|
||||
},
|
||||
},
|
||||
"sensor.gas_kitchen": {
|
||||
entity_id: "sensor.gas_kitchen",
|
||||
state: "100",
|
||||
attributes: {
|
||||
friendly_name: "Gas kitchen",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.gas_heat_pump": {
|
||||
entity_id: "sensor.gas_heat_pump",
|
||||
state: "500",
|
||||
attributes: {
|
||||
friendly_name: "Gas heat pump",
|
||||
unit_of_measurement: "m³",
|
||||
device_class: "gas",
|
||||
state_class: "total_increasing",
|
||||
},
|
||||
},
|
||||
"sensor.energy_car": {
|
||||
entity_id: "sensor.energy_car",
|
||||
state: "4",
|
||||
|
||||
@@ -158,6 +158,24 @@ const statisticsFunctions: Record<
|
||||
(bucketStart, bucketEnd) =>
|
||||
(20 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_kitchen": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
100,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.1 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.gas_heat_pump": (_id, start, end, period = "hour") =>
|
||||
generateSumStatistics(
|
||||
start,
|
||||
end,
|
||||
period,
|
||||
500,
|
||||
(bucketStart, bucketEnd) =>
|
||||
(0.6 * (bucketEnd.getTime() - bucketStart.getTime())) / 3600000
|
||||
),
|
||||
"sensor.energy_consumption_tarif_1": (
|
||||
_id: string,
|
||||
start: Date,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { ContextSubscriptionController } from "../../../src/common/decorators/consume";
|
||||
import { uiContext } from "../../../src/data/context";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
// A demo that provides its own hass hides the gallery theme from its children,
|
||||
// so pass the gallery light or dark mode on to it.
|
||||
export const followGalleryTheme = (
|
||||
host: ReactiveElement,
|
||||
hass: MockHomeAssistant
|
||||
) =>
|
||||
new ContextSubscriptionController(host, uiContext, (ui) => {
|
||||
if (ui) {
|
||||
hass.updateHass({
|
||||
themes: ui.themes,
|
||||
selectedTheme: ui.selectedTheme,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -4,7 +4,7 @@ title: "Our story"
|
||||
|
||||
## Open source home automation that puts local control and privacy first
|
||||
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Assistant and Amazon Alexa.
|
||||
Home Assistant is a free and open-source software for home automation that is designed to be the central control system for smart home devices with a focus on local control and privacy. It can be accessed via a web-based user interface, via apps for Android and iOS, or using voice commands via a supported virtual assistant like Google Home and Amazon Alexa.
|
||||
|
||||
IoT devices and services are supported by modular support for controlling proprietary ecosystems if they provide public access via an Open API for third-party integrations and protocols like Bluetooth, MQTT, Zigbee, and Z-Wave, After the Home Assistant software application is installed as a computer appliance it will act as a central control system for home automation. Information from all entities it sees can be used and controlled from within scripts trigger automations using scheduling and "blueprint" subroutines, e.g. for controlling lighting, climate, entertainment systems, and appliances.
|
||||
|
||||
@@ -36,6 +36,6 @@ Durability for the Open Home means that devices are designed and built to keep w
|
||||
|
||||
The project was started as a Python application by Paulus Schoutsen in September 2013 and first published publicly on GitHub in November 2013. In July 2017, a managed operating system called Hass.io was initially introduced to make it easier use to use Home Assistant on single-board computers like the Raspberry Pi series. Its bundled "supervisor" management system allowed users to manage, backup, and update the local installation and introduced the option to extend the functionality of the software with add-ons.
|
||||
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Assistant. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
An optional subscription service was introduced in December 2017 for $5/month to solve the complexities associated with secured remote access, as well as linking to Amazon Alexa and Google Home. Nabu Casa, Inc. was formed in September 2018 to take over the subscription service. The company's funding is based solely on revenue from the subscription service. It is used to finance the project's infrastructure and to pay for full-time employees contributing to the project.
|
||||
|
||||
In January 2020, branding was adjusted to make it easier to refer to different parts of the project. The main piece of software was renamed to Home Assistant Core, while the full suite of software with the embedded operating system and bundled "supervisor" management system was renamed to Home Assistant.
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
} from "../../../../src/data/history";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -167,7 +166,6 @@ export class DemoStateHistoryCharts extends LitElement {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
hass.addEntities(ENTITIES);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { ChartSelectMode } from "../../../../src/components/chart/ha-chart-base";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../../src/components/chart/statistics-chart";
|
||||
import "../../../../src/components/ha-button";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type {
|
||||
Statistics,
|
||||
@@ -13,9 +11,7 @@ import type {
|
||||
} from "../../../../src/data/recorder";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
import { followGalleryTheme } from "../../data/follow-gallery-theme";
|
||||
|
||||
const HOUR = 60 * 60 * 1000;
|
||||
const HOURS = 48;
|
||||
@@ -112,64 +108,6 @@ interface Example {
|
||||
height?: string;
|
||||
}
|
||||
|
||||
interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
interface RangeExample {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
statistics: Statistics;
|
||||
statTypes: StatisticType[];
|
||||
chartType: "line" | "bar";
|
||||
unit: string;
|
||||
selectMode: ChartSelectMode;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
const RANGE_EXAMPLES: RangeExample[] = [
|
||||
{
|
||||
id: "point",
|
||||
title: "Point selection",
|
||||
description:
|
||||
"With select-mode set to point, a click selects the hour that contains it, and everything outside fades. Dragging still zooms.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "point",
|
||||
hint: "Click on a bar to select its hour.",
|
||||
},
|
||||
{
|
||||
id: "range-bar",
|
||||
title: "Range selection on bars",
|
||||
description:
|
||||
"With select-mode set to range, dragging selects a time range instead of zooming, so several bars can be selected at once. A click still selects one bar.",
|
||||
statistics: { [HEAT_PUMP_ID]: HEAT_PUMP },
|
||||
statTypes: ["change"],
|
||||
chartType: "bar",
|
||||
unit: "kWh",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select one or more bars.",
|
||||
},
|
||||
{
|
||||
id: "range",
|
||||
title: "Range selection on lines",
|
||||
description: "The same on a line chart.",
|
||||
statistics: { [LIVING_ROOM_ID]: LIVING_ROOM },
|
||||
statTypes: ["mean", "min", "max"],
|
||||
chartType: "line",
|
||||
unit: "°C",
|
||||
selectMode: "range",
|
||||
hint: "Click or drag on the chart to select a period.",
|
||||
},
|
||||
];
|
||||
|
||||
const formatRange = (range: TimeRange) =>
|
||||
`${new Date(range.start).toLocaleString()} to ${new Date(range.end).toLocaleString()}`;
|
||||
|
||||
const EXAMPLES: Example[] = [
|
||||
{
|
||||
title: "Sum as bars",
|
||||
@@ -221,20 +159,17 @@ const EXAMPLES: Example[] = [
|
||||
export class DemoStatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@state() private _selections: Record<string, TimeRange | undefined> = {};
|
||||
|
||||
protected firstUpdated(changedProps: PropertyValues<this>) {
|
||||
super.firstUpdated(changedProps);
|
||||
const hass = provideHass(this);
|
||||
hass.updateTranslations(null, "en");
|
||||
followGalleryTheme(this, hass);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
return html`${EXAMPLES.map(
|
||||
return EXAMPLES.map(
|
||||
(example) => html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
@@ -256,66 +191,7 @@ export class DemoStatisticsChart extends LitElement {
|
||||
</div>
|
||||
</ha-card>
|
||||
`
|
||||
)}
|
||||
${RANGE_EXAMPLES.map((example) => this._renderRangeExample(example))}`;
|
||||
}
|
||||
|
||||
private _renderRangeExample(example: RangeExample) {
|
||||
const selection = this._selections[example.id];
|
||||
return html`
|
||||
<ha-card header=${example.title}>
|
||||
<div class="card-content">
|
||||
<p>${example.description}</p>
|
||||
<statistics-chart
|
||||
.statisticsData=${example.statistics}
|
||||
.metadata=${METADATA}
|
||||
.names=${NAMES}
|
||||
.statTypes=${example.statTypes}
|
||||
.chartType=${example.chartType}
|
||||
period="hour"
|
||||
.unit=${example.unit}
|
||||
.startTime=${START}
|
||||
.endTime=${END}
|
||||
.highlight=${selection}
|
||||
.clickForMoreInfo=${false}
|
||||
data-example=${example.id}
|
||||
.selectMode=${example.selectMode}
|
||||
hide-legend
|
||||
@chart-select=${this._rangeSelected}
|
||||
></statistics-chart>
|
||||
<div class="selection">
|
||||
${
|
||||
selection
|
||||
? html`
|
||||
<span>${formatRange(selection)}</span>
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="s"
|
||||
data-example=${example.id}
|
||||
@click=${this._clearSelection}
|
||||
>
|
||||
Clear
|
||||
</ha-button>
|
||||
`
|
||||
: html`<span class="hint">${example.hint}</span>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _rangeSelected(ev: HASSDomEvent<HASSDomEvents["chart-select"]>) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = {
|
||||
...this._selections,
|
||||
[id]: ev.detail,
|
||||
};
|
||||
}
|
||||
|
||||
private _clearSelection(ev: Event) {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.example!;
|
||||
this._selections = { ...this._selections, [id]: undefined };
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -326,20 +202,6 @@ export class DemoStatisticsChart extends LitElement {
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
.selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-2);
|
||||
min-height: 48px;
|
||||
margin-top: var(--ha-space-2);
|
||||
padding: 0 var(--ha-space-2) 0 var(--ha-space-4);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--secondary-background-color);
|
||||
}
|
||||
.hint {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const brandEntries: ConfigEntryExtended[] = (
|
||||
["tuya", "Tuya", "setup_error"],
|
||||
["unifi", "UniFi Network", "not_loaded"],
|
||||
["met", "Meteorologisk institutt (Met.no)", "setup_error"],
|
||||
["google_assistant", "Google Assistant", "not_loaded"],
|
||||
["google_assistant", "Google Home", "not_loaded"],
|
||||
["homekit_controller", "HomeKit Device", "setup_retry"],
|
||||
] as [string, string, ConfigEntryExtended["state"]][]
|
||||
).map(([domain, name, entryState]) =>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<title>Home Assistant</title>
|
||||
<%= renderTemplate("../../../src/html/_header.html.template") %>
|
||||
<%= renderTemplate("../../../src/html/_meta_viewport.html.template") %>
|
||||
<%= renderTemplate("../../../src/html/_style_base.html.template") %>
|
||||
<style>
|
||||
html {
|
||||
|
||||
+24
-24
@@ -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",
|
||||
@@ -40,7 +40,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.10",
|
||||
"@babel/runtime": "8.1.0",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.4",
|
||||
"@codemirror/commands": "6.11.1",
|
||||
@@ -73,16 +73,15 @@
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@lit/task": "1.0.3",
|
||||
"@maplibre/maplibre-gl-leaflet": "0.1.4",
|
||||
"@material/mwc-list": "0.27.0",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@tsparticles/engine": "4.5.0",
|
||||
"@tsparticles/preset-links": "4.5.0",
|
||||
"@versatiles/style": "7.1.0",
|
||||
"@swc/helpers": "0.5.24",
|
||||
"@tsparticles/engine": "4.5.3",
|
||||
"@tsparticles/preset-links": "4.5.3",
|
||||
"@versatiles/style": "8.1.1",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@vvo/tzdb": "6.202.0",
|
||||
"@vvo/tzdb": "6.203.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"barcode-detector": "3.2.3",
|
||||
"cally": "0.9.2",
|
||||
@@ -98,7 +97,7 @@
|
||||
"echarts": "6.1.0",
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fullcalendar": "7.1.3",
|
||||
"fullcalendar": "7.1.4",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.4",
|
||||
"home-assistant-js-websocket": "9.8.1",
|
||||
@@ -110,7 +109,7 @@
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"maplibre-gl": "6.16.0",
|
||||
"marked": "18.1.2",
|
||||
"marked": "18.1.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.2",
|
||||
"node-vibrant": "4.0.4",
|
||||
@@ -123,7 +122,7 @@
|
||||
"sortablejs": "1.15.7",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"temporal-polyfill": "1.0.6",
|
||||
"temporal-polyfill": "1.0.8",
|
||||
"tinykeys": "4.0.1",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
@@ -137,23 +136,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.10",
|
||||
"@babel/core": "8.1.0",
|
||||
"@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.23.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.23.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
"@html-eslint/eslint-plugin": "0.67.0",
|
||||
"@home-assistant/components": "workspace:*",
|
||||
"@html-eslint/eslint-plugin": "0.68.0",
|
||||
"@lokalise/node-api": "17.0.1",
|
||||
"@octokit/auth-oauth-device": "8.0.5",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.64.1",
|
||||
"@playwright/test": "1.65.0",
|
||||
"@rsdoctor/rspack-plugin": "1.6.5",
|
||||
"@rspack/core": "2.3.0",
|
||||
"@rspack/core": "2.3.1",
|
||||
"@rspack/dev-server": "2.2.2",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "7.0.1",
|
||||
@@ -170,7 +170,7 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@typescript/native": "npm:[email protected]",
|
||||
"@vitest/coverage-v8": "5.0.5",
|
||||
"@vitest/coverage-v8": "5.1.0",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-polyfill-corejs3": "1.0.0",
|
||||
"browserslist": "4.29.5",
|
||||
@@ -180,7 +180,7 @@
|
||||
"eslint": "10.14.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-import-x": "4.18.0",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.3",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
@@ -188,14 +188,14 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.4.1",
|
||||
"generate-license-file": "4.2.5",
|
||||
"glob": "14.0.3",
|
||||
"glob": "14.0.4",
|
||||
"globals": "17.14.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.1.3",
|
||||
"jsdom": "31.0.0",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
@@ -208,21 +208,21 @@
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.9.10",
|
||||
"rspack-manifest-plugin": "5.2.2",
|
||||
"serve": "14.2.6",
|
||||
"serve": "14.2.7",
|
||||
"sinon": "22.1.0",
|
||||
"tar": "7.5.22",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.72.1",
|
||||
"vite": "8.3.5",
|
||||
"vite": "8.3.6",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.5",
|
||||
"vitest": "5.1.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "7.4.1"
|
||||
},
|
||||
"packageManager": "[email protected]6.1",
|
||||
"packageManager": "[email protected]8.0",
|
||||
"volta": {
|
||||
"node": "24.22.2"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# @home-assistant/components
|
||||
|
||||
Home Assistant Components (`hac-*`): framework-agnostic web components built
|
||||
with [Lit](https://lit.dev).
|
||||
|
||||
> Work in progress, not published yet.
|
||||
|
||||
## Registration
|
||||
|
||||
Every element is imported on its own, in one of two ways:
|
||||
|
||||
| Import | Registers in | For |
|
||||
| --------------------------------------------- | -------------------- | ----------------------------------------------------------- |
|
||||
| `@home-assistant/components/<element>` | Global registry | Home Assistant itself |
|
||||
| `@home-assistant/components/<element>/scoped` | Your scoped registry | Custom cards and other code that runs inside Home Assistant |
|
||||
|
||||
`<element>` is the tag name without the `hac-` prefix, like `card` for
|
||||
`hac-card`.
|
||||
|
||||
Both use the copy bundled with your code. A scoped copy is isolated from the
|
||||
one Home Assistant registers globally, so a custom card keeps the version it
|
||||
was built with.
|
||||
|
||||
Global registration skips tag names that are already registered, so the first
|
||||
registration wins and nothing throws.
|
||||
|
||||
### Global
|
||||
|
||||
```ts
|
||||
import "@home-assistant/components/card";
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Each component exposes its own `--hac-*` custom properties, documented in its
|
||||
source, which take precedence. Next come the theme variables of the existing
|
||||
Home Assistant element it replaces, so current themes keep working: `hac-card`
|
||||
reads the variables of `ha-card` (`--ha-card-background`,
|
||||
`--card-background-color`, `--ha-card-border-radius`, …). Last come the
|
||||
semantic color tokens (`--ha-color-*`, like `--ha-color-surface-default`) and
|
||||
their light values, so the components also render outside Home Assistant.
|
||||
|
||||
The legacy theme variables are a temporary bridge for this experiment, not the
|
||||
target design. Home Assistant always defines some of them, like
|
||||
`--card-background-color`, so inside Home Assistant they currently win over
|
||||
the semantic tokens. Once themes move to the semantic tokens, the legacy
|
||||
variables will be removed from the fallbacks.
|
||||
|
||||
## Structure
|
||||
|
||||
Each element has its own directory, named like its import path:
|
||||
|
||||
```
|
||||
src/components/card/ <hac-card> -> @home-assistant/components/card
|
||||
hac-card.ts class, does not register itself
|
||||
info.ts tag name and version
|
||||
global.ts registers globally
|
||||
scoped.ts element for a scoped registry -> …/card/scoped
|
||||
```
|
||||
|
||||
Shared code lives in `src/utils/`: `registration/` for defining elements
|
||||
(global and scoped) and `element/` for helpers that components use
|
||||
themselves, like slot checks.
|
||||
|
||||
Every element has its own version in `info.ts`.
|
||||
|
||||
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 and custom-elements.json 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. The build then generates a
|
||||
[custom elements manifest](https://custom-elements-manifest.open-wc.org) from
|
||||
the JSDoc of each element, so document new slots, parts, CSS properties and
|
||||
events there.
|
||||
@@ -0,0 +1,84 @@
|
||||
// Generates dist/custom-elements.json from the JSDoc of the hac elements, for
|
||||
// editors and documentation tools. Only hac-*.ts files declare elements; the
|
||||
// entry points just re-export them.
|
||||
|
||||
const forEachDeclaration = (customElementsManifest, callback) => {
|
||||
for (const module of customElementsManifest.modules) {
|
||||
for (const declaration of module.declarations ?? []) {
|
||||
callback(declaration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// The analyzer leaves the readme empty. Paths in the manifest are relative to
|
||||
// the package root, like the module paths.
|
||||
const packageReadme = () => ({
|
||||
name: "package-readme",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
customElementsManifest.readme = "README.md";
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are documented with @summary, which the analyzer only puts into
|
||||
// `summary`. Most tools show `description`, so fall back to the summary.
|
||||
const summaryAsDescription = () => ({
|
||||
name: "summary-as-description",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
if (!declaration.description && declaration.summary) {
|
||||
declaration.description = declaration.summary;
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Private and protected members are internal, so keep them out of the
|
||||
// documented API.
|
||||
const publicApiOnly = () => ({
|
||||
name: "public-api-only",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
forEachDeclaration(customElementsManifest, (declaration) => {
|
||||
declaration.members = declaration.members?.filter(
|
||||
(member) => !member.privacy || member.privacy === "public"
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const distPaths = () => {
|
||||
const toDist = (value) => value.replace(/^src\/(.+)\.ts$/, "dist/$1.js");
|
||||
const rewrite = (node) => {
|
||||
if (Array.isArray(node)) {
|
||||
node.forEach(rewrite);
|
||||
return;
|
||||
}
|
||||
if (!node || typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
if ((key === "path" || key === "module") && typeof value === "string") {
|
||||
node[key] = toDist(value);
|
||||
} else {
|
||||
rewrite(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
return {
|
||||
name: "dist-paths",
|
||||
packageLinkPhase({ customElementsManifest }) {
|
||||
rewrite(customElementsManifest.modules);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
globs: ["src/components/**/hac-*.ts"],
|
||||
outdir: "dist",
|
||||
litelement: true,
|
||||
plugins: [
|
||||
packageReadme(),
|
||||
summaryAsDescription(),
|
||||
publicApiOnly(),
|
||||
distPaths(),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"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"
|
||||
],
|
||||
"customElements": "dist/custom-elements.json",
|
||||
"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 && cem analyze",
|
||||
"dev": "rslib build --watch",
|
||||
"prepublishOnly": "pnpm run build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"lit": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@custom-elements-manifest/analyzer": "0.11.0",
|
||||
"@rslib/core": "1.0.6",
|
||||
"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,8 @@
|
||||
import { define } from "../../utils/registration/define";
|
||||
import { HacCard } from "./hac-card";
|
||||
import { TAG_NAME } from "./info";
|
||||
|
||||
// Registers <hac-card> in the global registry, as used by Home Assistant
|
||||
define(TAG_NAME, HacCard);
|
||||
|
||||
export { HacCard };
|
||||
@@ -0,0 +1,165 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { property, state } from "lit/decorators.js";
|
||||
import {
|
||||
hasAssignedContent,
|
||||
hasAssignedElements,
|
||||
} from "../../utils/element/slot";
|
||||
|
||||
/**
|
||||
* @element hac-card
|
||||
*
|
||||
* @summary
|
||||
* Card that groups related content, with an optional header and footer.
|
||||
*
|
||||
* @slot header - Header of the card. Its spacing replaces the top padding of
|
||||
* the content.
|
||||
* @slot - Content of the card. Its padding is omitted when empty.
|
||||
* @slot footer - Footer of the card. Hidden when empty.
|
||||
*
|
||||
* @csspart header - Wrapper of the header slot.
|
||||
* @csspart content - Wrapper of the default slot.
|
||||
* @csspart footer - Wrapper of the footer slot.
|
||||
*
|
||||
* @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 and the footer divider.
|
||||
* @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. `outlined` draws a border without a shadow.
|
||||
* `raised` hides the border and lifts the card with a shadow instead.
|
||||
*/
|
||||
@property({ reflect: true }) public appearance: "outlined" | "raised" =
|
||||
"outlined";
|
||||
|
||||
@state() private _hasHeader = false;
|
||||
|
||||
@state() private _hasFooter = false;
|
||||
|
||||
@state() private _hasContent = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="header" part="header" ?hidden=${!this._hasHeader}>
|
||||
<slot name="header" @slotchange=${this._headerChanged}></slot>
|
||||
</div>
|
||||
<div
|
||||
class="content ${this._hasHeader ? "below-header" : ""}"
|
||||
part="content"
|
||||
?hidden=${!this._hasContent}
|
||||
>
|
||||
<slot @slotchange=${this._contentChanged}></slot>
|
||||
</div>
|
||||
<div class="footer" part="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);
|
||||
}
|
||||
|
||||
/*
|
||||
* Fallback order: --hac-card-*, then the theme variables of ha-card
|
||||
* (--ha-card-*, --card-background-color, --divider-color,
|
||||
* --primary-text-color) so existing themes keep working, then the semantic
|
||||
* tokens and their light values for use outside Home Assistant.
|
||||
* The ha-card variables are temporary, see "Theming" in the README.
|
||||
*/
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
background: var(
|
||||
--hac-card-background,
|
||||
var(
|
||||
--ha-card-background,
|
||||
var(--card-background-color, var(--ha-color-surface-default, #fff))
|
||||
)
|
||||
);
|
||||
backdrop-filter: var(--ha-card-backdrop-filter, none);
|
||||
color: var(
|
||||
--hac-card-color,
|
||||
var(--primary-text-color, var(--ha-color-on-surface-default, #141414))
|
||||
);
|
||||
border: var(--hac-card-border-width, var(--ha-card-border-width, 1px))
|
||||
solid
|
||||
var(
|
||||
--hac-card-border-color,
|
||||
var(
|
||||
--ha-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
)
|
||||
);
|
||||
border-radius: var(
|
||||
--hac-card-border-radius,
|
||||
var(--ha-card-border-radius, var(--ha-border-radius-lg, 12px))
|
||||
);
|
||||
box-shadow: var(--hac-card-box-shadow, var(--ha-card-box-shadow, none));
|
||||
}
|
||||
|
||||
:host([appearance="raised"]) {
|
||||
border-color: transparent;
|
||||
box-shadow: var(
|
||||
--hac-card-box-shadow,
|
||||
var(
|
||||
--ha-card-box-shadow,
|
||||
var(
|
||||
--ha-box-shadow-s,
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.08),
|
||||
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;
|
||||
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(
|
||||
--hac-card-border-color,
|
||||
var(--divider-color, var(--ha-color-border-neutral-quiet, #e6e6e6))
|
||||
);
|
||||
}
|
||||
|
||||
.header[hidden],
|
||||
.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,15 @@
|
||||
import type { ElementDefinition } from "../../utils/registration/definition";
|
||||
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: HacCard,
|
||||
};
|
||||
@@ -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,10 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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,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,26 +0,0 @@
|
||||
diff --git a/mwc-list-base.js b/mwc-list-base.js
|
||||
index 1ba95b6a01dcecea4d85b5cbbbcc3dfb04c40d5f..dced13fdb7929c490d6661b1bbe7e9f96dcd2285 100644
|
||||
--- a/mwc-list-base.js
|
||||
+++ b/mwc-list-base.js
|
||||
@@ -11,7 +11,7 @@ import { BaseElement } from '@material/mwc-base/base-element.js';
|
||||
import { observer } from '@material/mwc-base/observer.js';
|
||||
import { deepActiveElementPath, doesElementContainFocus, isNodeElement } from '@material/mwc-base/utils.js';
|
||||
import { html } from 'lit';
|
||||
-import { property, query, queryAssignedNodes } from 'lit/decorators.js';
|
||||
+import { property, query, queryAssignedElements } from 'lit/decorators.js';
|
||||
import { ifDefined } from 'lit/directives/if-defined.js';
|
||||
import MDCListFoundation, { isIndexSet } from './mwc-list-foundation.js';
|
||||
export { createSetFromIndex, isEventMulti, isIndexSet } from './mwc-list-foundation.js';
|
||||
@@ -425,10 +425,10 @@ __decorate([
|
||||
query('.mdc-deprecated-list')
|
||||
], ListBase.prototype, "mdcRoot", void 0);
|
||||
__decorate([
|
||||
- queryAssignedNodes('', true, '*')
|
||||
+ queryAssignedElements({ flatten: true, selector: "*" })
|
||||
], ListBase.prototype, "assignedElements", void 0);
|
||||
__decorate([
|
||||
- queryAssignedNodes('', true, '[tabindex="0"]')
|
||||
+ queryAssignedElements({ flatten: true, selector: '[tabindex="0"]' })
|
||||
], ListBase.prototype, "tabbableElements", void 0);
|
||||
__decorate([
|
||||
property({ type: Boolean }),
|
||||
Generated
+1632
-1278
File diff suppressed because it is too large.
Load diff
+5
-1
@@ -1,3 +1,7 @@
|
||||
packages:
|
||||
- "."
|
||||
- "packages/*"
|
||||
|
||||
savePrefix: ""
|
||||
autoInstallPeers: false
|
||||
|
||||
@@ -19,6 +23,7 @@ trustPolicyExclude:
|
||||
allowBuilds:
|
||||
"@tsparticles/engine": false
|
||||
core-js: false
|
||||
rs-module-lexer: false
|
||||
unrs-resolver: false
|
||||
|
||||
overrides:
|
||||
@@ -29,7 +34,6 @@ overrides:
|
||||
tslib: 2.9.1
|
||||
|
||||
patchedDependencies:
|
||||
"@material/[email protected]": patches/@[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
[email protected]: patches/[email protected]
|
||||
|
||||
@@ -89,6 +89,11 @@
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Keep semver ranges for peer dependencies of published packages, pinning them would force consumers onto one exact version",
|
||||
"matchDepTypes": ["peerDependencies"],
|
||||
"rangeStrategy": "widen"
|
||||
},
|
||||
{
|
||||
"description": "Group all Python version updates from home-assistant/core",
|
||||
"matchDepNames": ["python"],
|
||||
|
||||
@@ -3,6 +3,12 @@ import { ref } from "lit/directives/ref";
|
||||
|
||||
const MAX_ROWS = 2;
|
||||
|
||||
// A narrow list, as on a phone or a half width card, fits about one item per
|
||||
// row, so two rows hide most of the legend. 450px is about the widest list a
|
||||
// phone viewport gives.
|
||||
const NARROW_MAX_ROWS = 3;
|
||||
const NARROW_WIDTH = 450;
|
||||
|
||||
// How many items fit in maxRows of a wrapping row, leaving room for the
|
||||
// "more" item after them when not all of them fit.
|
||||
export const fitInRows = (
|
||||
@@ -34,9 +40,10 @@ export const fitInRows = (
|
||||
return widths.length;
|
||||
};
|
||||
|
||||
// Fits the items of a wrapping legend list into two rows. The list keeps its
|
||||
// "more" item rendered with the legend-more class, and items past the fit
|
||||
// stay rendered out of the flow, so both can always be measured.
|
||||
// Fits the items of a wrapping legend list into two rows, three when it is
|
||||
// narrow. The list keeps its "more" item rendered with the legend-more class,
|
||||
// and items past the fit stay rendered out of the flow, so both can always be
|
||||
// measured.
|
||||
export class LegendFitController {
|
||||
// Items to show while collapsed, undefined until the list is measured
|
||||
public fit?: number;
|
||||
@@ -118,7 +125,7 @@ export class LegendFitController {
|
||||
more?.getBoundingClientRect().width ?? 0,
|
||||
rowWidth,
|
||||
parseFloat(getComputedStyle(list).columnGap) || 0,
|
||||
MAX_ROWS
|
||||
rowWidth < NARROW_WIDTH ? NARROW_MAX_ROWS : MAX_ROWS
|
||||
);
|
||||
if (fit !== this.fit) {
|
||||
this.fit = fit;
|
||||
|
||||
@@ -163,9 +163,21 @@ export const findEntities = (
|
||||
return results;
|
||||
};
|
||||
|
||||
export const excludeEntities = (
|
||||
entityIds: string[],
|
||||
excludedEntityIds?: string[]
|
||||
): string[] => {
|
||||
if (!excludedEntityIds?.length) {
|
||||
return entityIds;
|
||||
}
|
||||
const excluded = new Set(excludedEntityIds);
|
||||
return entityIds.filter((entityId) => !excluded.has(entityId));
|
||||
};
|
||||
|
||||
export const findFilteredEntities = (
|
||||
sources: EntityFilterSources,
|
||||
filters: EntityFilter[]
|
||||
filters: EntityFilter[],
|
||||
excludedEntityIds?: string[]
|
||||
): string[] => {
|
||||
const filterFuncs = filters.map((filter) =>
|
||||
generateEntityFilter(
|
||||
@@ -177,5 +189,8 @@ export const findFilteredEntities = (
|
||||
filter
|
||||
)
|
||||
);
|
||||
return findEntities(Object.keys(sources.states), filterFuncs);
|
||||
return excludeEntities(
|
||||
findEntities(Object.keys(sources.states), filterFuncs),
|
||||
excludedEntityIds
|
||||
);
|
||||
};
|
||||
+13
-2
@@ -16,6 +16,13 @@ declare global {
|
||||
export interface NavigateOptions {
|
||||
replace?: boolean;
|
||||
data?: Record<string, unknown>;
|
||||
/**
|
||||
* Leave open dialogs open. For a replace that only canonicalizes the current
|
||||
* URL, like a router filling in its default page: a dialog restored from the
|
||||
* URL, such as a more-info deep link, may already be open at that point, and
|
||||
* the entry keeps its state so back still closes it.
|
||||
*/
|
||||
keepDialogs?: boolean;
|
||||
}
|
||||
|
||||
// max time to wait for dialogs to close before navigating
|
||||
@@ -196,7 +203,8 @@ const buildHistoryState = (
|
||||
};
|
||||
|
||||
const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
const canProceed = await ensureDialogsClosed(Date.now());
|
||||
const keepDialogs = options?.keepDialogs || false;
|
||||
const canProceed = await (keepDialogs || ensureDialogsClosed(Date.now()));
|
||||
if (!canProceed) {
|
||||
return false;
|
||||
}
|
||||
@@ -215,7 +223,10 @@ const performNavigation = async (path: string, options?: NavigateOptions) => {
|
||||
|
||||
const { history } = mainWindow;
|
||||
|
||||
if (replace) {
|
||||
if (replace && keepDialogs) {
|
||||
// Same entry under its canonical URL: its state, dialog included, stays.
|
||||
history.replaceState(history.state, "", path);
|
||||
} else if (replace) {
|
||||
// A replaced entry keeps its predecessor, so it keeps `from`.
|
||||
const { root, from } = history.state ?? {};
|
||||
const data = root ? { root: true } : options?.data;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
FloorAreasFilterValue,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ export const automationsFilterQueryParamConfig = {
|
||||
"assistants",
|
||||
"blueprint",
|
||||
],
|
||||
boolean: [{ key: "no_area", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type AutomationsFilterQueryParams = QueryParamValues<
|
||||
@@ -36,7 +38,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
): FloorAreasFilterValue | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const automationsFilterQueryParams = (
|
||||
@@ -44,6 +46,7 @@ export const automationsFilterQueryParams = (
|
||||
): AutomationsFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
entity: list(filters["ha-filter-entities"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
@@ -59,7 +62,7 @@ export const automationsFilterQueryString = (
|
||||
filters: DataTableFilters
|
||||
): string => {
|
||||
const params = automationsFilterQueryParams(filters);
|
||||
const sorted: AutomationsFilterQueryParams = {};
|
||||
const sorted: AutomationsFilterQueryParams = { ...params };
|
||||
|
||||
for (const key of automationsFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
@@ -94,8 +97,9 @@ export const automationsLinkFromUrl = (
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof AutomationsFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
const values = (
|
||||
key: (typeof automationsFilterQueryParamConfig.list)[number]
|
||||
): string[] => params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
@@ -109,7 +113,9 @@ export const automationsLinkFromUrl = (
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
floors.length || areas.length || params.no_area
|
||||
? { floors, areas, no_area: params.no_area }
|
||||
: undefined
|
||||
),
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
FloorAreasFilterValue,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ export const devicesFilterQueryParamConfig = {
|
||||
"config_entry",
|
||||
"sub_entry",
|
||||
],
|
||||
boolean: [{ key: "no_area", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type DevicesFilterQueryParams = QueryParamValues<
|
||||
@@ -35,7 +37,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
): FloorAreasFilterValue | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const devicesFilterQueryParams = (
|
||||
@@ -44,6 +46,7 @@ export const devicesFilterQueryParams = (
|
||||
status: list(filters["ha-filter-states"]),
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
|
||||
domain: list(filters["ha-filter-integrations"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
config_entry: list(filters.config_entry),
|
||||
@@ -55,7 +58,7 @@ export const devicesFilterQueryParams = (
|
||||
// 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 = {};
|
||||
const sorted: DevicesFilterQueryParams = { ...params };
|
||||
|
||||
for (const key of devicesFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
@@ -87,8 +90,9 @@ export const devicesLinkFromUrl = (
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof DevicesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
const values = (
|
||||
key: (typeof devicesFilterQueryParamConfig.list)[number]
|
||||
): string[] => params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
@@ -103,7 +107,9 @@ export const devicesLinkFromUrl = (
|
||||
filters: {
|
||||
"ha-filter-states": filter(values("status")),
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
floors.length || areas.length || params.no_area
|
||||
? { floors, areas, no_area: params.no_area }
|
||||
: undefined
|
||||
),
|
||||
"ha-filter-integrations": filter(values("domain")),
|
||||
"ha-filter-labels": filter(values("label")),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataTableFiltersValue,
|
||||
DataTableFiltersValues,
|
||||
FloorAreasFilterValue,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
@@ -30,6 +31,7 @@ export const entitiesFilterQueryParamConfig = {
|
||||
// Read until 2027.5, replaced with `assistants` on the next write
|
||||
"voice_assistant",
|
||||
],
|
||||
boolean: [{ key: "no_area", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type EntitiesFilterQueryParams = QueryParamValues<
|
||||
@@ -41,7 +43,7 @@ const list = (value: DataTableFiltersValue): string[] | undefined =>
|
||||
|
||||
const floorAreas = (
|
||||
value: DataTableFiltersValue
|
||||
): Record<string, string[]> | undefined =>
|
||||
): FloorAreasFilterValue | undefined =>
|
||||
value && !Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const entitiesFilterQueryParams = (
|
||||
@@ -50,6 +52,7 @@ export const entitiesFilterQueryParams = (
|
||||
status: list(filters["ha-filter-states"]),
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
device_class: list(filters["ha-filter-device-classes"]),
|
||||
domain: list(filters["ha-filter-integrations"]),
|
||||
@@ -67,7 +70,7 @@ export const entitiesFilterQueryString = (
|
||||
filters: DataTableFiltersValues
|
||||
): string => {
|
||||
const params = entitiesFilterQueryParams(filters);
|
||||
const sorted: EntitiesFilterQueryParams = {};
|
||||
const sorted: EntitiesFilterQueryParams = { ...params };
|
||||
|
||||
for (const key of entitiesFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
@@ -102,8 +105,9 @@ export const entitiesLinkFromUrl = (
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof EntitiesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
const values = (
|
||||
key: (typeof entitiesFilterQueryParamConfig.list)[number]
|
||||
): string[] => params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
@@ -114,7 +118,9 @@ export const entitiesLinkFromUrl = (
|
||||
filters: {
|
||||
"ha-filter-states": values("status"),
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
floors.length || areas.length || params.no_area
|
||||
? { floors, areas, no_area: params.no_area }
|
||||
: undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-device-classes": values("device_class"),
|
||||
"ha-filter-integrations": values("domain"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataTableFiltersValue,
|
||||
DataTableFiltersValues,
|
||||
FloorAreasFilterValue,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ export const helpersFilterQueryParamConfig = {
|
||||
// Read until 2027.5, replaced with `assistants` on the next write
|
||||
"voice_assistant",
|
||||
],
|
||||
boolean: [{ key: "no_area", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type HelpersFilterQueryParams = QueryParamValues<
|
||||
@@ -36,7 +38,7 @@ const list = (value: DataTableFiltersValue): string[] | undefined =>
|
||||
|
||||
const floorAreas = (
|
||||
value: DataTableFiltersValue
|
||||
): Record<string, string[]> | undefined =>
|
||||
): FloorAreasFilterValue | undefined =>
|
||||
value && !Array.isArray(value) ? value : undefined;
|
||||
|
||||
export const helpersFilterQueryParams = (
|
||||
@@ -44,6 +46,7 @@ export const helpersFilterQueryParams = (
|
||||
): HelpersFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
category: list(filters["ha-filter-categories"]),
|
||||
@@ -57,7 +60,7 @@ export const helpersFilterQueryString = (
|
||||
filters: DataTableFiltersValues
|
||||
): string => {
|
||||
const params = helpersFilterQueryParams(filters);
|
||||
const sorted: HelpersFilterQueryParams = {};
|
||||
const sorted: HelpersFilterQueryParams = { ...params };
|
||||
|
||||
for (const key of helpersFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
@@ -89,8 +92,9 @@ export const helpersLinkFromUrl = (
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof HelpersFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
const values = (
|
||||
key: (typeof helpersFilterQueryParamConfig.list)[number]
|
||||
): string[] => params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const floors = values("floor");
|
||||
const areas = values("area");
|
||||
@@ -100,7 +104,9 @@ export const helpersLinkFromUrl = (
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas":
|
||||
floors.length || areas.length ? { floors, areas } : undefined,
|
||||
floors.length || areas.length || params.no_area
|
||||
? { floors, areas, no_area: params.no_area }
|
||||
: undefined,
|
||||
"ha-filter-devices": values("device"),
|
||||
"ha-filter-labels": values("label"),
|
||||
"ha-filter-categories": values("category"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
FloorAreasFilterValue,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ export const scenesFilterQueryParamConfig = {
|
||||
"category",
|
||||
"assistants",
|
||||
],
|
||||
boolean: [{ key: "no_area", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type ScenesFilterQueryParams = QueryParamValues<
|
||||
@@ -35,7 +37,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
): FloorAreasFilterValue | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const scenesFilterQueryParams = (
|
||||
@@ -43,6 +45,7 @@ export const scenesFilterQueryParams = (
|
||||
): ScenesFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
entity: list(filters["ha-filter-entities"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
@@ -55,7 +58,7 @@ export const scenesFilterQueryParams = (
|
||||
// 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 = {};
|
||||
const sorted: ScenesFilterQueryParams = { ...params };
|
||||
|
||||
for (const key of scenesFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
@@ -87,8 +90,9 @@ export const scenesLinkFromUrl = (
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof ScenesFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
const values = (
|
||||
key: (typeof scenesFilterQueryParamConfig.list)[number]
|
||||
): string[] => params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
@@ -102,7 +106,9 @@ export const scenesLinkFromUrl = (
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
floors.length || areas.length || params.no_area
|
||||
? { floors, areas, no_area: params.no_area }
|
||||
: undefined
|
||||
),
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DataTableFilter,
|
||||
DataTableFilters,
|
||||
FloorAreasFilterValue,
|
||||
} from "../../data/data_table_filters";
|
||||
import { dataTableQueryParamConfig } from "./data-table-query-params";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ export const scriptsFilterQueryParamConfig = {
|
||||
"assistants",
|
||||
"blueprint",
|
||||
],
|
||||
boolean: [{ key: "no_area", trueValue: "1" }],
|
||||
} as const satisfies QueryParamConfig;
|
||||
|
||||
export type ScriptsFilterQueryParams = QueryParamValues<
|
||||
@@ -36,7 +38,7 @@ const list = (filter: DataTableFilter | undefined): string[] | undefined =>
|
||||
|
||||
const floorAreas = (
|
||||
filter: DataTableFilter | undefined
|
||||
): Record<string, string[]> | undefined =>
|
||||
): FloorAreasFilterValue | undefined =>
|
||||
filter?.value && !Array.isArray(filter.value) ? filter.value : undefined;
|
||||
|
||||
export const scriptsFilterQueryParams = (
|
||||
@@ -44,6 +46,7 @@ export const scriptsFilterQueryParams = (
|
||||
): ScriptsFilterQueryParams => ({
|
||||
floor: floorAreas(filters["ha-filter-floor-areas"])?.floors,
|
||||
area: floorAreas(filters["ha-filter-floor-areas"])?.areas,
|
||||
no_area: floorAreas(filters["ha-filter-floor-areas"])?.no_area,
|
||||
device: list(filters["ha-filter-devices"]),
|
||||
entity: list(filters["ha-filter-entities"]),
|
||||
label: list(filters["ha-filter-labels"]),
|
||||
@@ -57,7 +60,7 @@ export const scriptsFilterQueryParams = (
|
||||
// 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 = {};
|
||||
const sorted: ScriptsFilterQueryParams = { ...params };
|
||||
|
||||
for (const key of scriptsFilterQueryParamConfig.list) {
|
||||
sorted[key] = params[key] && [...params[key]].sort();
|
||||
@@ -89,8 +92,9 @@ export const scriptsLinkFromUrl = (
|
||||
|
||||
const { search } = decodeQueryParams(searchParams, dataTableQueryParamConfig);
|
||||
|
||||
const values = (key: keyof ScriptsFilterQueryParams): string[] =>
|
||||
params[key]?.filter(Boolean) ?? [];
|
||||
const values = (
|
||||
key: (typeof scriptsFilterQueryParamConfig.list)[number]
|
||||
): string[] => params[key]?.filter(Boolean) ?? [];
|
||||
|
||||
const filter = (value: DataTableFilter["value"]): DataTableFilter => ({
|
||||
value,
|
||||
@@ -104,7 +108,9 @@ export const scriptsLinkFromUrl = (
|
||||
search: search ?? "",
|
||||
filters: {
|
||||
"ha-filter-floor-areas": filter(
|
||||
floors.length || areas.length ? { floors, areas } : undefined
|
||||
floors.length || areas.length || params.no_area
|
||||
? { floors, areas, no_area: params.no_area }
|
||||
: undefined
|
||||
),
|
||||
"ha-filter-devices": filter(values("device")),
|
||||
"ha-filter-entities": filter(values("entity")),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/** Where a value is in a config, e.g. `["actions", 0, "then", 1]`. */
|
||||
export type ConfigPath = (string | number)[];
|
||||
|
||||
export const stringifyPath = (path: ConfigPath): string => path.join("/");
|
||||
|
||||
export const pathEquals = (a: ConfigPath, b: ConfigPath): boolean =>
|
||||
a.length === b.length && a.every((segment, index) => segment === b[index]);
|
||||
@@ -7,10 +7,7 @@ import {
|
||||
mdiRestart,
|
||||
} from "@mdi/js";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import type {
|
||||
DataZoomComponentOption,
|
||||
MarkAreaComponentOption,
|
||||
} from "echarts/components";
|
||||
import type { DataZoomComponentOption } from "echarts/components";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type {
|
||||
ECElementEvent,
|
||||
@@ -93,29 +90,14 @@ export const DEFAULT_CHART_WIDTH = 500;
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const CLICK_DRAG_TOLERANCE = 4;
|
||||
// The selection is shown by the mask, not by the brush
|
||||
const BRUSH_STYLE = {
|
||||
color: "rgba(0, 0, 0, 0)",
|
||||
borderColor: "rgba(0, 0, 0, 0)",
|
||||
};
|
||||
|
||||
export type ZoomSliderMode = "auto" | "always" | "never";
|
||||
|
||||
export type ChartSelectMode = "none" | "point" | "range";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
"highlight",
|
||||
] as const;
|
||||
const MASK_OPACITY = 0.7;
|
||||
|
||||
export interface ChartTimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -194,18 +176,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
|
||||
public growForZoomSlider = false;
|
||||
|
||||
// "point" selects on click, "range" also selects on drag instead of zooming
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
// Everything outside this time range fades
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
// Rounds a selection to the periods of the data, or drops it
|
||||
@property({ attribute: false }) public snapSelection?: (
|
||||
range: ChartTimeRange
|
||||
) => ChartTimeRange | undefined;
|
||||
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
|
||||
@@ -231,12 +201,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
private _pointerDownX?: number;
|
||||
|
||||
private _selectingRange?: ChartTimeRange;
|
||||
|
||||
private _maskColor = "";
|
||||
|
||||
// Separate from _isZoomed, whose touch handling reruns graph layouts
|
||||
@state() private _isGraphRoamed = false;
|
||||
|
||||
@@ -490,9 +454,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("selectMode")) {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
|
||||
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
|
||||
return;
|
||||
@@ -538,8 +499,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
) {
|
||||
this._disposeSonification();
|
||||
}
|
||||
} else if (changedProps.has("highlight")) {
|
||||
this._updateMask();
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
@@ -929,9 +888,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
|
||||
const style = getComputedStyle(this);
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
this._maskColor =
|
||||
style.getPropertyValue("--card-background-color").trim() ||
|
||||
style.getPropertyValue("--primary-background-color").trim();
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this._dragToZoom = false;
|
||||
@@ -1014,9 +970,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._legendItemCount = this._getLegendItems()?.length;
|
||||
this._updateSankeyRoam();
|
||||
this._updateDragToZoom();
|
||||
if (this.selectMode !== "none") {
|
||||
this._updateSelectMode();
|
||||
}
|
||||
if (zoomStart !== 0 || zoomEnd !== 100) {
|
||||
this.zoom(zoomStart, zoomEnd, true);
|
||||
}
|
||||
@@ -1068,10 +1021,7 @@ 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() &&
|
||||
this.selectMode !== "range";
|
||||
const active = !this._touchInput && this._hasZoomableXAxis();
|
||||
if (!this.chart || active === this._dragToZoom) {
|
||||
return;
|
||||
}
|
||||
@@ -1083,126 +1033,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _snapRange(range: ChartTimeRange) {
|
||||
return this.snapSelection ? this.snapSelection(range) : range;
|
||||
}
|
||||
|
||||
private _getMaskArea(): MarkAreaComponentOption | undefined {
|
||||
const range = this._selectingRange ?? this.highlight;
|
||||
if (!range) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
silent: true,
|
||||
z: 10,
|
||||
itemStyle: {
|
||||
color: this._maskColor,
|
||||
opacity: MASK_OPACITY,
|
||||
},
|
||||
data: [
|
||||
[{ xAxis: -Infinity }, { xAxis: range.start }],
|
||||
[{ xAxis: range.end }, { xAxis: Infinity }],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Only the first series carries the mask, merged in without resending data
|
||||
private _updateMask() {
|
||||
this.chart?.setOption({
|
||||
series: [{ markArea: this._getMaskArea() ?? { data: [] } }],
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSelectPointerDown = (e: ECElementEvent) => {
|
||||
this._pointerDownX = e.offsetX;
|
||||
};
|
||||
|
||||
private _handleSelectClick = (e: ECElementEvent) => {
|
||||
const point = [e.offsetX, e.offsetY];
|
||||
// A drag that ends on the plot is not a click
|
||||
const dragged =
|
||||
this._pointerDownX !== undefined &&
|
||||
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
|
||||
if (dragged || !this.chart?.containPixel("grid", point)) {
|
||||
return;
|
||||
}
|
||||
const [x] = this.chart.convertFromPixel("grid", point) as number[];
|
||||
const selection = this._snapRange({ start: x, end: x });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrush = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
if (range) {
|
||||
this._selectingRange = this._snapRange({
|
||||
start: range[0],
|
||||
end: range[1],
|
||||
});
|
||||
this._updateMask();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleBrushEnd = (e: any) => {
|
||||
const range = e.areas?.[0]?.coordRange as number[] | undefined;
|
||||
this.chart?.dispatchAction({ type: "brush", areas: [] });
|
||||
this._selectingRange = undefined;
|
||||
this._updateMask();
|
||||
const selection =
|
||||
range && this._snapRange({ start: range[0], end: range[1] });
|
||||
if (selection) {
|
||||
fireEvent(this, "chart-select", selection);
|
||||
}
|
||||
};
|
||||
|
||||
private _updateSelectMode() {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
const zr = this.chart.getZr();
|
||||
zr.off("mousedown", this._handleSelectPointerDown);
|
||||
zr.off("click", this._handleSelectClick);
|
||||
this.chart.off("brush", this._handleBrush);
|
||||
this.chart.off("brushEnd", this._handleBrushEnd);
|
||||
if (this.selectMode !== "none") {
|
||||
zr.on("mousedown", this._handleSelectPointerDown);
|
||||
zr.on("click", this._handleSelectClick);
|
||||
}
|
||||
const selectRange = this.selectMode === "range";
|
||||
if (selectRange) {
|
||||
this.chart.on("brush", this._handleBrush);
|
||||
this.chart.on("brushEnd", this._handleBrushEnd);
|
||||
this.chart.setOption({
|
||||
brush: {
|
||||
xAxisIndex: 0,
|
||||
// Only measures the range, the mask fades what is outside
|
||||
seriesIndex: [],
|
||||
transformable: false,
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
});
|
||||
}
|
||||
const brushCursor = {
|
||||
type: "takeGlobalCursor",
|
||||
key: "brush",
|
||||
brushOption: {
|
||||
brushType: selectRange ? "lineX" : false,
|
||||
brushMode: "single",
|
||||
brushStyle: BRUSH_STYLE,
|
||||
},
|
||||
};
|
||||
// Brush and drag to zoom share the global cursor, the last one taken wins
|
||||
if (selectRange) {
|
||||
this._updateDragToZoom();
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
} else {
|
||||
this.chart.dispatchAction(brushCursor);
|
||||
this._dragToZoom = false;
|
||||
this._updateDragToZoom();
|
||||
}
|
||||
}
|
||||
|
||||
private _hasZoomableXAxis() {
|
||||
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
|
||||
XAXisOption | undefined;
|
||||
@@ -1645,8 +1475,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
private _getSeries() {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
XAXisOption | undefined;
|
||||
const maskArea = this._getMaskArea();
|
||||
const series = ensureArray(this.data).map((s, index) => {
|
||||
const series = ensureArray(this.data).map((s) => {
|
||||
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
|
||||
? undefined
|
||||
: s.data;
|
||||
@@ -1684,16 +1513,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
}
|
||||
// ECharts merges series by id and would keep a mask left out here
|
||||
if (s.type === "line" || s.type === "bar") {
|
||||
result = {
|
||||
...result,
|
||||
markArea:
|
||||
index === 0 && maskArea
|
||||
? maskArea
|
||||
: ((s as LineSeriesOption).markArea ?? { data: [] }),
|
||||
} as HaECSeriesItem;
|
||||
}
|
||||
return processSeriesTooltipFormatter(result);
|
||||
});
|
||||
return series as ECOption["series"];
|
||||
@@ -1943,7 +1762,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
// it, so a quick drag out would lose the release and its selection
|
||||
if (
|
||||
ev.type === "pointerdown" &&
|
||||
(this._dragToZoom || this.selectMode === "range") &&
|
||||
this._dragToZoom &&
|
||||
this._isZoomPress(ev)
|
||||
) {
|
||||
this.chart
|
||||
@@ -2518,7 +2337,6 @@ declare global {
|
||||
"dataset-hidden": { id: string };
|
||||
"dataset-unhidden": { id: string };
|
||||
"chart-click": ECElementEvent;
|
||||
"chart-select": ChartTimeRange;
|
||||
"legend-label-click": { id: string };
|
||||
"chart-zoom": {
|
||||
start: number;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
@@ -49,13 +49,7 @@ import type {
|
||||
HomeAssistantUI,
|
||||
} from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type {
|
||||
ChartSelectMode,
|
||||
ChartTimeRange,
|
||||
CustomLegendOption,
|
||||
HaChartBase,
|
||||
ZoomSliderMode,
|
||||
} from "./ha-chart-base";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
@@ -134,16 +128,6 @@ export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
@property({ attribute: "select-mode" })
|
||||
public selectMode: ChartSelectMode = "none";
|
||||
|
||||
@property({ attribute: "zoom-slider" })
|
||||
public zoomSlider: ZoomSliderMode = "auto";
|
||||
|
||||
@property({ attribute: false }) public highlight?: ChartTimeRange;
|
||||
|
||||
@query("ha-chart-base") private _chartBase?: HaChartBase;
|
||||
|
||||
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
|
||||
@state() private _legendData: CustomLegendOption["data"];
|
||||
@@ -260,12 +244,8 @@ export class StatisticsChart extends LitElement {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
.data=${this._chartData}
|
||||
.highlight=${this.highlight}
|
||||
.snapSelection=${this._snapSelection}
|
||||
.options=${this._chartOptions}
|
||||
.height=${this.height}
|
||||
.selectMode=${this.selectMode}
|
||||
.zoomSlider=${this.zoomSlider}
|
||||
style=${styleMap({ height: this.height })}
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
@@ -279,34 +259,6 @@ export class StatisticsChart extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public resetZoom() {
|
||||
this._chartBase?.zoom(0, 100);
|
||||
}
|
||||
|
||||
// A click selects the period that contains it, a drag every period it
|
||||
// touches
|
||||
private _snapSelection = (
|
||||
range: ChartTimeRange
|
||||
): ChartTimeRange | undefined => {
|
||||
let start: number | undefined;
|
||||
let end: number | undefined;
|
||||
Object.values(this.statisticsData ?? {}).forEach((values) =>
|
||||
values.forEach((value) => {
|
||||
const touched =
|
||||
range.start === range.end
|
||||
? value.start <= range.start && range.start < value.end
|
||||
: value.start < range.end && value.end > range.start;
|
||||
if (touched) {
|
||||
start = Math.min(start ?? value.start, value.start);
|
||||
end = Math.max(end ?? value.end, value.end);
|
||||
}
|
||||
})
|
||||
);
|
||||
return start === undefined || end === undefined
|
||||
? undefined
|
||||
: { start, end };
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
|
||||
this._hiddenStats.add(ev.detail.id);
|
||||
this.requestUpdate("_hiddenStats");
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { join } from "lit/directives/join";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
@@ -124,16 +125,16 @@ export interface DataTableRowData {
|
||||
|
||||
export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
|
||||
// Slot for the actions at the end of a group's header, they don't toggle the
|
||||
// group. Put `ha-icon-button`s with a label in it. They get the rows' icon
|
||||
// Renders the actions at the end of a group's header, they don't toggle the
|
||||
// group. Return `ha-icon-button`s with a label. They get the rows' icon
|
||||
// colour and size by default, which `--data-table-group-actions-color`,
|
||||
// `--data-table-group-actions-button-size` and
|
||||
// `--data-table-group-actions-icon-size` change for all of them; style a
|
||||
// button itself for its own colour or size.
|
||||
export const GROUP_ACTIONS_SLOT_PREFIX = "group-actions-";
|
||||
|
||||
export const groupActionsSlot = (group: string) =>
|
||||
`${GROUP_ACTIONS_SLOT_PREFIX}${group}`;
|
||||
// button itself for its own colour or size. Not a slot per group, as WebKit
|
||||
// is slow to add and remove slots while the rows scroll.
|
||||
export type DataTableGroupActions = (
|
||||
group: string
|
||||
) => TemplateResult | typeof nothing;
|
||||
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
|
||||
@@ -197,6 +198,9 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public groupOrder?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public renderGroupActions?: DataTableGroupActions;
|
||||
|
||||
@property({ attribute: false }) public sortColumn?: string;
|
||||
|
||||
@property({ attribute: false }) public sortDirection: SortingDirection = null;
|
||||
@@ -397,9 +401,13 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
if (properties.has("data")) {
|
||||
// Clean up checked rows that no longer exist in the data
|
||||
// Clean up checked rows that no longer exist or can no longer be selected
|
||||
if (this._checkedRows.length) {
|
||||
const validIds = new Set(this.data.map((row) => String(row[this.id])));
|
||||
const validIds = new Set(
|
||||
this.data
|
||||
.filter((row) => row.selectable !== false)
|
||||
.map((row) => String(row[this.id]))
|
||||
);
|
||||
const validCheckedRows = this._checkedRows.filter((id) =>
|
||||
validIds.has(id)
|
||||
);
|
||||
@@ -506,7 +514,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -759,8 +768,6 @@ export class HaDataTable extends LitElement {
|
||||
`
|
||||
: html`
|
||||
${
|
||||
// Rendered before the rows, so the group's actions slot
|
||||
// shows here and not in its scrolled out header row
|
||||
this.groupColumn &&
|
||||
!this.autoHeight &&
|
||||
this._stickyGroup !== undefined
|
||||
@@ -775,11 +782,15 @@ export class HaDataTable extends LitElement {
|
||||
.group=${this._stickyGroup}
|
||||
@click=${this._collapseStickyGroup}
|
||||
>
|
||||
${this._renderGroupHeader(
|
||||
${keyed(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
true
|
||||
this._renderGroupHeader(
|
||||
this._stickyGroup,
|
||||
this._collapsedGroups.includes(this._stickyGroup),
|
||||
this._i18n?.localize,
|
||||
this.renderGroupActions,
|
||||
true
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
: nothing
|
||||
@@ -803,7 +814,14 @@ export class HaDataTable extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (row: DataTableRowData) => row?.[this.id] || row;
|
||||
// Group headers are keyed by their group, so they keep their actions' state
|
||||
// when the data changes. The prefixes keep them apart from row ids.
|
||||
private _keyFunction = (row: DataTableRowData) =>
|
||||
row?.[this.id]
|
||||
? `row-${row[this.id]}`
|
||||
: row?.groupHeader !== undefined
|
||||
? `group-${row.groupHeader}`
|
||||
: row;
|
||||
|
||||
private _renderMainValue(value: unknown) {
|
||||
return this.clickable
|
||||
@@ -1063,7 +1081,8 @@ export class HaDataTable extends LitElement {
|
||||
groupOrder: string[] | undefined,
|
||||
collapsedGroups: string[],
|
||||
sortColumn: string | undefined,
|
||||
sortDirection: SortingDirection
|
||||
sortDirection: SortingDirection,
|
||||
renderGroupActions: DataTableGroupActions | undefined
|
||||
) => {
|
||||
if (appendRow || groupColumn) {
|
||||
let items = [...data];
|
||||
@@ -1122,7 +1141,8 @@ export class HaDataTable extends LitElement {
|
||||
content: this._renderGroupHeader(
|
||||
groupName,
|
||||
collapsedGroups.includes(groupName),
|
||||
localize
|
||||
localize,
|
||||
renderGroupActions
|
||||
),
|
||||
});
|
||||
if (!collapsedGroups.includes(groupName)) {
|
||||
@@ -1148,6 +1168,7 @@ export class HaDataTable extends LitElement {
|
||||
groupName: string,
|
||||
collapsed: boolean,
|
||||
localize: LocalizeFunc | undefined,
|
||||
renderGroupActions: DataTableGroupActions | undefined,
|
||||
sticky = false
|
||||
) {
|
||||
// The sticky copy's button and name are hidden from assistive technology,
|
||||
@@ -1183,7 +1204,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
</span>
|
||||
<div class="group-actions" @click=${this._stopGroupToggle}>
|
||||
<slot name=${groupActionsSlot(groupName)}></slot>
|
||||
${renderGroupActions?.(groupName)}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -1264,7 +1285,8 @@ export class HaDataTable extends LitElement {
|
||||
this.groupOrder,
|
||||
this._collapsedGroups,
|
||||
this.sortColumn,
|
||||
this.sortDirection
|
||||
this.sortDirection,
|
||||
this.renderGroupActions
|
||||
);
|
||||
|
||||
if (
|
||||
@@ -1961,7 +1983,7 @@ export class HaDataTable extends LitElement {
|
||||
align-self: stretch;
|
||||
margin-top: -12px;
|
||||
margin-inline-start: auto;
|
||||
/* The rows' icon button size, inherited by the slotted ones */
|
||||
/* The rows' icon button size, inherited by the actions */
|
||||
--_button-size: var(--data-table-group-actions-button-size, 48px);
|
||||
--ha-icon-button-size: var(--_button-size);
|
||||
--mdc-icon-size: var(--data-table-group-actions-icon-size, 24px);
|
||||
@@ -1974,9 +1996,9 @@ export class HaDataTable extends LitElement {
|
||||
position: sticky;
|
||||
inset-inline-end: 0;
|
||||
cursor: default;
|
||||
/* Don't pass the group name's weight on to slotted menus */
|
||||
/* Don't pass the group name's weight on to the actions */
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
/* Inherited by the slotted icon buttons, like the rows' icon cells */
|
||||
/* Inherited by the actions, like the rows' icon cells */
|
||||
color: var(
|
||||
--data-table-group-actions-color,
|
||||
var(--secondary-text-color)
|
||||
|
||||
@@ -97,6 +97,8 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
|
||||
@state() private _dateValue?: string;
|
||||
|
||||
@state() private _rangeIncomplete = false;
|
||||
|
||||
@state() private _timeValue = {
|
||||
from: { hours: 0, minutes: 0 },
|
||||
to: { hours: 23, minutes: 59 },
|
||||
@@ -195,6 +197,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
.locale=${this._i18n.locale.language}
|
||||
.focusedDate=${this._focusDate}
|
||||
@focusday=${this._focusChanged}
|
||||
@rangestart=${this._handleRangeStart}
|
||||
@change=${this._handleChange}
|
||||
show-outside-days
|
||||
.firstDayOfWeek=${firstWeekdayIndex(this._i18n.locale)}
|
||||
@@ -253,7 +256,9 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
<ha-button appearance="plain" @click=${this._cancel}
|
||||
>${this._i18n.localize("ui.common.cancel")}</ha-button
|
||||
>
|
||||
<ha-button .disabled=${!this._dateValue} @click=${this._save}
|
||||
<ha-button
|
||||
.disabled=${!this._dateValue || this._rangeIncomplete}
|
||||
@click=${this._save}
|
||||
>${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select"
|
||||
)}</ha-button
|
||||
@@ -280,7 +285,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _save() {
|
||||
if (!this._dateValue) {
|
||||
if (!this._dateValue || this._rangeIncomplete) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,9 +336,21 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
|
||||
this._focusDate = ev.target.focusedDate;
|
||||
}
|
||||
|
||||
private _handleRangeStart(
|
||||
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
|
||||
) {
|
||||
this._rangeIncomplete = matchMedia("(hover: hover)").matches;
|
||||
if (this._rangeIncomplete) {
|
||||
return;
|
||||
}
|
||||
const day = ev.target.tentative;
|
||||
this._dateValue = `${day}/${day}`;
|
||||
}
|
||||
|
||||
private _handleChange(
|
||||
ev: HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
|
||||
) {
|
||||
this._rangeIncomplete = false;
|
||||
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
|
||||
this._dateValue = dateElement.value;
|
||||
this._focusDate = dateElement.focusedDate;
|
||||
|
||||
@@ -107,9 +107,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public ranges?: DateRangePickerRanges | false;
|
||||
|
||||
// Added after the default presets
|
||||
@property({ attribute: false }) public extraRanges?: DateRangePickerRanges;
|
||||
|
||||
@state() private _ranges?: DateRangePickerRanges;
|
||||
|
||||
@property({ attribute: "time-picker", type: Boolean })
|
||||
@@ -169,8 +166,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
if (
|
||||
changedProps.has("_i18n") ||
|
||||
changedProps.has("_hassConfig") ||
|
||||
changedProps.has("extendedPresets") ||
|
||||
changedProps.has("extraRanges")
|
||||
changedProps.has("extendedPresets")
|
||||
) {
|
||||
const rangeKeys = this.extendedPresets
|
||||
? [...RANGE_KEYS, ...EXTENDED_RANGE_KEYS]
|
||||
@@ -182,7 +178,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._i18n.localize(`ui.components.date-range-picker.ranges.${key}`)
|
||||
] = calcDateRange(this._i18n.locale, this._hassConfig, key);
|
||||
});
|
||||
this._ranges = { ...ranges, ...this.extraRanges };
|
||||
this._ranges = ranges;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,7 @@ export class HaControlSlider extends LitElement {
|
||||
this.pressed = false;
|
||||
this._hideTooltip();
|
||||
this.value = savedValue;
|
||||
fireEvent(this, "slider-moved", { value: undefined });
|
||||
});
|
||||
this._mc.on("panmove", (e) => {
|
||||
if (this.disabled) return;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "../common/decorators/consume";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { getDeviceAreaId } from "../common/entity/context/get_device_context";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
@@ -17,11 +19,16 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { AreaRegistryEntry } from "../data/area/area_registry";
|
||||
import type { FloorAreasFilterValue } from "../data/data_table_filters";
|
||||
import {
|
||||
apiContext,
|
||||
areasContext,
|
||||
devicesContext,
|
||||
floorsContext,
|
||||
fullEntitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../data/context";
|
||||
import { getFloorAreaLookup } from "../data/floor_registry";
|
||||
import type { RelatedResult } from "../data/search";
|
||||
@@ -33,6 +40,7 @@ import "./ha-icon";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./item/ha-list-item-base";
|
||||
import "./item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
@@ -59,10 +67,27 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
@consume({ context: apiContext, subscribe: true })
|
||||
private _api!: ContextType<typeof apiContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: {
|
||||
floors?: string[];
|
||||
areas?: string[];
|
||||
};
|
||||
@consume({ context: devicesContext, subscribe: true })
|
||||
@state()
|
||||
private _devices!: ContextType<typeof devicesContext>;
|
||||
|
||||
@consume({ context: fullEntitiesContext, subscribe: true })
|
||||
@state()
|
||||
private _entities?: ContextType<typeof fullEntitiesContext>;
|
||||
|
||||
// Only a state being added or removed can change the state-only entities,
|
||||
// so plain state updates keep the previous set and don't rerender
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@transform<ContextType<typeof statesContext>, Set<string>>({
|
||||
transformer: function (this: HaFilterFloorAreas, states) {
|
||||
const ids = new Set(Object.keys(states));
|
||||
return deepEqual(ids, this._stateIds) ? this._stateIds : ids;
|
||||
},
|
||||
})
|
||||
private _stateIds?: Set<string>;
|
||||
|
||||
@property({ attribute: false }) public value?: FloorAreasFilterValue;
|
||||
|
||||
@property() public type?: keyof RelatedResult;
|
||||
|
||||
@@ -81,12 +106,17 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
private _relatedRequestId = 0;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
public willUpdate(properties: PropertyValues) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
if (
|
||||
properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value"))
|
||||
(properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value"))) ||
|
||||
// Items without an area are worked out from the registries
|
||||
(this.value?.no_area &&
|
||||
(properties.has("_devices") ||
|
||||
properties.has("_entities") ||
|
||||
(this.type !== "device" && properties.has("_stateIds"))))
|
||||
) {
|
||||
this._findRelated();
|
||||
}
|
||||
@@ -94,6 +124,12 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
protected render() {
|
||||
const areas = this._areas(this._areasReg, this._floorsReg);
|
||||
const groupOtherAreas =
|
||||
!!areas.floors.length && !!areas.unassisgnedAreas.length;
|
||||
const count =
|
||||
(this.value?.areas?.length || 0) +
|
||||
(this.value?.floors?.length || 0) +
|
||||
(this.value?.no_area ? 1 : 0);
|
||||
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
@@ -104,13 +140,8 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.panel.config.areas.caption")}
|
||||
${
|
||||
this.value?.areas?.length || this.value?.floors?.length
|
||||
? html`<div class="badge">
|
||||
${
|
||||
(this.value?.areas?.length || 0) +
|
||||
(this.value?.floors?.length || 0)
|
||||
}
|
||||
</div>
|
||||
count
|
||||
? html`<div class="badge">${count}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
@@ -131,6 +162,17 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
aria-label=${this._localize("ui.panel.config.areas.caption")}
|
||||
>
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${"no_area"}
|
||||
.type=${"no_area"}
|
||||
.selected=${this.value?.no_area || false}
|
||||
>
|
||||
<span slot="headline">
|
||||
${this._localize("ui.components.area-picker.no_area")}
|
||||
</span>
|
||||
</ha-list-item-option>
|
||||
${repeat(
|
||||
areas?.floors || [],
|
||||
(floor) => floor.floor_id,
|
||||
@@ -162,10 +204,31 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
)}
|
||||
`
|
||||
)}
|
||||
${
|
||||
groupOtherAreas
|
||||
? html`<ha-list-item-base role="presentation">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">
|
||||
${this._localize(
|
||||
"ui.panel.config.areas.picker.other_areas"
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item-base>`
|
||||
: nothing
|
||||
}
|
||||
${repeat(
|
||||
areas?.unassisgnedAreas,
|
||||
(area) => area.area_id,
|
||||
(area) => this._renderArea(area)
|
||||
areas.unassisgnedAreas,
|
||||
(area, index) =>
|
||||
`${area.area_id}${index === areas.unassisgnedAreas.length - 1 ? "___last" : ""}`,
|
||||
(area, index) =>
|
||||
this._renderArea(
|
||||
area,
|
||||
index === areas.unassisgnedAreas.length - 1,
|
||||
groupOtherAreas
|
||||
)
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
</div>
|
||||
@@ -175,9 +238,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderArea(area, last = false) {
|
||||
const hasFloor = !!area.floor_id;
|
||||
|
||||
private _renderArea(
|
||||
area: AreaRegistryEntry,
|
||||
last = false,
|
||||
nested = !!area.floor_id
|
||||
) {
|
||||
return html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
@@ -190,11 +255,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
),
|
||||
floor: hasFloor,
|
||||
floor: nested,
|
||||
})}
|
||||
>
|
||||
${
|
||||
hasFloor
|
||||
nested
|
||||
? html`<ha-tree-indicator
|
||||
slot="start"
|
||||
.end=${last}
|
||||
@@ -227,6 +292,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (addedItem.type === "no_area") {
|
||||
this.value = { ...this.value, no_area: true };
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[addedItem.type]: [
|
||||
@@ -249,6 +319,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (removedItem.type === "no_area") {
|
||||
this.value = { ...this.value, no_area: false };
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[removedItem.type]: this.value![removedItem.type].filter(
|
||||
@@ -289,7 +364,9 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
if (
|
||||
!this.value ||
|
||||
(!this.value.areas?.length && !this.value.floors?.length)
|
||||
(!this.value.areas?.length &&
|
||||
!this.value.floors?.length &&
|
||||
!this.value.no_area)
|
||||
) {
|
||||
this.value = {};
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
@@ -332,6 +409,9 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
}
|
||||
|
||||
const items = new Set<string>();
|
||||
if (this.value.no_area) {
|
||||
this._itemsWithoutArea().forEach((item) => items.add(item));
|
||||
}
|
||||
for (const result of results) {
|
||||
if (result[this.type!]) {
|
||||
result[this.type!]!.forEach((item) => items.add(item));
|
||||
@@ -344,6 +424,39 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _itemsWithoutArea(): string[] {
|
||||
if (this.type === "device") {
|
||||
return Object.values(this._devices)
|
||||
.filter((device) => !getDeviceAreaId(device, this._devices))
|
||||
.map((device) => device.id);
|
||||
}
|
||||
// Matches the area column of each table: the entities and helpers tables
|
||||
// give an entity without its own area the area of its device, the
|
||||
// automations, scripts and scenes tables only show the entity's own area
|
||||
const useDeviceArea = this.type === "entity";
|
||||
const entities = this._entities || [];
|
||||
const items = entities
|
||||
.filter(
|
||||
(entity) =>
|
||||
(this.includeDisabledEntities || !entity.disabled_by) &&
|
||||
!entity.area_id &&
|
||||
!(
|
||||
useDeviceArea &&
|
||||
entity.device_id &&
|
||||
this._devices[entity.device_id]?.area_id
|
||||
)
|
||||
)
|
||||
.map((entity) => entity.entity_id);
|
||||
// Entities without a registry entry can't have an area
|
||||
const registered = new Set(entities.map((entity) => entity.entity_id));
|
||||
for (const entityId of this._stateIds || []) {
|
||||
if (!registered.has(entityId)) {
|
||||
items.push(entityId);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private _handleClearFilterKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import type {
|
||||
HaFormCondition,
|
||||
HaFormDataContainer,
|
||||
@@ -87,26 +88,38 @@ export const combineVisible = (
|
||||
return conditions;
|
||||
};
|
||||
|
||||
const getConditionFields = (visible: HaFormVisible | undefined): string[] =>
|
||||
visible === undefined || typeof visible === "boolean"
|
||||
? []
|
||||
: ensureArray(visible).flatMap((condition) =>
|
||||
"condition" in condition
|
||||
? condition.conditions.flatMap((sub) => getConditionFields(sub))
|
||||
: [condition.field]
|
||||
);
|
||||
|
||||
// Hiding a field drops its value, which can flip another field's condition, so
|
||||
// resolve the set to a fixpoint. Fields are given in order as name and visible.
|
||||
// a field is evaluated after the fields its condition depends on.
|
||||
export const getHiddenFields = (
|
||||
fields: readonly (readonly [string, HaFormVisible | undefined])[],
|
||||
data: HaFormDataContainer | undefined
|
||||
): Set<string> => {
|
||||
const hidden = new Set<string>();
|
||||
const conditions = new Map(fields);
|
||||
const evalData: HaFormDataContainer = { ...(data ?? {}) };
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [name, visible] of fields) {
|
||||
if (hidden.has(name) || isVisible(visible, evalData)) {
|
||||
continue;
|
||||
}
|
||||
const hidden = new Set<string>();
|
||||
const resolved = new Set<string>();
|
||||
const resolve = (name: string) => {
|
||||
if (resolved.has(name) || !conditions.has(name)) {
|
||||
return;
|
||||
}
|
||||
resolved.add(name);
|
||||
const visible = conditions.get(name);
|
||||
getConditionFields(visible).forEach(resolve);
|
||||
if (!isVisible(visible, evalData)) {
|
||||
hidden.add(name);
|
||||
delete evalData[name];
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
fields.forEach(([name]) => resolve(name));
|
||||
return hidden;
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,30 @@ import type {
|
||||
HaFormIntegerSchema,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* The value the slider shows: the data, or for a required field without data
|
||||
* its suggested value, default or minimum.
|
||||
*/
|
||||
export const computeIntegerSliderValue = (
|
||||
schema: HaFormIntegerSchema,
|
||||
data?: HaFormIntegerData
|
||||
): number => {
|
||||
if (data !== undefined) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!schema.required) {
|
||||
return schema.valueMin || 0;
|
||||
}
|
||||
|
||||
return (
|
||||
(schema.description?.suggested_value as HaFormIntegerData | undefined) ??
|
||||
schema.default ??
|
||||
schema.valueMin ??
|
||||
0
|
||||
);
|
||||
};
|
||||
|
||||
@customElement("ha-form-integer")
|
||||
export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
@@ -79,7 +103,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
}
|
||||
<ha-slider
|
||||
labeled
|
||||
.value=${this._value}
|
||||
.value=${computeIntegerSliderValue(this.schema, this.data)}
|
||||
.min=${this.schema.valueMin}
|
||||
.max=${this.schema.valueMax}
|
||||
.disabled=${
|
||||
@@ -136,24 +160,6 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
}
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
if (this.data !== undefined) {
|
||||
return this.data;
|
||||
}
|
||||
|
||||
if (!this.schema.required) {
|
||||
return this.schema.valueMin || 0;
|
||||
}
|
||||
|
||||
return (
|
||||
(this.schema.description?.suggested_value !== undefined &&
|
||||
this.schema.description?.suggested_value !== null) ||
|
||||
this.schema.default ||
|
||||
this.schema.valueMin ||
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
private _handleCheckboxChange(ev: HASSDomTargetEvent<HaCheckbox>) {
|
||||
const checked = (ev.target as HaCheckbox).checked;
|
||||
let value: HaFormIntegerData | undefined;
|
||||
|
||||
@@ -214,15 +214,19 @@ class HaHsColorPicker extends LitElement {
|
||||
this._mc.add(new Tap({ event: "singletap" }));
|
||||
|
||||
let savedPosition;
|
||||
let savedValue: [number, number] | undefined;
|
||||
this._mc.on("panstart", (e) => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = e.pointerType;
|
||||
savedPosition = this._cursorPosition;
|
||||
savedValue = this._localValue;
|
||||
});
|
||||
this._mc.on("pancancel", () => {
|
||||
if (this.disabled) return;
|
||||
this._pressed = undefined;
|
||||
this._cursorPosition = savedPosition;
|
||||
this._localValue = savedValue;
|
||||
fireEvent(this, "cursor-moved", { value: undefined });
|
||||
});
|
||||
this._mc.on("panmove", (e) => {
|
||||
if (this.disabled) return;
|
||||
@@ -283,13 +287,14 @@ class HaHsColorPicker extends LitElement {
|
||||
const x = e.center.x;
|
||||
const y = e.center.y;
|
||||
const boundingRect = e.target.getBoundingClientRect();
|
||||
const offsetX = boundingRect.left;
|
||||
const offsetY = boundingRect.top;
|
||||
const maxX = e.target.clientWidth;
|
||||
const maxY = e.target.clientHeight;
|
||||
// The wheel is drawn as a centered square (object-fit: contain), but the
|
||||
// canvas box itself is not always square (e.g. wider in Safari).
|
||||
const size = Math.min(boundingRect.width, boundingRect.height);
|
||||
const centerX = boundingRect.left + boundingRect.width / 2;
|
||||
const centerY = boundingRect.top + boundingRect.height / 2;
|
||||
|
||||
const _x = (2 * (x - offsetX)) / maxX - 1;
|
||||
const _y = (2 * (y - offsetY)) / maxY - 1;
|
||||
const _x = (2 * (x - centerX)) / size;
|
||||
const _y = (2 * (y - centerY)) / size;
|
||||
|
||||
const [r, phi] = xy2polar(_x, _y);
|
||||
const [xx, yy] = polar2xy(Math.min(1, r), phi);
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { ListItemBase } from "@material/mwc-list/mwc-list-item-base";
|
||||
import { styles } from "@material/mwc-list/mwc-list-item.css";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-list-item")
|
||||
export class HaListItem extends ListItemBase {
|
||||
protected renderRipple() {
|
||||
if (this.noninteractive) {
|
||||
return "";
|
||||
}
|
||||
return super.renderRipple();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
padding-left: var(
|
||||
--mdc-list-side-padding-left,
|
||||
var(--mdc-list-side-padding, 20px)
|
||||
);
|
||||
padding-inline-start: var(
|
||||
--mdc-list-side-padding-left,
|
||||
var(--mdc-list-side-padding, 20px)
|
||||
);
|
||||
padding-right: var(
|
||||
--mdc-list-side-padding-right,
|
||||
var(--mdc-list-side-padding, 20px)
|
||||
);
|
||||
padding-inline-end: var(
|
||||
--mdc-list-side-padding-right,
|
||||
var(--mdc-list-side-padding, 20px)
|
||||
);
|
||||
}
|
||||
:host([graphic="avatar"]:not([twoLine])),
|
||||
:host([graphic="icon"]:not([twoLine])) {
|
||||
height: 48px;
|
||||
}
|
||||
span.material-icons:first-of-type {
|
||||
margin-inline-start: 0px !important;
|
||||
margin-inline-end: var(
|
||||
--mdc-list-item-graphic-margin,
|
||||
16px
|
||||
) !important;
|
||||
direction: var(--direction) !important;
|
||||
}
|
||||
span.material-icons:last-of-type {
|
||||
margin-inline-start: auto !important;
|
||||
margin-inline-end: 0px !important;
|
||||
direction: var(--direction) !important;
|
||||
}
|
||||
.mdc-deprecated-list-item__meta {
|
||||
display: var(--mdc-list-item-meta-display);
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
:host([graphic="icon"]:not([twoline]))
|
||||
.mdc-deprecated-list-item__graphic {
|
||||
margin-inline-end: var(
|
||||
--mdc-list-item-graphic-margin,
|
||||
20px
|
||||
) !important;
|
||||
}
|
||||
:host([multiline-secondary]) {
|
||||
height: auto;
|
||||
}
|
||||
:host([multiline-secondary]) .mdc-deprecated-list-item__text {
|
||||
padding: 8px 0;
|
||||
}
|
||||
:host([multiline-secondary]) .mdc-deprecated-list-item__secondary-text {
|
||||
text-overflow: initial;
|
||||
white-space: normal;
|
||||
overflow: auto;
|
||||
display: inline-block;
|
||||
margin-top: 10px;
|
||||
}
|
||||
:host([multiline-secondary]) .mdc-deprecated-list-item__primary-text {
|
||||
margin-top: 10px;
|
||||
}
|
||||
:host([multiline-secondary])
|
||||
.mdc-deprecated-list-item__secondary-text::before {
|
||||
display: none;
|
||||
}
|
||||
:host([multiline-secondary])
|
||||
.mdc-deprecated-list-item__primary-text::before {
|
||||
display: none;
|
||||
}
|
||||
:host([disabled]) {
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
:host([noninteractive]) {
|
||||
pointer-events: unset;
|
||||
}
|
||||
`,
|
||||
// safari workaround - must be explicit
|
||||
document.dir === "rtl"
|
||||
? css`
|
||||
span.material-icons:first-of-type,
|
||||
span.material-icons:last-of-type {
|
||||
direction: rtl !important;
|
||||
--direction: rtl;
|
||||
}
|
||||
`
|
||||
: css``,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-item": HaListItem;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { ListBase } from "@material/mwc-list/mwc-list-base";
|
||||
import { styles } from "@material/mwc-list/mwc-list.css";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-list")
|
||||
export class HaList extends ListBase {
|
||||
static styles = styles;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list": HaList;
|
||||
}
|
||||
}
|
||||
@@ -219,6 +219,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (this._searchFieldElement) {
|
||||
this._searchFieldElement.value = value;
|
||||
}
|
||||
this._applySearch(value);
|
||||
}
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
@@ -691,7 +692,11 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
private _filterChanged = (ev: InputEvent) => {
|
||||
const textfield = ev.target as HaInputSearch;
|
||||
const searchString = (textfield.value ?? "").trim();
|
||||
this._applySearch(textfield.value ?? "");
|
||||
};
|
||||
|
||||
private _applySearch(value: string) {
|
||||
const searchString = value.trim();
|
||||
this._search = searchString;
|
||||
|
||||
if (this.sections?.length) {
|
||||
@@ -736,7 +741,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
this._resetSelectedItem();
|
||||
this._resetListScroll();
|
||||
};
|
||||
}
|
||||
|
||||
// Keep focus in the search field, so a focus change doesn't scroll the
|
||||
// list to the active row and make the click miss.
|
||||
|
||||
@@ -21,6 +21,8 @@ export class HaActionSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public value?: Action;
|
||||
|
||||
@property() public name?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
@@ -51,6 +53,7 @@ export class HaActionSelector extends LitElement {
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-automation-action
|
||||
.pathKey=${this.name}
|
||||
.disabled=${this.disabled}
|
||||
.actions=${this._actions(this.value)}
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -11,6 +11,22 @@ import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import "../ha-button-toggle-group";
|
||||
import "./ha-selector";
|
||||
|
||||
/**
|
||||
* A copy of the value with each choice's value under its key. A plain value,
|
||||
* like a duration without active_choice, belongs to the active choice.
|
||||
*/
|
||||
export const computeChooseValues = (
|
||||
value: any,
|
||||
activeChoice: string
|
||||
): Record<string, any> => {
|
||||
if (value === null || value === undefined) {
|
||||
return {};
|
||||
}
|
||||
return typeof value === "object" && "active_choice" in value
|
||||
? { ...value }
|
||||
: { [activeChoice]: value };
|
||||
};
|
||||
|
||||
@customElement("ha-selector-choose")
|
||||
export class HaChooseSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -109,12 +125,7 @@ export class HaChooseSelector extends LitElement {
|
||||
|
||||
private _choiceChanged(ev) {
|
||||
ev.stopPropagation();
|
||||
const value =
|
||||
typeof this.value === "object"
|
||||
? this.value
|
||||
: {
|
||||
[this._activeChoice!]: this.value,
|
||||
};
|
||||
const value = computeChooseValues(this.value, this._activeChoice!);
|
||||
const choice = ev.detail?.value || ev.target.value;
|
||||
this._activeChoice = choice;
|
||||
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
|
||||
@@ -131,7 +142,7 @@ export class HaChooseSelector extends LitElement {
|
||||
|
||||
private _handleValueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = typeof this.value === "object" ? this.value : {};
|
||||
const value = computeChooseValues(this.value, this._activeChoice!);
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...value,
|
||||
|
||||
@@ -16,6 +16,8 @@ export class HaConditionSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public value?: Condition;
|
||||
|
||||
@property() public name?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
@@ -27,6 +29,7 @@ export class HaConditionSelector extends LitElement {
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-automation-condition
|
||||
.pathKey=${this.name}
|
||||
.disabled=${this.disabled}
|
||||
.conditions=${this.value || []}
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -278,6 +278,13 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _getActiveChoice(entry: ThresholdValueEntry | undefined) {
|
||||
return (
|
||||
entry?.active_choice ??
|
||||
(entry?.entity !== undefined ? "entity" : "number")
|
||||
);
|
||||
}
|
||||
|
||||
private _renderValueRow(
|
||||
rowLabel: string,
|
||||
entry: ThresholdValueEntry | undefined,
|
||||
@@ -287,7 +294,7 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
unitOptions: readonly string[] | undefined,
|
||||
choiceToggleButtons: { label: string; value: string }[]
|
||||
) {
|
||||
const activeChoice = entry?.active_choice ?? "number";
|
||||
const activeChoice = this._getActiveChoice(entry);
|
||||
const isEntity = activeChoice === "entity";
|
||||
const showUnit = !isEntity && !!unitOptions && unitOptions.length > 1;
|
||||
const innerValue = isEntity ? entry?.entity : entry?.number;
|
||||
@@ -409,7 +416,7 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
ev: CustomEvent
|
||||
) {
|
||||
ev.stopPropagation();
|
||||
const activeChoice = this.value?.[field]?.active_choice ?? "number";
|
||||
const activeChoice = this._getActiveChoice(this.value?.[field]);
|
||||
const defaultUnit = this._getUnitOptions()?.[0];
|
||||
const entry: ThresholdValueEntry = {
|
||||
...this.value?.[field],
|
||||
@@ -450,7 +457,7 @@ export class HaNumericThresholdSelector extends LitElement {
|
||||
) {
|
||||
const unit = ev.detail?.value;
|
||||
if (unit === this.value?.[field]?.unit_of_measurement) return;
|
||||
const activeChoice = this.value?.[field]?.active_choice ?? "number";
|
||||
const activeChoice = this._getActiveChoice(this.value?.[field]);
|
||||
const defaultType = field === "value" ? "above" : "between";
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
|
||||
@@ -17,6 +17,8 @@ export class HaTriggerSelector extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public value?: Trigger;
|
||||
|
||||
@property() public name?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
@@ -32,6 +34,7 @@ export class HaTriggerSelector extends LitElement {
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-automation-trigger
|
||||
.pathKey=${this.name}
|
||||
.disabled=${this.disabled}
|
||||
.triggers=${this._triggers(this.value)}
|
||||
.hass=${this.hass}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import { grid } from "@lit-labs/virtualizer/layouts/grid";
|
||||
import type { ContextType } from "@lit/context";
|
||||
|
||||
import {
|
||||
mdiArrowUpRight,
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { consume } from "../../common/decorators/consume";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -56,7 +55,6 @@ import {
|
||||
} from "../../data/media_source";
|
||||
import { isTTSMediaSource } from "../../data/tts";
|
||||
import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
|
||||
import { getWsErrorMessage } from "../../util/ws-error";
|
||||
import { panelIsReady } from "../../layouts/panel-ready";
|
||||
import { haStyle, haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
@@ -65,6 +63,7 @@ import type {
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../types";
|
||||
import { documentationUrlForVersion } from "../../util/documentation-url";
|
||||
import { getWsErrorMessage } from "../../util/ws-error";
|
||||
import "../entity/ha-entity-picker";
|
||||
import "../ha-alert";
|
||||
import "../ha-button";
|
||||
@@ -73,18 +72,22 @@ import "../ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../ha-dropdown";
|
||||
import "../ha-dropdown-item";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-list";
|
||||
import "../ha-list-item";
|
||||
import "../input/ha-input-search";
|
||||
import type { HaInputSearch } from "../input/ha-input-search";
|
||||
import "./ha-media-browser-thumbnail";
|
||||
import "../ha-spinner";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tooltip";
|
||||
import "../input/ha-input-search";
|
||||
import type { HaInputSearch } from "../input/ha-input-search";
|
||||
import { HaListItemButton } from "../item/ha-list-item-button";
|
||||
import "../list/ha-list-virtualized";
|
||||
import type {
|
||||
HaListVirtualized,
|
||||
HaListVirtualizedItem,
|
||||
} from "../list/ha-list-virtualized";
|
||||
import "./ha-browse-media-manual";
|
||||
import type { ManualMediaPickedEvent } from "./ha-browse-media-manual";
|
||||
import "./ha-browse-media-tts";
|
||||
import type { TtsMediaPickedEvent } from "./ha-browse-media-tts";
|
||||
import "./ha-media-browser-thumbnail";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -107,6 +110,115 @@ export interface MediaPlayerItemId {
|
||||
|
||||
type MediaClass = MediaPlayerItem["media_class"];
|
||||
|
||||
type MediaBrowseListRow =
|
||||
| (HaListVirtualizedItem & {
|
||||
type: "item";
|
||||
item: MediaPlayerItem;
|
||||
})
|
||||
| (HaListVirtualizedItem & {
|
||||
type: "not-shown";
|
||||
count: number;
|
||||
});
|
||||
|
||||
@customElement("ha-media-player-browse-list-item")
|
||||
export class HaMediaPlayerBrowseListItem extends HaListItemButton {
|
||||
@property() public thumbnail?: string;
|
||||
|
||||
// The "not shown" row is plain text, not a button
|
||||
protected override _renderBase(inner: TemplateResult): TemplateResult {
|
||||
if (!this.interactive) {
|
||||
return html`<div part="base" class="base" id="item">${inner}</div>`;
|
||||
}
|
||||
return super._renderBase(inner);
|
||||
}
|
||||
|
||||
// Always reserve the graphic, so titles line up across rows
|
||||
protected override _renderStart(): TemplateResult {
|
||||
return html`<div part="start" class="start">
|
||||
<div class="graphic">
|
||||
${
|
||||
this.thumbnail
|
||||
? html`<ha-media-browser-thumbnail
|
||||
.url=${this.thumbnail}
|
||||
></ha-media-browser-thumbnail>`
|
||||
: nothing
|
||||
}
|
||||
<slot name="start"></slot>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemButton.styles,
|
||||
css`
|
||||
:host {
|
||||
direction: var(--direction);
|
||||
--ha-row-item-padding-block: 0;
|
||||
--ha-row-item-min-height: 48px;
|
||||
}
|
||||
|
||||
:host(.show-images) {
|
||||
--ha-row-item-padding-block: var(--ha-space-1);
|
||||
--ha-row-item-min-height: 64px;
|
||||
}
|
||||
|
||||
:host(.not-shown) {
|
||||
font-style: italic;
|
||||
color: var(--secondary-text-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
::slotted(ha-svg-icon) {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.graphic {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:host(.show-images) .graphic {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
ha-media-browser-thumbnail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
::slotted(.play) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
background-color: rgba(var(--rgb-card-background-color), 0.5);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
|
||||
:host(:hover) ::slotted(.play) {
|
||||
opacity: 1;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
::slotted(.play.show) {
|
||||
opacity: 1;
|
||||
background-color: transparent;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
/** Folders with at least this many items get a filter field when their source cannot search. */
|
||||
const FILTER_MIN_CHILDREN = 10;
|
||||
|
||||
@@ -195,6 +307,10 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
@query("lit-virtualizer") private _virtualizer?: LitVirtualizer;
|
||||
|
||||
@query("ha-list-virtualized") private _list?: HaListVirtualized;
|
||||
|
||||
private _listScroller?: HTMLElement;
|
||||
|
||||
private _observed = false;
|
||||
|
||||
private _headerOffsetHeight = 0;
|
||||
@@ -205,6 +321,9 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._observeListScroll();
|
||||
}
|
||||
this.updateComplete.then(() => this._attachResizeObserver());
|
||||
}
|
||||
|
||||
@@ -213,6 +332,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
if (this._resizeObserver) {
|
||||
this._resizeObserver.disconnect();
|
||||
}
|
||||
this._listScroller?.removeEventListener("scroll", this._listScrolled);
|
||||
this._listScroller = undefined;
|
||||
}
|
||||
|
||||
public async refresh() {
|
||||
@@ -398,6 +519,8 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
protected updated(changedProps: PropertyValues): void {
|
||||
super.updated(changedProps);
|
||||
|
||||
this._observeListScroll();
|
||||
|
||||
if (changedProps.has("_scrolled") || changedProps.has("_currentItem")) {
|
||||
// Re-measure across frames rather than once: the search input sizes
|
||||
// asynchronously, so a single measurement can be too small and the
|
||||
@@ -508,7 +631,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
)
|
||||
: [];
|
||||
|
||||
const mediaClass = MediaClassBrowserSettings[currentItem.media_class];
|
||||
const childrenMediaClass = currentItem.children_media_class
|
||||
? MediaClassBrowserSettings[currentItem.children_media_class]
|
||||
: MediaClassBrowserSettings.directory;
|
||||
@@ -517,6 +639,21 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
currentItem?.can_play ||
|
||||
(currentItem && this.accept?.includes("directory"));
|
||||
|
||||
const listRows: MediaBrowseListRow[] = children.map((item, index) => ({
|
||||
id: `${index}:${item.media_content_id}`,
|
||||
type: "item",
|
||||
interactive: true,
|
||||
item,
|
||||
}));
|
||||
if (notShown) {
|
||||
listRows.push({
|
||||
id: "not-shown",
|
||||
type: "not-shown",
|
||||
interactive: false,
|
||||
count: notShown,
|
||||
});
|
||||
}
|
||||
|
||||
return html`
|
||||
${
|
||||
canPickCurrent || showSearch || showFilter
|
||||
@@ -736,39 +873,13 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
}
|
||||
`
|
||||
: html`
|
||||
<ha-list>
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
.items=${children}
|
||||
style=${styleMap({
|
||||
height: `${children.length * 72 + 26}px`,
|
||||
})}
|
||||
class="ha-scrollbar"
|
||||
.renderItem=${this._renderListItem}
|
||||
></lit-virtualizer>
|
||||
${
|
||||
notShown
|
||||
? html`
|
||||
<ha-list-item
|
||||
noninteractive
|
||||
class="not-shown"
|
||||
.graphic=${
|
||||
mediaClass.show_list_images
|
||||
? "medium"
|
||||
: "avatar"
|
||||
}
|
||||
>
|
||||
<span class="title">
|
||||
${this._i18n.localize(
|
||||
"ui.components.media-browser.not_shown",
|
||||
{ count: notShown }
|
||||
)}
|
||||
</span>
|
||||
</ha-list-item>
|
||||
`
|
||||
: ""
|
||||
<ha-list-virtualized
|
||||
.rows=${listRows}
|
||||
.rowRenderer=${this._renderListItem}
|
||||
@ha-list-visibility-changed=${
|
||||
this._observeListScroll
|
||||
}
|
||||
</ha-list>
|
||||
></ha-list-virtualized>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
@@ -910,6 +1021,7 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
private _handleFilterInput(ev: InputEvent): void {
|
||||
this._filterQuery = (ev.target as HaInputSearch).value ?? "";
|
||||
this._virtualizer?.scrollTo(0, 0);
|
||||
this._list?.scrollElement?.scrollTo(0, 0);
|
||||
this._content?.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
@@ -1067,63 +1179,64 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _renderListItem = (child: MediaPlayerItem): TemplateResult => {
|
||||
const currentItem = this._currentItem;
|
||||
const mediaClass = MediaClassBrowserSettings[currentItem!.media_class];
|
||||
const showImage = mediaClass.show_list_images && !!child.thumbnail;
|
||||
private _renderListItem = (row: HaListVirtualizedItem): TemplateResult => {
|
||||
const listRow = row as MediaBrowseListRow;
|
||||
const mediaClass =
|
||||
MediaClassBrowserSettings[this._currentItem!.media_class];
|
||||
const showImages = mediaClass.show_list_images === true;
|
||||
|
||||
if (listRow.type === "not-shown") {
|
||||
return html`
|
||||
<ha-media-player-browse-list-item
|
||||
class=${classMap({ "not-shown": true, "show-images": showImages })}
|
||||
.interactive=${false}
|
||||
>
|
||||
<span slot="headline" class="title">
|
||||
${this._i18n.localize("ui.components.media-browser.not_shown", {
|
||||
count: listRow.count,
|
||||
})}
|
||||
</span>
|
||||
</ha-media-player-browse-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
const child = listRow.item;
|
||||
const showImage = showImages && !!child.thumbnail;
|
||||
|
||||
return html`
|
||||
<ha-list-item
|
||||
<ha-media-player-browse-list-item
|
||||
@click=${this._childClicked}
|
||||
.item=${child}
|
||||
.graphic=${mediaClass.show_list_images ? "medium" : "avatar"}
|
||||
class=${classMap({ "show-images": showImages })}
|
||||
.thumbnail=${showImage ? child.thumbnail : undefined}
|
||||
>
|
||||
${
|
||||
!showImage && !child.can_play
|
||||
? html`<ha-svg-icon
|
||||
.path=${
|
||||
MediaClassBrowserSettings[
|
||||
child.media_class === "directory"
|
||||
? child.children_media_class || child.media_class
|
||||
: child.media_class
|
||||
].icon
|
||||
}
|
||||
slot="graphic"
|
||||
></ha-svg-icon>`
|
||||
: html`<div
|
||||
class=${classMap({
|
||||
graphic: true,
|
||||
thumbnail: mediaClass.show_list_images === true,
|
||||
})}
|
||||
slot="graphic"
|
||||
>
|
||||
${
|
||||
showImage
|
||||
? html`<ha-media-browser-thumbnail
|
||||
.url=${child.thumbnail}
|
||||
></ha-media-browser-thumbnail>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
child.can_play
|
||||
? html`<ha-icon-button
|
||||
class="play ${classMap({
|
||||
show:
|
||||
!mediaClass.show_list_images || !child.thumbnail,
|
||||
})}"
|
||||
.item=${child}
|
||||
.label=${this._i18n.localize(
|
||||
`ui.components.media-browser.${this.action}-media`
|
||||
)}
|
||||
.path=${this.action === "play" ? mdiPlay : mdiPlus}
|
||||
@click=${this._actionClicked}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
child.can_play
|
||||
? html`<ha-icon-button
|
||||
slot="start"
|
||||
class=${classMap({ play: true, show: !showImage })}
|
||||
.item=${child}
|
||||
.label=${this._i18n.localize(
|
||||
`ui.components.media-browser.${this.action}-media`
|
||||
)}
|
||||
.path=${this.action === "play" ? mdiPlay : mdiPlus}
|
||||
@click=${this._actionClicked}
|
||||
></ha-icon-button>`
|
||||
: showImage
|
||||
? nothing
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${
|
||||
MediaClassBrowserSettings[
|
||||
child.media_class === "directory"
|
||||
? child.children_media_class || child.media_class
|
||||
: child.media_class
|
||||
].icon
|
||||
}
|
||||
></ha-svg-icon>`
|
||||
}
|
||||
<span class="title">${child.title}</span>
|
||||
</ha-list-item>
|
||||
<span slot="headline" class="title">${child.title}</span>
|
||||
</ha-media-player-browse-list-item>
|
||||
`;
|
||||
};
|
||||
|
||||
@@ -1316,9 +1429,31 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
// The list scrolls inside its virtualizer, which only exists once the list
|
||||
// has rendered its rows
|
||||
private _observeListScroll(): void {
|
||||
const scroller = this._list?.scrollElement;
|
||||
if (scroller === this._listScroller) {
|
||||
return;
|
||||
}
|
||||
this._listScroller?.removeEventListener("scroll", this._listScrolled);
|
||||
scroller?.addEventListener("scroll", this._listScrolled, {
|
||||
passive: true,
|
||||
});
|
||||
this._listScroller = scroller;
|
||||
// The new scroller starts at the top, like the grid after the list
|
||||
if (!scroller) {
|
||||
this.scrolled = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _listScrolled = (ev: Event): void => {
|
||||
this._scroll(ev);
|
||||
};
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _scroll(ev: Event): void {
|
||||
const content = ev.currentTarget as HTMLDivElement;
|
||||
const content = this._listScroller ?? (ev.currentTarget as HTMLElement);
|
||||
if (!this.scrolled && content.scrollTop > this._headerOffsetHeight) {
|
||||
this.scrolled = true;
|
||||
} else if (this.scrolled && content.scrollTop < this._headerOffsetHeight) {
|
||||
@@ -1529,23 +1664,14 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
|
||||
/* ============= CHILDREN ============= */
|
||||
|
||||
ha-list {
|
||||
--mdc-list-vertical-padding: 0;
|
||||
--mdc-list-item-graphic-margin: 0;
|
||||
--mdc-theme-text-icon-on-background: var(--secondary-text-color);
|
||||
ha-list-virtualized {
|
||||
height: calc(100% - 10px);
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
ha-list li:last-child {
|
||||
display: none;
|
||||
}
|
||||
|
||||
ha-list li[divider] {
|
||||
border-bottom-color: var(--divider-color);
|
||||
}
|
||||
|
||||
ha-list-item {
|
||||
width: 100%;
|
||||
ha-list-virtualized::part(base) {
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
div.children {
|
||||
@@ -1680,45 +1806,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
ha-list-item .graphic {
|
||||
position: relative;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
overflow: hidden;
|
||||
line-height: initial;
|
||||
}
|
||||
|
||||
ha-list-item .graphic ha-media-browser-thumbnail {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
ha-list-item .graphic .play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
opacity: 0;
|
||||
transition: all 0.5s;
|
||||
background-color: rgba(var(--rgb-card-background-color), 0.5);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
--ha-icon-button-size: 40px;
|
||||
}
|
||||
|
||||
ha-list-item:hover .graphic .play {
|
||||
opacity: 1;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
ha-list-item .graphic .play.show {
|
||||
opacity: 1;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
ha-list-item .title {
|
||||
margin-left: 16px;
|
||||
margin-inline-start: 16px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
|
||||
/* ============= Narrow ============= */
|
||||
|
||||
:host([narrow]) {
|
||||
@@ -1858,5 +1945,6 @@ export class HaMediaPlayerBrowse extends LitElement {
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-media-player-browse": HaMediaPlayerBrowse;
|
||||
"ha-media-player-browse-list-item": HaMediaPlayerBrowseListItem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { ConfigEntry } from "./config_entries";
|
||||
import type { DataEntryFlowProgress } from "./data_entry_flow";
|
||||
import type { EntityRegistryEntry } from "./entity/entity_registry";
|
||||
import type { IntegrationSetup } from "./integration";
|
||||
import type { RepairsIssue } from "./repairs";
|
||||
import type { LoggedError } from "./system_log";
|
||||
import { getLoggedErrorIntegration } from "./system_log";
|
||||
|
||||
export type HealthStatus = "ok" | "warning" | "error" | "info";
|
||||
|
||||
export type OverallHealthStatus = Exclude<HealthStatus, "info">;
|
||||
|
||||
export interface ConfigEntryHealth {
|
||||
status: OverallHealthStatus;
|
||||
state: HealthStatus;
|
||||
entities: {
|
||||
status: HealthStatus;
|
||||
total: number;
|
||||
unavailable: EntityRegistryEntry[];
|
||||
};
|
||||
repairs: { status: HealthStatus; issues: RepairsIssue[] };
|
||||
logs: { status: HealthStatus; errors: number; warnings: number };
|
||||
flows: { status: HealthStatus; flows: DataEntryFlowProgress[] };
|
||||
setupSeconds?: number;
|
||||
}
|
||||
|
||||
const ATTENTION_SOURCES = ["reauth", "reconfigure"];
|
||||
|
||||
const STATE_STATUS: Record<ConfigEntry["state"], HealthStatus> = {
|
||||
loaded: "ok",
|
||||
setup_in_progress: "info",
|
||||
unload_in_progress: "info",
|
||||
not_loaded: "info",
|
||||
setup_retry: "warning",
|
||||
setup_error: "error",
|
||||
migration_error: "error",
|
||||
failed_unload: "error",
|
||||
};
|
||||
|
||||
const worst = (statuses: HealthStatus[]): OverallHealthStatus =>
|
||||
statuses.includes("error")
|
||||
? "error"
|
||||
: statuses.includes("warning")
|
||||
? "warning"
|
||||
: "ok";
|
||||
|
||||
export const computeConfigEntryHealth = (
|
||||
entry: ConfigEntry,
|
||||
entities: EntityRegistryEntry[],
|
||||
states: HassEntities,
|
||||
issues: RepairsIssue[],
|
||||
log: LoggedError[],
|
||||
flows: DataEntryFlowProgress[],
|
||||
setups: IntegrationSetup[],
|
||||
// Library loggers from the manifest, so dependency errors count too.
|
||||
loggers: string[] = []
|
||||
): ConfigEntryHealth => {
|
||||
const enabled = entities.filter((entity) => !entity.disabled_by);
|
||||
// Entities without a state yet (e.g. while the entry is not loaded) count as unavailable.
|
||||
const unavailable = enabled.filter(
|
||||
(entity) =>
|
||||
!states[entity.entity_id] ||
|
||||
states[entity.entity_id].state === "unavailable"
|
||||
);
|
||||
const entryIssues = issues.filter(
|
||||
(issue) =>
|
||||
!issue.ignored && (issue.issue_domain ?? issue.domain) === entry.domain
|
||||
);
|
||||
const entryLog = log.filter(
|
||||
(item) =>
|
||||
getLoggedErrorIntegration(item) === entry.domain ||
|
||||
loggers.some(
|
||||
(logger) => item.name === logger || item.name.startsWith(`${logger}.`)
|
||||
)
|
||||
);
|
||||
const sumCount = (levels: LoggedError["level"][]) =>
|
||||
entryLog
|
||||
.filter((item) => levels.includes(item.level))
|
||||
.reduce((total, item) => total + item.count, 0);
|
||||
const errors = sumCount(["error", "critical"]);
|
||||
const warnings = sumCount(["warning"]);
|
||||
const entryFlows = flows.filter(
|
||||
(flow) =>
|
||||
flow.handler === entry.domain &&
|
||||
flow.context.entry_id === entry.entry_id &&
|
||||
ATTENTION_SOURCES.includes(flow.context.source)
|
||||
);
|
||||
|
||||
// A disabled entry that failed to unload still needs a restart.
|
||||
const state: HealthStatus =
|
||||
entry.disabled_by && entry.state !== "failed_unload"
|
||||
? "info"
|
||||
: STATE_STATUS[entry.state];
|
||||
const health: ConfigEntryHealth = {
|
||||
status: "ok",
|
||||
state,
|
||||
entities: {
|
||||
status: entry.disabled_by || !unavailable.length ? "ok" : "warning",
|
||||
total: enabled.length,
|
||||
unavailable,
|
||||
},
|
||||
repairs: {
|
||||
status: entryIssues.some((issue) => issue.severity !== "warning")
|
||||
? "error"
|
||||
: entryIssues.length
|
||||
? "warning"
|
||||
: "ok",
|
||||
issues: entryIssues,
|
||||
},
|
||||
logs: {
|
||||
status: errors ? "error" : warnings ? "warning" : "ok",
|
||||
errors,
|
||||
warnings,
|
||||
},
|
||||
flows: { status: entryFlows.length ? "error" : "ok", flows: entryFlows },
|
||||
setupSeconds: setups.find((setup) => setup.domain === entry.domain)
|
||||
?.seconds,
|
||||
};
|
||||
health.status = worst([
|
||||
state,
|
||||
health.entities.status,
|
||||
health.repairs.status,
|
||||
health.logs.status,
|
||||
health.flows.status,
|
||||
]);
|
||||
return health;
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
import type {
|
||||
DataEntryFlowProgress,
|
||||
DataEntryFlowStep,
|
||||
@@ -78,10 +78,10 @@ export const deleteConfigFlow = (
|
||||
|
||||
// The backend indexes a single key, and only these two exist.
|
||||
export const getConfigFlowHandlers = (
|
||||
hass: HomeAssistant,
|
||||
callApi: HomeAssistantApi["callApi"],
|
||||
type?: "helper" | "integration"
|
||||
) =>
|
||||
hass.callApi<string[]>(
|
||||
callApi<string[]>(
|
||||
"GET",
|
||||
`config/config_entries/flow_handlers${type ? `?type=${type}` : ""}`
|
||||
);
|
||||
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
|
||||
export interface Counter {
|
||||
id: string;
|
||||
@@ -25,10 +25,10 @@ export const fetchCounter = (hass: HomeAssistant) =>
|
||||
hass.callWS<Counter[]>({ type: "counter/list" });
|
||||
|
||||
export const createCounter = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: CounterMutableParams
|
||||
) =>
|
||||
hass.callWS<Counter>({
|
||||
callWS<Counter>({
|
||||
type: "counter/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -5,8 +5,17 @@ export interface DataTableFilter {
|
||||
|
||||
export type DataTableFilters = Record<string, DataTableFilter>;
|
||||
|
||||
export interface FloorAreasFilterValue {
|
||||
floors?: string[];
|
||||
areas?: string[];
|
||||
no_area?: boolean;
|
||||
}
|
||||
|
||||
export type DataTableFiltersValue =
|
||||
string[] | Record<"key" | string, string[]> | undefined;
|
||||
| string[]
|
||||
| Record<"key" | string, string[]>
|
||||
| FloorAreasFilterValue
|
||||
| undefined;
|
||||
|
||||
export type DataTableFiltersValues = Record<string, DataTableFiltersValue>;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Connection } from "home-assistant-js-websocket";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../../types";
|
||||
import type { ConfigEntry } from "../config_entries";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
@@ -231,11 +231,11 @@ export const devicesInArea = (devices: DeviceRegistryEntry[], areaId: string) =>
|
||||
devices.filter((device) => device.area_id === areaId);
|
||||
|
||||
export const updateDeviceRegistryEntry = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
deviceId: string,
|
||||
updates: Partial<DeviceRegistryEntryMutableParams>
|
||||
) =>
|
||||
hass.callWS<DeviceRegistryEntry>({
|
||||
callWS<DeviceRegistryEntry>({
|
||||
type: "config/device_registry/update",
|
||||
device_id: deviceId,
|
||||
...updates,
|
||||
|
||||
+183
-21
@@ -252,12 +252,14 @@ export interface EnergyPreferences {
|
||||
energy_sources: EnergySource[];
|
||||
device_consumption: DeviceConsumptionEnergyPreference[];
|
||||
device_consumption_water: DeviceConsumptionEnergyPreference[];
|
||||
device_consumption_gas: DeviceConsumptionEnergyPreference[];
|
||||
}
|
||||
|
||||
export const EMPTY_PREFERENCES: EnergyPreferences = {
|
||||
energy_sources: [],
|
||||
device_consumption: [],
|
||||
device_consumption_water: [],
|
||||
device_consumption_gas: [],
|
||||
};
|
||||
|
||||
export interface EnergyInfo {
|
||||
@@ -275,6 +277,7 @@ export interface EnergyPreferencesValidation {
|
||||
energy_sources: EnergyValidationIssue[][];
|
||||
device_consumption: EnergyValidationIssue[][];
|
||||
device_consumption_water: EnergyValidationIssue[][];
|
||||
device_consumption_gas: EnergyValidationIssue[][];
|
||||
}
|
||||
|
||||
export const getEnergyInfo = (callWS: HomeAssistant["callWS"]) =>
|
||||
@@ -342,25 +345,41 @@ const ENERGY_DEVICE_NAME = [
|
||||
{ type: "device" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
const ENERGY_DEVICE_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
/**
|
||||
* Display name of an individual device. A name set by the user always wins;
|
||||
* otherwise the device is named like an entity without the entity part, so
|
||||
* the energy and power sensors of one device share a name. Entities without a
|
||||
* device and statistics without an entity keep the statistic label.
|
||||
*
|
||||
* `includeEntityName` adds the entity name back for devices that would
|
||||
* otherwise share a name with another device in the same list, see
|
||||
* `computeAmbiguousEnergyDevices`.
|
||||
*/
|
||||
export const computeEnergyLabel = (
|
||||
states: HomeAssistant["states"],
|
||||
formatEntityName: HomeAssistant["formatEntityName"],
|
||||
statisticId: string,
|
||||
statisticsMetaData?: StatisticsMetaData,
|
||||
customName?: string
|
||||
customName?: string,
|
||||
includeEntityName = false
|
||||
): string => {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const entity = states[statisticId];
|
||||
const deviceName = entity && formatEntityName(entity, ENERGY_DEVICE_NAME);
|
||||
const deviceName =
|
||||
entity &&
|
||||
formatEntityName(
|
||||
entity,
|
||||
includeEntityName ? ENERGY_DEVICE_ENTITY_NAME : ENERGY_DEVICE_NAME
|
||||
);
|
||||
if (deviceName) {
|
||||
return deviceName;
|
||||
}
|
||||
@@ -386,6 +405,61 @@ export const computeEnergyStatisticArea = (
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Statistic ids of the devices that would share their label with another
|
||||
* device in the same list, which happens when one device has several meters of
|
||||
* the same type. Those are named with the entity name as well, so they stay
|
||||
* apart in the charts. Lists hold one type of device, so an energy meter and a
|
||||
* water meter of one device never collide.
|
||||
*/
|
||||
export const computeAmbiguousEnergyDevices = (
|
||||
states: HomeAssistant["states"],
|
||||
formatEntityName: HomeAssistant["formatEntityName"],
|
||||
devices: Partial<DeviceConsumptionEnergyPreference>[],
|
||||
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
|
||||
): Set<string> => {
|
||||
const statisticIdsByLabel = new Map<string, Set<string>>();
|
||||
const named = new Set<string>();
|
||||
|
||||
for (const device of devices) {
|
||||
const statisticId = device[statisticKey];
|
||||
if (!statisticId) {
|
||||
continue;
|
||||
}
|
||||
const entity = states[statisticId];
|
||||
// A name set by the user is the label, so it counts towards a collision,
|
||||
// but it is never the one that gives way. Statistics named after their
|
||||
// entity or their metadata are distinct enough already.
|
||||
const label =
|
||||
device.name || (entity && formatEntityName(entity, ENERGY_DEVICE_NAME));
|
||||
if (!label) {
|
||||
continue;
|
||||
}
|
||||
if (device.name) {
|
||||
named.add(statisticId);
|
||||
}
|
||||
const statisticIds = statisticIdsByLabel.get(label);
|
||||
if (statisticIds) {
|
||||
statisticIds.add(statisticId);
|
||||
} else {
|
||||
statisticIdsByLabel.set(label, new Set([statisticId]));
|
||||
}
|
||||
}
|
||||
|
||||
const ambiguous = new Set<string>();
|
||||
for (const statisticIds of statisticIdsByLabel.values()) {
|
||||
if (statisticIds.size > 1) {
|
||||
for (const statisticId of statisticIds) {
|
||||
if (!named.has(statisticId)) {
|
||||
ambiguous.add(statisticId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ambiguous;
|
||||
};
|
||||
|
||||
/**
|
||||
* Device labels keyed by statistic id. Cards that show live power or flow
|
||||
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
|
||||
@@ -399,6 +473,12 @@ export const computeEnergyDeviceLabels = (
|
||||
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
|
||||
): Record<string, string> => {
|
||||
const labels: Record<string, string> = {};
|
||||
const ambiguous = computeAmbiguousEnergyDevices(
|
||||
states,
|
||||
formatEntityName,
|
||||
devices,
|
||||
statisticKey
|
||||
);
|
||||
|
||||
for (const device of devices) {
|
||||
const statisticId = device[statisticKey];
|
||||
@@ -408,7 +488,8 @@ export const computeEnergyDeviceLabels = (
|
||||
formatEntityName,
|
||||
statisticId,
|
||||
statsMetadata?.[statisticId],
|
||||
device.name
|
||||
device.name,
|
||||
ambiguous.has(statisticId)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -507,6 +588,11 @@ export const getReferencedStatisticIds = (
|
||||
...prefs.device_consumption_water.map((d) => d.stat_consumption)
|
||||
);
|
||||
}
|
||||
if (!(includeTypes && !includeTypes.includes("gas"))) {
|
||||
statIDs.push(
|
||||
...prefs.device_consumption_gas.map((d) => d.stat_consumption)
|
||||
);
|
||||
}
|
||||
|
||||
return statIDs;
|
||||
};
|
||||
@@ -543,6 +629,7 @@ export const getReferencedStatisticIdsPower = (
|
||||
}
|
||||
statIDs.push(...prefs.device_consumption.map((d) => d.stat_rate));
|
||||
statIDs.push(...prefs.device_consumption_water.map((d) => d.stat_rate));
|
||||
statIDs.push(...prefs.device_consumption_gas.map((d) => d.stat_rate));
|
||||
|
||||
return statIDs.filter(Boolean) as string[];
|
||||
};
|
||||
@@ -651,9 +738,16 @@ const getEnergyData = async (
|
||||
const gasIsOtherEnergy =
|
||||
ENERGY_UNITS.includes(gasUnit as any) && gasUnit !== "kWh";
|
||||
const gasEnergyStatIds = gasIsOtherEnergy
|
||||
? (energySourcesByType(prefs).gas ?? []).map(
|
||||
(source) => source.stat_energy_from
|
||||
)
|
||||
? [
|
||||
...new Set([
|
||||
...(energySourcesByType(prefs).gas ?? []).map(
|
||||
(source) => source.stat_energy_from
|
||||
),
|
||||
...prefs.device_consumption_gas.map(
|
||||
(device) => device.stat_consumption
|
||||
),
|
||||
]),
|
||||
]
|
||||
: [];
|
||||
const gasEnergyUnits: StatisticsUnitConfiguration = {
|
||||
energy: gasIsOtherEnergy
|
||||
@@ -1474,13 +1568,18 @@ export type EnergyGasUnitClass = (typeof energyGasUnitClass)[number];
|
||||
export const getEnergyGasUnitClass = (
|
||||
prefs: EnergyPreferences,
|
||||
excludeSource?: string,
|
||||
statisticsMetaData: Record<string, StatisticsMetaData> = {}
|
||||
statisticsMetaData: Record<string, StatisticsMetaData> = {},
|
||||
excludeFrom?: "source" | "device"
|
||||
): EnergyGasUnitClass | undefined => {
|
||||
for (const source of prefs.energy_sources) {
|
||||
if (source.type !== "gas") {
|
||||
continue;
|
||||
}
|
||||
if (excludeSource && excludeSource === source.stat_energy_from) {
|
||||
if (
|
||||
excludeSource &&
|
||||
excludeSource === source.stat_energy_from &&
|
||||
excludeFrom !== "device"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const statisticIdWithMeta = statisticsMetaData[source.stat_energy_from];
|
||||
@@ -1492,6 +1591,19 @@ export const getEnergyGasUnitClass = (
|
||||
return statisticIdWithMeta.unit_class as EnergyGasUnitClass;
|
||||
}
|
||||
}
|
||||
for (const device of prefs.device_consumption_gas) {
|
||||
if (
|
||||
excludeSource &&
|
||||
excludeSource === device.stat_consumption &&
|
||||
excludeFrom !== "source"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const unitClass = statisticsMetaData[device.stat_consumption]?.unit_class;
|
||||
if (energyGasUnitClass.includes(unitClass as EnergyGasUnitClass)) {
|
||||
return unitClass as EnergyGasUnitClass;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1501,15 +1613,15 @@ const getEnergyGasUnit = (
|
||||
prefs: EnergyPreferences,
|
||||
statisticsMetaData: Record<string, StatisticsMetaData> = {}
|
||||
): string => {
|
||||
const units = prefs.energy_sources
|
||||
.filter((s) => s.type === "gas")
|
||||
.map((s) =>
|
||||
getDisplayUnit(
|
||||
states,
|
||||
s.stat_energy_from,
|
||||
statisticsMetaData[s.stat_energy_from]
|
||||
)
|
||||
);
|
||||
const gasStatIds = [
|
||||
...prefs.energy_sources.flatMap((s) =>
|
||||
s.type === "gas" ? [s.stat_energy_from] : []
|
||||
),
|
||||
...prefs.device_consumption_gas.map((d) => d.stat_consumption),
|
||||
];
|
||||
const units = gasStatIds.map((id) =>
|
||||
getDisplayUnit(states, id, statisticsMetaData[id])
|
||||
);
|
||||
|
||||
const unitClass = getEnergyGasUnitClass(prefs, undefined, statisticsMetaData);
|
||||
if (unitClass === "energy") {
|
||||
@@ -1535,10 +1647,7 @@ const getEnergyGasUnit = (
|
||||
return [];
|
||||
}),
|
||||
]);
|
||||
const isSharedWithKWh = prefs.energy_sources.some(
|
||||
(source) =>
|
||||
source.type === "gas" && kWhStatIds.has(source.stat_energy_from)
|
||||
);
|
||||
const isSharedWithKWh = gasStatIds.some((id) => kWhStatIds.has(id));
|
||||
const first = units[0];
|
||||
if (
|
||||
!isSharedWithKWh &&
|
||||
@@ -2200,6 +2309,48 @@ export const formatFlowRateShort = (
|
||||
});
|
||||
};
|
||||
|
||||
export type WaterGasResource = "water" | "gas";
|
||||
|
||||
export const getWaterGasDevicesKey = (resource: WaterGasResource) =>
|
||||
`device_consumption_${resource}` as const;
|
||||
|
||||
export const getWaterGasDevices = (
|
||||
prefs: EnergyPreferences,
|
||||
resource: WaterGasResource
|
||||
): DeviceConsumptionEnergyPreference[] =>
|
||||
prefs[getWaterGasDevicesKey(resource)];
|
||||
|
||||
export const getFlowRateUnit = (
|
||||
resource: WaterGasResource,
|
||||
prefs: EnergyPreferences,
|
||||
states: HomeAssistant["states"]
|
||||
): string | undefined => {
|
||||
const ids = [
|
||||
...prefs.energy_sources.flatMap((s) =>
|
||||
s.type === resource && s.stat_rate ? [s.stat_rate] : []
|
||||
),
|
||||
...getWaterGasDevices(prefs, resource).flatMap((d) =>
|
||||
d.stat_rate ? [d.stat_rate] : []
|
||||
),
|
||||
];
|
||||
for (const id of ids) {
|
||||
const unit = states[id]?.attributes.unit_of_measurement;
|
||||
if (unit) {
|
||||
return unit;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getFlowRateInUnit = (
|
||||
stateObj: HassEntity | undefined,
|
||||
unit: string
|
||||
): number | undefined => {
|
||||
const value = getFlowRateFromState(stateObj);
|
||||
const factor = FLOW_RATE_TO_LMIN[unit];
|
||||
return value === undefined || factor === undefined ? value : value / factor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current power value from entity state, normalized to watts (W)
|
||||
* @param stateObj - The entity state object to get power value from
|
||||
@@ -2280,6 +2431,7 @@ export const downloadEnergyData = (
|
||||
const energy_sources = energyData.prefs.energy_sources;
|
||||
const device_consumption = energyData.prefs.device_consumption;
|
||||
const device_consumption_water = energyData.prefs.device_consumption_water;
|
||||
const device_consumption_gas = energyData.prefs.device_consumption_gas;
|
||||
const stats = state.stats;
|
||||
|
||||
interface CsvRow {
|
||||
@@ -2460,6 +2612,16 @@ export const downloadEnergyData = (
|
||||
printCategory("device_consumption_water", waterDevices, state.waterUnit);
|
||||
}
|
||||
|
||||
if (device_consumption_gas) {
|
||||
const gasDevices: string[] = [];
|
||||
device_consumption_gas.forEach((source) => {
|
||||
source = source as DeviceConsumptionEnergyPreference;
|
||||
gasDevices.push(source.stat_consumption);
|
||||
});
|
||||
|
||||
printCategory("device_consumption_gas", gasDevices, state.gasUnit);
|
||||
}
|
||||
|
||||
const { summedData } = getSummedData(state);
|
||||
const { consumption } = computeConsumptionData(summedData, undefined);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import memoizeOne from "memoize-one";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { debounce } from "../../common/util/debounce";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../../types";
|
||||
import type { LightColor } from "../light";
|
||||
import type { NextNamePart, RegistryEntry } from "../registry";
|
||||
import type { Segment } from "../vacuum";
|
||||
@@ -284,11 +284,11 @@ export const getExtendedEntityRegistryEntries = (
|
||||
});
|
||||
|
||||
export const updateEntityRegistryEntry = (
|
||||
hass: Pick<HomeAssistant, "callWS">,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
entityId: string,
|
||||
updates: Partial<EntityRegistryEntryUpdateParams>
|
||||
): Promise<UpdateEntityRegistryEntryResult> =>
|
||||
hass.callWS({
|
||||
callWS({
|
||||
type: "config/entity_registry/update",
|
||||
entity_id: entityId,
|
||||
...updates,
|
||||
@@ -382,11 +382,47 @@ export const getEntityPlatformLookup = (
|
||||
return entityLookup;
|
||||
};
|
||||
|
||||
export interface AutomaticEntityIdOverrides {
|
||||
name?: string | null;
|
||||
area_id?: string | null;
|
||||
}
|
||||
|
||||
export const getAutomaticEntityIds = (
|
||||
hass: HomeAssistant,
|
||||
entity_ids: string[]
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
entity_ids: string[],
|
||||
overrides?: Record<string, AutomaticEntityIdOverrides>
|
||||
) =>
|
||||
hass.callWS<Record<string, string | null>>({
|
||||
callWS<Record<string, string | null>>({
|
||||
type: "config/entity_registry/get_automatic_entity_ids",
|
||||
entity_ids,
|
||||
...(overrides && { overrides }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Set the area of entities and regenerate their entity IDs, which can include
|
||||
* the area. Returns the entity ID of each entity after the change.
|
||||
*/
|
||||
export const updateEntitiesArea = async (
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
entityIds: string[],
|
||||
areaId: string
|
||||
): Promise<Record<string, string>> => {
|
||||
await Promise.all(
|
||||
entityIds.map((entityId) =>
|
||||
updateEntityRegistryEntry(callWS, entityId, { area_id: areaId })
|
||||
)
|
||||
);
|
||||
const newEntityIds = await getAutomaticEntityIds(callWS, entityIds);
|
||||
const result: Record<string, string> = {};
|
||||
for (const entityId of entityIds) {
|
||||
const newEntityId = newEntityIds[entityId];
|
||||
if (newEntityId) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await updateEntityRegistryEntry(callWS, entityId, {
|
||||
new_entity_id: newEntityId,
|
||||
});
|
||||
}
|
||||
result[entityId] = newEntityId || entityId;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
+2
-1
@@ -19,7 +19,8 @@ export interface ESPHomeZWaveProxyCapabilities {
|
||||
export interface ESPHomeSerialProxy {
|
||||
name: string;
|
||||
port_type: ESPHomeSerialPortType | null;
|
||||
url: string;
|
||||
// `null` when no device is present (e.g. unpopulated USB port)
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface ESPHomeDeviceCapabilities {
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export const voiceAssistants = {
|
||||
},
|
||||
"cloud.google_assistant": {
|
||||
domain: "google_assistant",
|
||||
name: "Google Assistant",
|
||||
name: "Google Home",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -51,6 +51,10 @@ export interface SecurityFrontendSystemData {
|
||||
favorite_entities?: string[];
|
||||
}
|
||||
|
||||
export interface MaintenanceFrontendSystemData {
|
||||
hidden_entities?: string[];
|
||||
}
|
||||
|
||||
export interface EnergyFrontendSystemData {
|
||||
// Stable "<view>.<card-type>" keys of energy dashboard cards the user has
|
||||
// hidden. An absent key or array means nothing is hidden (all cards visible),
|
||||
@@ -69,6 +73,7 @@ declare global {
|
||||
home: HomeFrontendSystemData;
|
||||
energy: EnergyFrontendSystemData;
|
||||
security: SecurityFrontendSystemData;
|
||||
maintenance: MaintenanceFrontendSystemData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +150,32 @@ export const saveFrontendSystemData = async <
|
||||
value,
|
||||
});
|
||||
|
||||
const pendingSystemDataSaves = new Map<ValidSystemDataKey, Promise<void>>();
|
||||
|
||||
export const queueFrontendSystemDataSave = <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
conn: Connection,
|
||||
key: SystemDataKey,
|
||||
value: FrontendSystemData[SystemDataKey]
|
||||
): Promise<void> => {
|
||||
const save = (pendingSystemDataSaves.get(key) ?? Promise.resolve())
|
||||
.catch(() => undefined)
|
||||
.then(() => saveFrontendSystemData(conn, key, value));
|
||||
pendingSystemDataSaves.set(key, save);
|
||||
return save;
|
||||
};
|
||||
|
||||
export const fetchFrontendSystemDataAfterSaves = async <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
conn: Connection,
|
||||
key: SystemDataKey
|
||||
): Promise<FrontendSystemData[SystemDataKey] | null> => {
|
||||
await pendingSystemDataSaves.get(key)?.catch(() => undefined);
|
||||
return fetchFrontendSystemData(conn, key);
|
||||
};
|
||||
|
||||
export const subscribeFrontendSystemData = <
|
||||
SystemDataKey extends ValidSystemDataKey,
|
||||
>(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
|
||||
export interface InputBoolean {
|
||||
id: string;
|
||||
@@ -17,10 +17,10 @@ export const fetchInputBoolean = (hass: HomeAssistant) =>
|
||||
hass.callWS<InputBoolean[]>({ type: "input_boolean/list" });
|
||||
|
||||
export const createInputBoolean = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputBooleanMutableParams
|
||||
) =>
|
||||
hass.callWS<InputBoolean>({
|
||||
callWS<InputBoolean>({
|
||||
type: "input_boolean/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
|
||||
export interface InputButton {
|
||||
id: string;
|
||||
@@ -15,10 +15,10 @@ export const fetchInputButton = (hass: HomeAssistant) =>
|
||||
hass.callWS<InputButton[]>({ type: "input_button/list" });
|
||||
|
||||
export const createInputButton = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputButtonMutableParams
|
||||
) =>
|
||||
hass.callWS<InputButton>({
|
||||
callWS<InputButton>({
|
||||
type: "input_button/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -45,10 +45,10 @@ export const fetchInputDateTime = (hass: HomeAssistant) =>
|
||||
hass.callWS<InputDateTime[]>({ type: "input_datetime/list" });
|
||||
|
||||
export const createInputDateTime = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputDateTimeMutableParams
|
||||
) =>
|
||||
hass.callWS<InputDateTime>({
|
||||
callWS<InputDateTime>({
|
||||
type: "input_datetime/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
|
||||
export interface InputNumber {
|
||||
id: string;
|
||||
@@ -29,10 +29,10 @@ export const fetchInputNumber = (hass: HomeAssistant) =>
|
||||
hass.callWS<InputNumber[]>({ type: "input_number/list" });
|
||||
|
||||
export const createInputNumber = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputNumberMutableParams
|
||||
) =>
|
||||
hass.callWS<InputNumber>({
|
||||
callWS<InputNumber>({
|
||||
type: "input_number/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
HassEntityAttributeBase,
|
||||
HassEntityBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
|
||||
interface InputSelectEntityAttributes extends HassEntityAttributeBase {
|
||||
options: string[];
|
||||
@@ -41,10 +41,10 @@ export const fetchInputSelect = (hass: HomeAssistant) =>
|
||||
hass.callWS<InputSelect[]>({ type: "input_select/list" });
|
||||
|
||||
export const createInputSelect = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputSelectMutableParams
|
||||
) =>
|
||||
hass.callWS<InputSelect>({
|
||||
callWS<InputSelect>({
|
||||
type: "input_select/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { HomeAssistant, HomeAssistantApi } from "../types";
|
||||
|
||||
export interface InputText {
|
||||
id: string;
|
||||
@@ -36,10 +36,10 @@ export const fetchInputText = (hass: HomeAssistant) =>
|
||||
hass.callWS<InputText[]>({ type: "input_text/list" });
|
||||
|
||||
export const createInputText = (
|
||||
hass: HomeAssistant,
|
||||
callWS: HomeAssistantApi["callWS"],
|
||||
values: InputTextMutableParams
|
||||
) =>
|
||||
hass.callWS<InputText>({
|
||||
callWS<InputText>({
|
||||
type: "input_text/create",
|
||||
...values,
|
||||
});
|
||||
|
||||
@@ -196,8 +196,8 @@ export const fetchIntegrationManifest = (
|
||||
.callWS<IntegrationManifest>({ type: "manifest/get", integration })
|
||||
.then(sanitizeManifest);
|
||||
|
||||
export const fetchIntegrationSetups = (hass: HomeAssistant) =>
|
||||
hass.callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
|
||||
export const fetchIntegrationSetups = (callWS: HomeAssistant["callWS"]) =>
|
||||
callWS<IntegrationSetup[]>({ type: "integration/setup_info" });
|
||||
|
||||
export const fetchIntegrationLogInfo = (conn) =>
|
||||
conn.sendMessagePromise({
|
||||
|
||||
Loaded 100 of 341 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user