mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-23 17:47:09 +00:00
Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8580c99f0a | |||
| 3cf2d9d6dd | |||
| 081212eab1 | |||
| 7c6609aee7 | |||
| 7048c5f3d2 | |||
| 9ed47be6c3 | |||
| 128f4526e3 | |||
| 3f1b7ce391 | |||
| 4073b4e1f5 | |||
| 86a24d1532 | |||
| 46bab5bb01 | |||
| e8f486af0a | |||
| 211579eade | |||
| f6458925c9 | |||
| ae5e35e7ed | |||
| 8c1727859a | |||
| 287562221f | |||
| 2593dfed8d | |||
| 2d92f1fb3b | |||
| 8cff4c6bd2 | |||
| 5aa8455861 | |||
| 4d142734d8 | |||
| eaecc76f36 | |||
| 7dc0033c03 | |||
| 601e6d0542 | |||
| c7ca3dd837 | |||
| f75a376add | |||
| a541204ffb | |||
| cbbce90eae | |||
| 950de204aa | |||
| 91b6a4c4b6 | |||
| 643cc4ca7d | |||
| 9ef71e6cf4 | |||
| bface72af7 | |||
| 90028b2e22 | |||
| 914c48abd5 | |||
| 79c082acde | |||
| 4728eb7231 | |||
| d02b92bd32 | |||
| 98525d23e6 | |||
| ec98b21276 | |||
| defad3beca | |||
| 635d61256b | |||
| 60c5bea6e0 | |||
| aed83ccc07 | |||
| 85b2ca377a | |||
| e194247f50 | |||
| c79956b893 | |||
| abb4cbc263 | |||
| 811397f740 | |||
| c7c78bd587 | |||
| bf67a3ec1d | |||
| b0772d6701 | |||
| d484ef3f2f | |||
| 6f8fffccbd | |||
| df03a0dfd9 | |||
| bb12cb19b5 | |||
| 7baf7f4701 | |||
| 0dfb801ff6 | |||
| d94dcf50fb | |||
| fb1f5ef722 | |||
| e5d5797d91 | |||
| adee24f745 | |||
| 1b695e24d0 | |||
| 7f9259edf9 | |||
| 6954dc1a54 | |||
| 032d0fb332 | |||
| 43ed97da43 | |||
| 9f4d35bc05 | |||
| 11afde6b5f | |||
| 1b0dcb33b1 | |||
| 67eecbc51d | |||
| 969ccf85d2 | |||
| 500ce18ae5 | |||
| b413a7742c | |||
| e84373fdbd | |||
| caaee14856 | |||
| 28f04df81d | |||
| 48a8c5b2d5 | |||
| 45312ba7fd | |||
| b5dad80e19 | |||
| ae85263d91 | |||
| c5000bcdde | |||
| 5e085c70b0 | |||
| 71fc44284c | |||
| b7e1e23eaa | |||
| 2ee7c6fc2a | |||
| 7d069c4f5e | |||
| 20bf8181dd | |||
| 1884a06f98 | |||
| 0c63078923 | |||
| c6ae47f1c8 | |||
| 0a9fe0e0c7 | |||
| c3480bc319 | |||
| 8af5908682 | |||
| 60e95b886c | |||
| 0385ca8076 | |||
| 02c65fc8cb | |||
| 49290d5c83 | |||
| 08aff3bfd7 | |||
| 455fa45b9c | |||
| 2e56a4ec4c | |||
| 76131ff09e | |||
| 89d8723c5a | |||
| 7bdb63a6fe | |||
| eed79f1797 | |||
| 76665009da | |||
| 6d7d08fddc | |||
| 77d4e6dc43 | |||
| 7345256b30 | |||
| e0d98e95fa | |||
| 17041044cf | |||
| 9a10cd7fa8 | |||
| fa354aed2a | |||
| c044d96712 |
@@ -58,6 +58,8 @@ jobs:
|
||||
run: yarn run lint:lit --quiet
|
||||
- name: Run prettier
|
||||
run: yarn run lint:prettier
|
||||
- name: Check dependency licenses
|
||||
run: yarn run lint:licenses
|
||||
test:
|
||||
name: Run tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -62,4 +62,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
|
||||
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+7
-2
@@ -1,11 +1,16 @@
|
||||
compressionLevel: mixed
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
npmMinimalAgeGate: "3d"
|
||||
compressionLevel: mixed
|
||||
|
||||
defaultSemverRangePrefix: ""
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.14.1.cjs
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./compress.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./licenses.js";
|
||||
import "./locale-data.js";
|
||||
import "./service-worker.js";
|
||||
import "./translations.js";
|
||||
@@ -36,7 +37,12 @@ gulp.task(
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"build-translations",
|
||||
"build-locale-data",
|
||||
"gen-licenses"
|
||||
),
|
||||
"copy-static-app",
|
||||
"rspack-prod-app",
|
||||
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* global process */
|
||||
// Tasks to generate entry HTML
|
||||
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ const SAFARI_TO_MACOS = {
|
||||
16: [11, 0, 0],
|
||||
17: [12, 0, 0],
|
||||
18: [13, 0, 0],
|
||||
26: [26, 0, 0],
|
||||
};
|
||||
|
||||
const getCommonTemplateVars = () => {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Gulp task to generate third-party license notices.
|
||||
|
||||
import { readFile, access } from "fs/promises";
|
||||
import { generateLicenseFile } from "generate-license-file";
|
||||
import gulp from "gulp";
|
||||
import path from "path";
|
||||
import paths from "../paths.cjs";
|
||||
|
||||
const OUTPUT_FILE = path.join(
|
||||
paths.app_output_static,
|
||||
"third-party-licenses.txt"
|
||||
);
|
||||
|
||||
// The echarts package ships an Apache-2.0 NOTICE file that must be
|
||||
// redistributed alongside the compiled output per Apache License §4(d).
|
||||
const NOTICE_FILES = [
|
||||
path.resolve(paths.root_dir, "node_modules/echarts/NOTICE"),
|
||||
];
|
||||
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
//
|
||||
// Each entry is pinned to a specific version. If a package is updated,
|
||||
// this list must be reviewed and the version updated after verifying
|
||||
// that the new version's license still matches. The build will fail
|
||||
// if the installed version does not match the pinned version.
|
||||
const LICENSE_OVERRIDES = [
|
||||
{
|
||||
// type-fest ships two license files (MIT for code, CC0 for types).
|
||||
// We use the MIT license since that covers the bundled code.
|
||||
packageName: "type-fest",
|
||||
version: "5.6.0",
|
||||
licensePath: path.resolve(
|
||||
paths.root_dir,
|
||||
"node_modules/type-fest/license-mit"
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
gulp.task("gen-licenses", async () => {
|
||||
const licenseOverrides = {};
|
||||
|
||||
for (const { packageName, version, licensePath } of LICENSE_OVERRIDES) {
|
||||
const pkgJsonPath = path.resolve(
|
||||
paths.root_dir,
|
||||
`node_modules/${packageName}/package.json`
|
||||
);
|
||||
|
||||
let packageJSON;
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
packageJSON = JSON.parse(await readFile(pkgJsonPath, "utf-8"));
|
||||
} catch {
|
||||
throw new Error(
|
||||
`package.json for "${packageName}" not found or unreadable at ${pkgJsonPath}`
|
||||
);
|
||||
}
|
||||
|
||||
if (packageJSON.version !== version) {
|
||||
throw new Error(
|
||||
`License override for "${packageName}" is pinned to version ${version}, but found version ${packageJSON.version}. ` +
|
||||
`Please verify the new version's license and update the override in build-scripts/gulp/licenses.js.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await access(licensePath);
|
||||
} catch {
|
||||
throw new Error(`License file not found or unreadable: ${licensePath}`);
|
||||
}
|
||||
|
||||
licenseOverrides[`${packageName}@${version}`] = licensePath;
|
||||
}
|
||||
|
||||
await generateLicenseFile(
|
||||
path.resolve(paths.root_dir, "package.json"),
|
||||
OUTPUT_FILE,
|
||||
{ append: NOTICE_FILES, replace: licenseOverrides }
|
||||
);
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
import "@material/mwc-drawer";
|
||||
import "@material/mwc-top-app-bar-fixed";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
+25
-10
@@ -1,4 +1,3 @@
|
||||
import "@material/mwc-drawer";
|
||||
import "@material/mwc-top-app-bar-fixed";
|
||||
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -7,6 +6,8 @@ import { customElement, query, state } from "lit/decorators";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/ha-drawer";
|
||||
import type { HaDrawer } from "../../src/components/ha-drawer";
|
||||
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
|
||||
import "../../src/components/ha-icon-button";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
@@ -39,8 +40,8 @@ class HaGallery extends LitElement {
|
||||
@query("notification-manager")
|
||||
private _notifications!: HTMLElementTagNameMap["notification-manager"];
|
||||
|
||||
@query("mwc-drawer")
|
||||
private _drawer!: HTMLElementTagNameMap["mwc-drawer"];
|
||||
@query("ha-drawer")
|
||||
private _drawer!: HaDrawer;
|
||||
|
||||
private _narrow = window.matchMedia("(max-width: 600px)").matches;
|
||||
|
||||
@@ -75,15 +76,14 @@ class HaGallery extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<mwc-drawer
|
||||
hasHeader
|
||||
<ha-drawer
|
||||
.direction=${this._rtl ? "rtl" : "ltr"}
|
||||
.open=${!this._narrow}
|
||||
.type=${this._narrow ? "modal" : "dismissible"}
|
||||
>
|
||||
<span slot="title">Home Assistant Design</span>
|
||||
<!-- <span slot="subtitle">subtitle</span> -->
|
||||
<div class="drawer-title">Home Assistant Design</div>
|
||||
<div class="sidebar">${sidebar}</div>
|
||||
<div slot="appContent">
|
||||
<div slot="appContent" class="app-content">
|
||||
<mwc-top-app-bar-fixed>
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@@ -144,7 +144,7 @@ class HaGallery extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mwc-drawer>
|
||||
</ha-drawer>
|
||||
<notification-manager
|
||||
.hass=${FAKE_HASS}
|
||||
id="notifications"
|
||||
@@ -226,12 +226,27 @@ class HaGallery extends LitElement {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
--ha-sidebar-width: 256px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100vh - 64px);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--primary-text-color);
|
||||
display: flex;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
min-height: 64px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
color: var(--primary-text-color);
|
||||
display: block;
|
||||
@@ -255,7 +270,7 @@ class HaGallery extends LitElement {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
div[slot="appContent"] {
|
||||
.app-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
[build.environment]
|
||||
YARN_VERSION = "1.22.11"
|
||||
NODE_OPTIONS = "--max_old_space_size=6144"
|
||||
|
||||
+31
-30
@@ -14,6 +14,7 @@
|
||||
"format:prettier": "prettier . --cache --write",
|
||||
"lint:types": "tsc",
|
||||
"lint:lit": "lit-analyzer \"{.,*}/src/**/*.ts\"",
|
||||
"lint:licenses": "node --no-deprecation script/check-licenses",
|
||||
"lint": "yarn run lint:eslint && yarn run lint:prettier && yarn run lint:types && yarn run lint:lit",
|
||||
"format": "yarn run format:eslint && yarn run format:prettier",
|
||||
"postinstall": "husky",
|
||||
@@ -36,18 +37,18 @@
|
||||
"@codemirror/lint": "6.9.6",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.42.1",
|
||||
"@codemirror/view": "6.43.0",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.2",
|
||||
"@formatjs/intl-displaynames": "7.3.5",
|
||||
"@formatjs/intl-durationformat": "0.10.8",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.6",
|
||||
"@formatjs/intl-listformat": "8.3.5",
|
||||
"@formatjs/intl-locale": "5.3.5",
|
||||
"@formatjs/intl-numberformat": "9.3.5",
|
||||
"@formatjs/intl-pluralrules": "6.3.5",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.5",
|
||||
"@formatjs/intl-datetimeformat": "7.4.5",
|
||||
"@formatjs/intl-displaynames": "7.3.7",
|
||||
"@formatjs/intl-durationformat": "0.10.11",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.8",
|
||||
"@formatjs/intl-listformat": "8.3.7",
|
||||
"@formatjs/intl-locale": "5.3.7",
|
||||
"@formatjs/intl-numberformat": "9.3.8",
|
||||
"@formatjs/intl-pluralrules": "6.3.7",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.7",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -61,9 +62,7 @@
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/mwc-base": "0.27.0",
|
||||
"@material/mwc-drawer": "0.27.0",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/mwc-top-app-bar": "0.27.0",
|
||||
@@ -75,8 +74,8 @@
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.21",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "3.9.1",
|
||||
"@tsparticles/preset-links": "3.2.0",
|
||||
"@tsparticles/engine": "4.0.2",
|
||||
"@tsparticles/preset-links": "4.0.2",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
@@ -87,7 +86,7 @@
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns": "4.2.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
@@ -99,13 +98,13 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.4",
|
||||
"intl-messageformat": "11.2.6",
|
||||
"js-yaml": "4.1.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.3",
|
||||
"memoize-one": "6.0.0",
|
||||
@@ -137,12 +136,12 @@
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.60.0",
|
||||
"@lokalise/node-api": "15.7.1",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "2.0.2",
|
||||
"@rsdoctor/rspack-plugin": "1.5.11",
|
||||
"@rspack/core": "2.0.3",
|
||||
"@rspack/dev-server": "2.0.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -161,21 +160,22 @@
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.3.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-lit": "2.2.1",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.5",
|
||||
"generate-license-file": "4.1.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "5.0.1",
|
||||
@@ -186,7 +186,8 @@
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "17.0.2",
|
||||
"license-checker-rseidelsohn": "4.4.2",
|
||||
"lint-staged": "17.0.5",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -197,19 +198,19 @@
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.15",
|
||||
"terser-webpack-plugin": "5.5.0",
|
||||
"terser-webpack-plugin": "5.6.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.2",
|
||||
"typescript-eslint": "8.59.3",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.5",
|
||||
"vitest": "4.1.6",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
},
|
||||
"resolutions": {
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
|
||||
@@ -18,7 +18,46 @@
|
||||
"enabled": true,
|
||||
"schedule": ["on the 19th day of the month before 4am"]
|
||||
},
|
||||
"customDatasources": {
|
||||
"ha-core-python": {
|
||||
"defaultRegistryUrlTemplate": "https://raw.githubusercontent.com/home-assistant/core/dev/.python-version",
|
||||
"format": "plain"
|
||||
}
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"description": "Keep PYTHON_VERSION in sync with home-assistant/core (patch + minor)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/[^/]+\\.ya?ml$/"],
|
||||
"matchStrings": ["PYTHON_VERSION: \"(?<currentValue>[^\"]+)\""],
|
||||
"depNameTemplate": "python",
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python"
|
||||
},
|
||||
{
|
||||
"description": "Keep devcontainer image and requires-python in sync with home-assistant/core (minor only)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.devcontainer/Dockerfile$/",
|
||||
"/^pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"devcontainers/python:(?<currentValue>[\\d.]+)",
|
||||
"requires-python = \">=(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "python",
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python",
|
||||
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all Python version updates from home-assistant/core",
|
||||
"matchDepNames": ["python"],
|
||||
"matchDatasources": ["custom.ha-core-python"],
|
||||
"groupName": "Python version"
|
||||
},
|
||||
{
|
||||
"description": "MDC packages are pinned to the same version as MWC",
|
||||
"extends": ["monorepo:material-components-web"],
|
||||
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
// Checks that all production dependencies use approved open-source licenses.
|
||||
//
|
||||
// To allow a new license type, add its SPDX identifier to ALLOWED_LICENSES.
|
||||
// To allow a specific package that cannot be relicensed (e.g. a dual-license
|
||||
// package where the reported identifier is non-standard), add it to
|
||||
// ALLOWED_PACKAGES with a comment explaining why.
|
||||
|
||||
import { createRequire } from "module";
|
||||
import { fileURLToPath } from "url";
|
||||
import path from "path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const checker = require("license-checker-rseidelsohn");
|
||||
const root = path.resolve(fileURLToPath(import.meta.url), "../../");
|
||||
|
||||
// Permissive licenses that are compatible with distribution in a compiled wheel.
|
||||
// Copyleft licenses (GPL, LGPL, AGPL, EUPL, etc.) must NOT be added here.
|
||||
const ALLOWED_LICENSES = new Set([
|
||||
"MIT",
|
||||
"MIT*",
|
||||
"ISC",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSD*",
|
||||
"Apache-2.0",
|
||||
"0BSD",
|
||||
"CC0-1.0",
|
||||
"(MIT OR CC0-1.0)",
|
||||
"(MIT AND Zlib)",
|
||||
"Python-2.0", // argparse - Python Software Foundation License (permissive)
|
||||
"Public Domain",
|
||||
"W3C-20150513", // wicg-inert - W3C Software and Document License (permissive)
|
||||
"Unlicense",
|
||||
"CC-BY-4.0",
|
||||
]);
|
||||
|
||||
// Packages whose license identifier is ambiguous or non-standard but have been
|
||||
// manually verified as permissive. Add only when strictly necessary.
|
||||
const ALLOWED_PACKAGES = {
|
||||
// No entries currently needed.
|
||||
};
|
||||
|
||||
checker.init(
|
||||
{
|
||||
start: root,
|
||||
production: true,
|
||||
excludePrivatePackages: true,
|
||||
},
|
||||
(err, packages) => {
|
||||
if (err) {
|
||||
console.error("license-checker failed:", err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const violations = [];
|
||||
|
||||
for (const [nameAtVersion, info] of Object.entries(packages)) {
|
||||
if (nameAtVersion in ALLOWED_PACKAGES) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const license = info.licenses;
|
||||
|
||||
if (!ALLOWED_LICENSES.has(license)) {
|
||||
violations.push({ package: nameAtVersion, license });
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(
|
||||
"The following packages have licenses that are not on the allowlist:"
|
||||
);
|
||||
for (const { package: pkg, license } of violations) {
|
||||
console.error(` ${pkg}: ${license}`);
|
||||
}
|
||||
console.error(`
|
||||
If the license is permissive and appropriate for distribution, add it
|
||||
to ALLOWED_LICENSES in script/check-licenses. If it is a specific
|
||||
package with an ambiguous identifier, add it to ALLOWED_PACKAGES.
|
||||
|
||||
Do NOT add copyleft licenses (GPL, LGPL, AGPL, etc.) to the allowlist.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const count = Object.keys(packages).length;
|
||||
console.log(
|
||||
`License check passed: all ${count} production dependencies use approved licenses.`
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -54,6 +54,8 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
@query("ha-auth-form") private _form?: HaAuthForm;
|
||||
|
||||
@query("ha-form") private _haForm?: HTMLElement;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -160,9 +162,8 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
// 100ms to give all the form elements time to initialize.
|
||||
setTimeout(() => {
|
||||
const form = this.renderRoot.querySelector("ha-form");
|
||||
if (form) {
|
||||
(form as any).focus();
|
||||
if (this._haForm) {
|
||||
(this._haForm as any).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @param arr - The array to get combinations of
|
||||
* @returns A multidimensional array of all possible combinations
|
||||
*/
|
||||
export function getAllCombinations<T>(arr: T[]) {
|
||||
export function getAllCombinations<T>(arr: readonly T[]): T[][] {
|
||||
return arr.reduce<T[][]>(
|
||||
(combinations, element) =>
|
||||
combinations.concat(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { isComponentLoaded } from "./is_component_loaded";
|
||||
|
||||
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
(isCore(page) || isLoadedIntegration(hass, page)) &&
|
||||
!hideAdvancedPage(hass, page) &&
|
||||
isNotLoadedIntegration(hass, page);
|
||||
|
||||
export const isLoadedIntegration = (
|
||||
@@ -27,8 +26,3 @@ export const isNotLoadedIntegration = (
|
||||
);
|
||||
|
||||
export const isCore = (page: PageNavigation) => page.core;
|
||||
export const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
|
||||
export const userWantsAdvanced = (hass: HomeAssistant) =>
|
||||
hass.userData?.showAdvanced;
|
||||
export const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
isAdvancedPage(page) && !userWantsAdvanced(hass);
|
||||
|
||||
@@ -114,6 +114,15 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
|
||||
export const UNIT_C = "°C";
|
||||
export const UNIT_F = "°F";
|
||||
|
||||
/** Length units. */
|
||||
export const UNIT_IN = "in";
|
||||
export const UNIT_KM = "km";
|
||||
export const UNIT_MM = "mm";
|
||||
|
||||
/** Pressure units. */
|
||||
export const UNIT_HPA = "hPa";
|
||||
export const UNIT_INHG = "inHg";
|
||||
|
||||
/** Entity ID of the default view. */
|
||||
export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { setupConditionListeners } from "../condition/listeners";
|
||||
|
||||
/**
|
||||
* Reactive controller that manages the media-query and time-based listeners
|
||||
* needed to keep a set of lovelace visibility conditions evaluated live.
|
||||
*
|
||||
* The host is responsible for the actual evaluation (e.g. computing visible /
|
||||
* hidden / invalid state); the controller only triggers it via the supplied
|
||||
* `onUpdate` callback when something the conditions depend on changes. Call
|
||||
* `setup()` whenever the conditions change; the controller clears previous
|
||||
* listeners and re-subscribes. Listeners are automatically released when the
|
||||
* host disconnects.
|
||||
*/
|
||||
export class ConditionListenersController implements ReactiveController {
|
||||
private _unsubs: (() => void)[] = [];
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public setup(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
onUpdate: () => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this.clear();
|
||||
if (!conditions.length) {
|
||||
return;
|
||||
}
|
||||
setupConditionListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => this._unsubs.push(unsub),
|
||||
() => onUpdate(),
|
||||
getContext
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,17 @@
|
||||
import {
|
||||
mdiBattery,
|
||||
mdiBattery10,
|
||||
mdiBattery20,
|
||||
mdiBattery30,
|
||||
mdiBattery40,
|
||||
mdiBattery50,
|
||||
mdiBattery60,
|
||||
mdiBattery70,
|
||||
mdiBattery80,
|
||||
mdiBattery90,
|
||||
mdiBatteryAlertVariantOutline,
|
||||
mdiBatteryUnknown,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
|
||||
const BATTERY_ICONS = {
|
||||
@@ -12,6 +26,18 @@ const BATTERY_ICONS = {
|
||||
90: "mdi:battery-90",
|
||||
100: "mdi:battery",
|
||||
};
|
||||
const BATTERY_ICON_PATHS = {
|
||||
10: mdiBattery10,
|
||||
20: mdiBattery20,
|
||||
30: mdiBattery30,
|
||||
40: mdiBattery40,
|
||||
50: mdiBattery50,
|
||||
60: mdiBattery60,
|
||||
70: mdiBattery70,
|
||||
80: mdiBattery80,
|
||||
90: mdiBattery90,
|
||||
100: mdiBattery,
|
||||
};
|
||||
const BATTERY_CHARGING_ICONS = {
|
||||
10: "mdi:battery-charging-10",
|
||||
20: "mdi:battery-charging-20",
|
||||
@@ -57,3 +83,15 @@ export const batteryLevelIcon = (
|
||||
}
|
||||
return BATTERY_ICONS[batteryRound];
|
||||
};
|
||||
|
||||
export const batteryLevelIconPath = (batteryLevel: number | string): string => {
|
||||
const batteryValue = Number(batteryLevel);
|
||||
if (isNaN(batteryValue)) {
|
||||
return mdiBatteryUnknown;
|
||||
}
|
||||
if (batteryValue <= 5) {
|
||||
return mdiBatteryAlertVariantOutline;
|
||||
}
|
||||
const batteryRound = Math.round(batteryValue / 10) * 10;
|
||||
return BATTERY_ICON_PATHS[batteryRound];
|
||||
};
|
||||
|
||||
@@ -137,7 +137,10 @@ export const computeEntityPickerDisplay = (
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(hass);
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isUnavailableState } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface EntityUnitStubConfig {
|
||||
@@ -21,32 +21,24 @@ export const computeEntityUnitDisplay = (
|
||||
stateObj: HassEntity | undefined,
|
||||
config: EntityUnitStubConfig
|
||||
): string => {
|
||||
let unit;
|
||||
if (
|
||||
stateObj &&
|
||||
!isUnavailableState(stateObj.state) &&
|
||||
(config.attribute || stateObj.attributes.device_class !== "duration")
|
||||
!stateObj ||
|
||||
stateObj.state === UNAVAILABLE ||
|
||||
stateObj.state === UNKNOWN ||
|
||||
(!config.attribute && stateObj.attributes.device_class === "duration")
|
||||
) {
|
||||
// check for an explicitly defined unit in config
|
||||
unit = config.unit;
|
||||
|
||||
if (!unit) {
|
||||
if (!config.attribute) {
|
||||
// use entity's unit_of_measurement
|
||||
const stateParts = hass.formatEntityStateToParts(stateObj);
|
||||
unit = stateParts.find((part) => part.type === "unit")?.value;
|
||||
} else {
|
||||
// use attribute's unit if available
|
||||
const attrParts = hass.formatEntityAttributeValueToParts(
|
||||
stateObj,
|
||||
config.attribute
|
||||
);
|
||||
unit = attrParts.find((part) => part.type === "unit")?.value;
|
||||
}
|
||||
}
|
||||
|
||||
return unit ?? "";
|
||||
return "";
|
||||
}
|
||||
|
||||
return "";
|
||||
// check for an explicitly defined unit in config
|
||||
if (config.unit) {
|
||||
return config.unit;
|
||||
}
|
||||
|
||||
// otherwise derive from the entity's state or attribute
|
||||
const parts = config.attribute
|
||||
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
|
||||
: hass.formatEntityStateToParts(stateObj);
|
||||
|
||||
return parts.find((part) => part.type === "unit")?.value ?? "";
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE_STATES } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { stringCompare } from "../string/compare";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
@@ -253,7 +253,7 @@ export const getStatesDomain = (
|
||||
|
||||
if (!attribute) {
|
||||
// All entities can have unavailable states
|
||||
result.push(...UNAVAILABLE_STATES);
|
||||
result.push(UNAVAILABLE, UNKNOWN);
|
||||
}
|
||||
|
||||
if (!attribute && domain in FIXED_DOMAIN_STATES) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
@@ -8,14 +8,20 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
const validState = states.some(
|
||||
(stateObj) => !isUnavailableState(stateObj.state)
|
||||
const allUnavailable = states.every(
|
||||
(stateObj) => stateObj.state === UNAVAILABLE
|
||||
);
|
||||
|
||||
if (!validState) {
|
||||
if (allUnavailable) {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
const hasValidState = states.some(
|
||||
(stateObj) => stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
|
||||
);
|
||||
if (!hasValidState) {
|
||||
return UNKNOWN;
|
||||
}
|
||||
|
||||
// Use the first state to determine the domain
|
||||
// This assumes all states in the group have the same domain
|
||||
const domain = computeStateDomain(states[0]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
|
||||
export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
@@ -19,7 +19,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
return compareState !== UNAVAILABLE;
|
||||
}
|
||||
|
||||
if (isUnavailableState(compareState)) {
|
||||
if (compareState === UNAVAILABLE || compareState === UNKNOWN) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* @summary Truncates a string to `maxLength`, appending `ellipsis` only when it actually shortens the result.
|
||||
* @param text The input string.
|
||||
* @param maxLength Maximum length of the prefix kept before the ellipsis.
|
||||
* @param ellipsis Suffix appended when truncation occurs.
|
||||
* @returns `text` unchanged when its length is `<= maxLength + ellipsis.length`, otherwise `text.substring(0, maxLength) + ellipsis`.
|
||||
*/
|
||||
export const truncateWithEllipsis = (
|
||||
text: string,
|
||||
maxLength: number,
|
||||
ellipsis = "..."
|
||||
): string => {
|
||||
if (text.length <= maxLength + ellipsis.length) {
|
||||
return text;
|
||||
}
|
||||
return `${text.substring(0, maxLength)}${ellipsis}`;
|
||||
};
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { LitElement } from "lit";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HomeAssistant, Translation } from "../../types";
|
||||
|
||||
export function computeRTL(hass: HomeAssistant) {
|
||||
const lang = hass.language || "en";
|
||||
if (hass.translationMetadata.translations[lang]) {
|
||||
return hass.translationMetadata.translations[lang].isRTL || false;
|
||||
export function computeRTL(
|
||||
language = "en",
|
||||
translations: Record<string, Translation>
|
||||
) {
|
||||
if (translations[language]) {
|
||||
return translations[language].isRTL || false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeRTLDirection(hass: HomeAssistant) {
|
||||
return emitRTLDirection(computeRTL(hass));
|
||||
return emitRTLDirection(
|
||||
computeRTL(hass.language, hass.translationMetadata.translations)
|
||||
);
|
||||
}
|
||||
|
||||
export function emitRTLDirection(rtl: boolean) {
|
||||
|
||||
@@ -121,6 +121,7 @@ export class HaAutomationRowEventChip extends LitElement {
|
||||
align-items: center;
|
||||
--mdc-icon-size: 16px;
|
||||
line-height: 1;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
|
||||
|
||||
/**
|
||||
* @element ha-automation-row-live-test
|
||||
*
|
||||
* @summary
|
||||
* Small status indicator dot used in automation/condition rows to surface the
|
||||
* live evaluation result.
|
||||
*
|
||||
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
|
||||
* @attr {string} label - Accessible label announced by assistive technology.
|
||||
*/
|
||||
@customElement("ha-automation-row-live-test")
|
||||
export class HaAutomationRowLiveTest extends LitElement {
|
||||
@property({ reflect: true }) public state: LiveTestState = "unknown";
|
||||
|
||||
@property() public label = "";
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
id="indicator"
|
||||
role="status"
|
||||
tabindex="0"
|
||||
aria-label=${this.label}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
inset-inline-end: -6px;
|
||||
display: inline-block;
|
||||
}
|
||||
#indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
border: 3px solid;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--card-background-color);
|
||||
transition: all var(--ha-animation-duration-normal) ease-in-out;
|
||||
}
|
||||
:host([state="pass"]) #indicator {
|
||||
background-color: var(--ha-color-fill-success-loud-resting);
|
||||
border-color: var(--ha-color-fill-success-loud-resting);
|
||||
}
|
||||
:host([state="fail"]) #indicator {
|
||||
border-color: var(--ha-color-fill-warning-loud-resting);
|
||||
}
|
||||
:host([state="invalid"]) #indicator {
|
||||
border-color: var(--ha-color-fill-danger-loud-resting);
|
||||
}
|
||||
:host([state="unknown"]) #indicator {
|
||||
border-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-row-live-test": HaAutomationRowLiveTest;
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,7 @@ export class HaAutomationRow extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
@@ -186,7 +187,6 @@ export class HaAutomationRow extends LitElement {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0 var(--ha-space-3);
|
||||
}
|
||||
::slotted([slot="header"]) {
|
||||
overflow-wrap: anywhere;
|
||||
@@ -194,7 +194,6 @@ export class HaAutomationRow extends LitElement {
|
||||
}
|
||||
::slotted([slot="event"]) {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
.icons {
|
||||
|
||||
@@ -116,7 +116,7 @@ export class HaProgressButton extends LitElement {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
ha-svg-icon {
|
||||
:host([appearance="brand"]) ha-svg-icon {
|
||||
color: var(--white-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
|
||||
* with the displayed content.
|
||||
*/
|
||||
export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
@@ -19,7 +19,7 @@ import type {
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
@@ -102,6 +102,8 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
@state() private _hiddenDatasets = new Set<string>();
|
||||
|
||||
@query(".chart") private _chartContainer?: HTMLDivElement;
|
||||
|
||||
private _modifierPressed = false;
|
||||
|
||||
private _isTouchDevice = "ontouchstart" in window;
|
||||
@@ -469,7 +471,6 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private async _setupChart() {
|
||||
if (this._loading) return;
|
||||
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
|
||||
this._loading = true;
|
||||
try {
|
||||
if (this.chart) {
|
||||
@@ -484,7 +485,7 @@ export class HaChartBase extends LitElement {
|
||||
const style = getComputedStyle(this);
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
|
||||
this.chart = echarts.init(container, "custom");
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this.chart.on("datazoom", (e: any) => {
|
||||
this._handleDataZoomEvent(e);
|
||||
});
|
||||
|
||||
@@ -11,6 +11,8 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -116,9 +118,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
private _previousYAxisLabelValue = 0;
|
||||
|
||||
private _yAxisMaximumFractionDigits = 0;
|
||||
private _yAxisFractionDigits = 1;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
@@ -293,7 +293,10 @@ export class StateHistoryChartLine extends LitElement {
|
||||
(changedProps.has("hass") &&
|
||||
this._hasEntityStatesChanged(changedProps.get("hass")))
|
||||
) {
|
||||
const rtl = computeRTL(this.hass);
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
let minYAxis: number | ((values: { min: number }) => number) | undefined =
|
||||
this.minYAxis;
|
||||
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
|
||||
@@ -410,8 +413,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -433,6 +435,14 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
const entityIds: string[] = [];
|
||||
const datasetToDataIndex: number[] = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
if (entityStates.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -468,6 +478,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
d.data!.push([timestamp, prevValues[i]]);
|
||||
}
|
||||
d.data!.push([timestamp, datavalues[i]]);
|
||||
trackY(datavalues[i]);
|
||||
});
|
||||
prevValues = datavalues;
|
||||
};
|
||||
@@ -818,6 +829,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
|
||||
if (currentValue !== null) {
|
||||
data[0].data!.push([now, currentValue]);
|
||||
trackY(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -825,6 +837,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
Array.prototype.push.apply(datasets, data);
|
||||
});
|
||||
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._entityIds = entityIds;
|
||||
this._datasetToDataIndex = datasetToDataIndex;
|
||||
@@ -858,20 +871,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
}
|
||||
|
||||
private _formatYAxisLabel = (value: number) => {
|
||||
// show the first significant digit for tiny values
|
||||
const maximumFractionDigits = Math.max(
|
||||
1,
|
||||
// use the difference to the previous value to determine the number of significant digits #25526
|
||||
-Math.floor(
|
||||
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
||||
)
|
||||
);
|
||||
this._yAxisMaximumFractionDigits = Math.max(
|
||||
this._yAxisMaximumFractionDigits,
|
||||
maximumFractionDigits
|
||||
);
|
||||
const label = formatNumber(value, this.hass.locale, {
|
||||
maximumFractionDigits: this._yAxisMaximumFractionDigits,
|
||||
maximumFractionDigits: this._yAxisFractionDigits,
|
||||
});
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
@@ -881,7 +882,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
this._previousYAxisLabelValue = value;
|
||||
return label;
|
||||
};
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
@@ -144,7 +145,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
"ui.components.history_charts.duration"
|
||||
)}: ${millisecondsToDuration(durationInMs)}`;
|
||||
|
||||
const markerLocalized = !computeRTL(this.hass)
|
||||
const markerLocalized = !computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? marker
|
||||
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
|
||||
|
||||
@@ -167,11 +171,12 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("data") ||
|
||||
this._chartTime <
|
||||
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES)
|
||||
this.isConnected &&
|
||||
(changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("data") ||
|
||||
this._chartTime <
|
||||
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES))
|
||||
) {
|
||||
// If the line is more than 5 minutes old, re-gen it
|
||||
// so the X axis grows even if there is no new data
|
||||
@@ -198,7 +203,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(this.hass);
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -256,8 +264,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiRestart } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import type {
|
||||
@@ -104,6 +110,11 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@state() private _hasZoomedCharts = false;
|
||||
|
||||
@queryAll("state-history-chart-line, state-history-chart-timeline")
|
||||
private _chartComponents!: NodeListOf<
|
||||
StateHistoryChartLine | StateHistoryChartTimeline
|
||||
>;
|
||||
|
||||
private _isSyncing = false;
|
||||
|
||||
// @ts-ignore
|
||||
@@ -327,11 +338,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
this._isSyncing = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const chartComponents = this.renderRoot.querySelectorAll(
|
||||
"state-history-chart-line, state-history-chart-timeline"
|
||||
) as unknown as (StateHistoryChartLine | StateHistoryChartTimeline)[];
|
||||
|
||||
chartComponents.forEach((chartComponent, index) => {
|
||||
this._chartComponents.forEach((chartComponent, index) => {
|
||||
if (index === sourceChartIndex) {
|
||||
return;
|
||||
}
|
||||
@@ -350,11 +357,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
this._isSyncing = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const chartComponents = this.renderRoot.querySelectorAll(
|
||||
"state-history-chart-line, state-history-chart-timeline"
|
||||
);
|
||||
|
||||
chartComponents.forEach((chartComponent: any) => {
|
||||
this._chartComponents.forEach((chartComponent: any) => {
|
||||
const chartBase =
|
||||
chartComponent.renderRoot?.querySelector("ha-chart-base");
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
import { formatDate } from "../../common/datetime/format_date";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
|
||||
import {
|
||||
formatNumber,
|
||||
getNumberFormatOptions,
|
||||
@@ -37,7 +39,9 @@ import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -128,7 +132,7 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
private _computedStyle?: CSSStyleDeclaration;
|
||||
|
||||
private _previousYAxisLabelValue = 0;
|
||||
private _yAxisFractionDigits = 1;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
return changedProps.size > 1 || !changedProps.has("hass");
|
||||
@@ -140,7 +144,8 @@ export class StatisticsChart extends LitElement {
|
||||
changedProps.has("statTypes") ||
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats")
|
||||
changedProps.has("_hiddenStats") ||
|
||||
changedProps.has("names")
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
@@ -241,6 +246,8 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
private _renderTooltip = (params: any) => {
|
||||
const rendered: Record<string, boolean> = {};
|
||||
const chartIsBar = this.chartType.startsWith("bar");
|
||||
const period = this.period;
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
@@ -252,8 +259,67 @@ export class StatisticsChart extends LitElement {
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const entry = this.hass.entities[statisticId];
|
||||
// max series can have 3 values, as the second value is the max-min to form a band
|
||||
const rawValue = String(param.value[2] ?? param.value[1]);
|
||||
let rawValue: string;
|
||||
let rawTime: string;
|
||||
if (chartIsBar) {
|
||||
// For bar charts value is always second value.
|
||||
rawValue = String(param.value[1]);
|
||||
// Time value is third value (un-shifted date) if given, otherwise first value
|
||||
let startTime: Date;
|
||||
let endTime: Date | undefined;
|
||||
if (param.value[2]) {
|
||||
startTime = new Date(param.value[2]);
|
||||
if (param.value[3]) {
|
||||
endTime = new Date(param.value[3]);
|
||||
}
|
||||
} else {
|
||||
startTime = new Date(param.value[0]);
|
||||
}
|
||||
if (
|
||||
period === "year" ||
|
||||
period === "month" ||
|
||||
period === "week" ||
|
||||
period === "day"
|
||||
) {
|
||||
// For year/month/day periods, show only the date
|
||||
rawTime =
|
||||
formatDate(startTime, this.hass.locale, this.hass.config) +
|
||||
(endTime && period !== "day"
|
||||
? ` – ${formatDate(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "") +
|
||||
"<br>";
|
||||
} else {
|
||||
// For other time periods, include time in render, and optionally show range
|
||||
// if we have an end time.
|
||||
rawTime =
|
||||
formatDateTimeWithSeconds(
|
||||
startTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) +
|
||||
(endTime
|
||||
? ` – ${formatTimeWithSeconds(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "") +
|
||||
"<br>";
|
||||
}
|
||||
} else {
|
||||
// For lines max series can have 3 values, as the second value is the max-min to form a band
|
||||
rawValue = String(param.value[2] ?? param.value[1]);
|
||||
// Time value is always first value
|
||||
rawTime = `${formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)} <br>`;
|
||||
}
|
||||
|
||||
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||
maximumFractionDigits: 2,
|
||||
@@ -265,14 +331,7 @@ export class StatisticsChart extends LitElement {
|
||||
options
|
||||
)}${unit}`;
|
||||
|
||||
const time =
|
||||
index === 0
|
||||
? formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) + "<br>"
|
||||
: "";
|
||||
const time = index === 0 ? rawTime : "";
|
||||
return `${time}${param.marker} ${param.seriesName}: ${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
@@ -368,7 +427,12 @@ export class StatisticsChart extends LitElement {
|
||||
nameTextStyle: {
|
||||
align: "left",
|
||||
},
|
||||
position: computeRTL(this.hass) ? "right" : "left",
|
||||
position: computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "right"
|
||||
: "left",
|
||||
scale:
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
@@ -398,8 +462,7 @@ export class StatisticsChart extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
position: sideTooltipPosition,
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -434,6 +497,14 @@ export class StatisticsChart extends LitElement {
|
||||
const chartStacked = this.chartType.endsWith("stack");
|
||||
const statisticsData = Object.entries(this.statisticsData);
|
||||
const totalDataSets: typeof this._chartData = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
const legendData: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -506,33 +577,57 @@ export class StatisticsChart extends LitElement {
|
||||
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
const statLegendData: typeof legendData = [];
|
||||
|
||||
// Place bars at centre of their specified time range if this is a bar chart
|
||||
// and the period is 5minute or hour.
|
||||
const centerBars =
|
||||
chartType === "bar" &&
|
||||
(this.period === "5minute" || this.period === "hour");
|
||||
|
||||
const pushData = (
|
||||
start: Date,
|
||||
end: Date,
|
||||
start: Date, // Data point start time
|
||||
end: Date, // Data point end time
|
||||
limit: Date, // Limit for end time (e.g. now)
|
||||
dataValues: (number | null)[][]
|
||||
) => {
|
||||
if (!dataValues.length) return;
|
||||
if (start > end) {
|
||||
// Limit for time range is lesser of overall limit and data point end
|
||||
limit = end.getTime() < limit.getTime() ? end : limit;
|
||||
if (start.getTime() > limit.getTime()) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
statDataSets.forEach((d, i) => {
|
||||
if (
|
||||
chartType === "line" &&
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
if (chartType === "line") {
|
||||
if (
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
|
||||
// the last element. For regular rows it's [value]. Same call works.
|
||||
trackY(dataValues[i][dataValues[i].length - 1]);
|
||||
} else {
|
||||
let time = start;
|
||||
if (centerBars) {
|
||||
// If centering bars, set the time to the midpoint between start and end instead
|
||||
// of the start time.
|
||||
time = new Date((start.getTime() + end.getTime()) / 2);
|
||||
}
|
||||
// Data value should always be a scalar for bar charts. Pass in
|
||||
// real start time as extra value to allow formatting tooltip.
|
||||
d.data!.push([time, dataValues[i][0]!, start, end]);
|
||||
trackY(dataValues[i][0]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
});
|
||||
prevValues = dataValues;
|
||||
prevEndTime = end;
|
||||
prevEndTime = limit;
|
||||
};
|
||||
|
||||
let color = colors[statistic_id];
|
||||
@@ -692,11 +787,7 @@ export class StatisticsChart extends LitElement {
|
||||
dataValues.push(val);
|
||||
});
|
||||
if (!this._hiddenStats.has(statistic_id)) {
|
||||
pushData(
|
||||
startDate,
|
||||
endDate.getTime() < endTime.getTime() ? endDate : endTime,
|
||||
dataValues
|
||||
);
|
||||
pushData(startDate, endDate, endTime, dataValues);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -745,6 +836,7 @@ export class StatisticsChart extends LitElement {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
trackY(val[val.length - 1]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -778,6 +870,7 @@ export class StatisticsChart extends LitElement {
|
||||
});
|
||||
});
|
||||
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = totalDataSets;
|
||||
if (legendData.length !== this._legendData?.length) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
@@ -811,21 +904,10 @@ export class StatisticsChart extends LitElement {
|
||||
return Math.abs(value) < 1 ? value : roundingFn(value);
|
||||
}
|
||||
|
||||
private _formatYAxisLabel = (value: number) => {
|
||||
// show the first significant digit for tiny values
|
||||
const maximumFractionDigits = Math.max(
|
||||
1,
|
||||
// use the difference to the previous value to determine the number of significant digits #25526
|
||||
-Math.floor(
|
||||
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
||||
)
|
||||
);
|
||||
const label = formatNumber(value, this.hass.locale, {
|
||||
maximumFractionDigits,
|
||||
private _formatYAxisLabel = (value: number) =>
|
||||
formatNumber(value, this.hass.locale, {
|
||||
maximumFractionDigits: this._yAxisFractionDigits,
|
||||
});
|
||||
this._previousYAxisLabelValue = value;
|
||||
return label;
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We estimate the tick interval as `range / 10` (twice
|
||||
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
|
||||
// intervals), then derive `ceil(-log10(interval))`.
|
||||
export function computeYAxisFractionDigits(min: number, max: number): number {
|
||||
const range = max - min;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
|
||||
}
|
||||
@@ -127,7 +127,6 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${localize("ui.components.data-table.settings.header")}
|
||||
@closed=${this._dialogClosed}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
@@ -17,40 +18,163 @@ import "../ha-label";
|
||||
class HaDataTableLabels extends LitElement {
|
||||
@property({ attribute: false }) public labels!: LabelRegistryEntry[];
|
||||
|
||||
@state() private _visibleCount = 0;
|
||||
|
||||
@query(".viewport") private _viewport?: HTMLDivElement;
|
||||
@query(".measure") private _measure?: HTMLDivElement;
|
||||
|
||||
private _sortedLabels: LabelRegistryEntry[] = [];
|
||||
|
||||
private _chipWidths: number[] = [];
|
||||
private _plusWidth = 0;
|
||||
private _gap = 8;
|
||||
|
||||
private _resizeController = new ResizeController(this, {
|
||||
target: null,
|
||||
skipInitial: true,
|
||||
callback: (entries) => {
|
||||
const entry = entries[0];
|
||||
const width = entry?.contentRect.width ?? 0;
|
||||
this._recomputeVisibleCount(width);
|
||||
return width;
|
||||
},
|
||||
});
|
||||
|
||||
protected willUpdate(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has("labels")) {
|
||||
this._sortedLabels = [...this.labels].sort((a, b) =>
|
||||
stringCompare(a.name, b.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const labels = this.labels.sort((a, b) => stringCompare(a.name, b.name));
|
||||
const labels = this._sortedLabels;
|
||||
const visible = labels.slice(0, this._visibleCount);
|
||||
const hidden = labels.length - this._visibleCount;
|
||||
|
||||
return html`
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
labels.slice(0, 2),
|
||||
(label) => label.label_id,
|
||||
(label) => this._renderLabel(label, true)
|
||||
)}
|
||||
${labels.length > 2
|
||||
? html`<ha-dropdown
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${stopPropagation}
|
||||
@wa-select=${this._handleDropdownSelect}
|
||||
>
|
||||
<ha-label slot="trigger" class="plus" dense>
|
||||
+${labels.length - 2}
|
||||
</ha-label>
|
||||
${repeat(
|
||||
labels.slice(2),
|
||||
(label) => label.label_id,
|
||||
(label) => html`
|
||||
<ha-dropdown-item .value=${label.label_id} .item=${label}>
|
||||
${this._renderLabel(label, false)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
)}
|
||||
</ha-dropdown>`
|
||||
: nothing}
|
||||
</ha-chip-set>
|
||||
<div class="viewport">
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
visible,
|
||||
(label) => label.label_id,
|
||||
(label) => this._renderLabel(label, true)
|
||||
)}
|
||||
${hidden > 0
|
||||
? html`
|
||||
<ha-dropdown
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${stopPropagation}
|
||||
@wa-select=${this._handleDropdownSelect}
|
||||
>
|
||||
<ha-label slot="trigger" class="plus" dense>
|
||||
+${hidden}
|
||||
</ha-label>
|
||||
${repeat(
|
||||
labels.slice(this._visibleCount),
|
||||
(label) => label.label_id,
|
||||
(label) => html`
|
||||
<ha-dropdown-item .value=${label.label_id} .item=${label}>
|
||||
${this._renderLabel(label, false)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
)}
|
||||
</ha-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
</ha-chip-set>
|
||||
</div>
|
||||
|
||||
<div class="measure" aria-hidden="true">
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
labels,
|
||||
(label) => label.label_id,
|
||||
(label) => html`
|
||||
<div class="measure-chip" data-chip>
|
||||
${this._renderLabel(label, false)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div class="measure-chip" data-plus>
|
||||
<ha-label class="plus" dense>+99</ha-label>
|
||||
</div>
|
||||
</ha-chip-set>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected async firstUpdated() {
|
||||
await this.updateComplete;
|
||||
if (this._viewport) {
|
||||
this._resizeController.observe(this._viewport);
|
||||
}
|
||||
await this._measureWidths();
|
||||
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
|
||||
}
|
||||
|
||||
protected async updated(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has("labels")) {
|
||||
await this.updateComplete;
|
||||
await this._measureWidths();
|
||||
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
private async _measureWidths() {
|
||||
await this.updateComplete;
|
||||
|
||||
const measureRoot = this._measure;
|
||||
if (!measureRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measureChipSet = measureRoot.querySelector("ha-chip-set");
|
||||
if (measureChipSet) {
|
||||
const styles = getComputedStyle(measureChipSet);
|
||||
const raw = styles.columnGap || styles.gap;
|
||||
this._gap = raw ? parseFloat(raw) : 0;
|
||||
}
|
||||
|
||||
const chipEls = Array.from(
|
||||
measureRoot.querySelectorAll<HTMLElement>("[data-chip]")
|
||||
);
|
||||
const plusEl = measureRoot.querySelector<HTMLElement>("[data-plus]");
|
||||
|
||||
this._chipWidths = chipEls.map((el) => el.offsetWidth);
|
||||
this._plusWidth = plusEl?.offsetWidth ?? 0;
|
||||
}
|
||||
|
||||
private _recomputeVisibleCount(containerWidth: number) {
|
||||
if (!containerWidth || !this.labels?.length) {
|
||||
this._visibleCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const total = this._sortedLabels.length;
|
||||
|
||||
let used = 0;
|
||||
let visibleCount = 0;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const chipWidth = this._chipWidths[i] ?? 0;
|
||||
const nextUsed =
|
||||
visibleCount === 0 ? chipWidth : used + this._gap + chipWidth;
|
||||
const remaining = total - (i + 1);
|
||||
const reserve = remaining > 0 ? this._gap + this._plusWidth : 0;
|
||||
|
||||
if (nextUsed + reserve <= containerWidth) {
|
||||
used = nextUsed;
|
||||
visibleCount++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._visibleCount = visibleCount;
|
||||
}
|
||||
|
||||
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
|
||||
return html`
|
||||
<ha-label
|
||||
@@ -93,13 +217,43 @@ class HaDataTableLabels extends LitElement {
|
||||
:host {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
height: 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.measure {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.measure ha-chip-set {
|
||||
width: max-content;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.measure-chip {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.plus {
|
||||
--ha-label-background-color: transparent;
|
||||
border: 1px solid var(--divider-color);
|
||||
|
||||
@@ -24,6 +24,7 @@ import "../ha-icon-button";
|
||||
import "../ha-icon-button-next";
|
||||
import "../ha-icon-button-prev";
|
||||
import "../ha-textarea";
|
||||
import type { HaTextArea } from "../ha-textarea";
|
||||
import "./date-range-picker";
|
||||
|
||||
export type DateRangePickerRanges = Record<string, [Date, Date]>;
|
||||
@@ -98,6 +99,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
@query(".container") private _containerElement?: HTMLDivElement;
|
||||
|
||||
@query("ha-textarea") private _textareaElement?: HaTextArea;
|
||||
|
||||
private _narrow = false;
|
||||
|
||||
private _unsubscribeTinyKeys?: () => void;
|
||||
@@ -335,9 +338,8 @@ export class HaDateRangePicker extends LitElement {
|
||||
};
|
||||
|
||||
private _setTextareaFocusStyle(focused: boolean) {
|
||||
const textarea = this.renderRoot.querySelector("ha-textarea");
|
||||
if (textarea) {
|
||||
textarea.setFocused(focused);
|
||||
if (this._textareaElement) {
|
||||
this._textareaElement.setFocused(focused);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
@@ -12,7 +14,7 @@ import {
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-generic-picker";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
@@ -46,13 +48,14 @@ export abstract class HaDeviceAutomationPicker<
|
||||
}
|
||||
|
||||
private _localizeDeviceAutomation: (
|
||||
hass: HomeAssistant,
|
||||
localize: LocalizeFunc,
|
||||
states: HassEntities,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automation: T
|
||||
) => string;
|
||||
|
||||
private _fetchDeviceAutomations: (
|
||||
hass: HomeAssistant,
|
||||
callWS: CallWS,
|
||||
deviceId: string
|
||||
) => Promise<T[]>;
|
||||
|
||||
@@ -127,7 +130,8 @@ export abstract class HaDeviceAutomationPicker<
|
||||
|
||||
const automationListItems = automations.map((automation, idx) => {
|
||||
const primary = this._localizeDeviceAutomation(
|
||||
this.hass,
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this._entityReg,
|
||||
automation
|
||||
);
|
||||
@@ -162,7 +166,12 @@ export abstract class HaDeviceAutomationPicker<
|
||||
);
|
||||
|
||||
const text = automation
|
||||
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
|
||||
? this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this._entityReg,
|
||||
automation
|
||||
)
|
||||
: value === NO_AUTOMATION_KEY
|
||||
? this.NO_AUTOMATION_TEXT
|
||||
: value;
|
||||
@@ -172,9 +181,9 @@ export abstract class HaDeviceAutomationPicker<
|
||||
|
||||
private async _updateDeviceInfo() {
|
||||
this._automations = this.deviceId
|
||||
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
|
||||
sortDeviceAutomations
|
||||
)
|
||||
? (
|
||||
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
|
||||
).sort(sortDeviceAutomations)
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiPlus, mdiShape } from "@mdi/js";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
@@ -20,17 +20,20 @@ import {
|
||||
} from "../../panels/config/helpers/const";
|
||||
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import "../ha-icon";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import "../ha-picker-field";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
import "../ha-picker-popover";
|
||||
import "../ha-picker-search-list";
|
||||
import type {
|
||||
HaPickerSearchList,
|
||||
PickerSearchFn,
|
||||
} from "../ha-picker-search-list";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-badge";
|
||||
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
@customElement("ha-entity-picker")
|
||||
export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -122,15 +125,17 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@property({ attribute: "add-button", type: Boolean })
|
||||
public addButton = false;
|
||||
@query(".trigger") private _trigger?: HTMLElement;
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
@query("ha-picker-search-list") private _searchList?: HaPickerSearchList;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@state() private _pickerOpen = false;
|
||||
|
||||
@state() private _pendingEntityId?: string;
|
||||
|
||||
// Commit fires on @closed (after the hide animation) to avoid flicker.
|
||||
private _pendingValue?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
@@ -145,7 +150,7 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Load title translations so it is available when the combo-box opens
|
||||
// Preload title translations so they're ready when the dropdown opens.
|
||||
this.hass.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
@@ -275,40 +280,48 @@ export class HaEntityPicker extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _getAdditionalItems = () =>
|
||||
this._getCreateItems(this.hass.localize, this.createDomains);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
private _getCreateActions = memoizeOne(
|
||||
(
|
||||
localize: this["hass"]["localize"],
|
||||
createDomains: this["createDomains"]
|
||||
) => {
|
||||
): EntityComboBoxItem[] => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
return createDomains.map((domain) => {
|
||||
const primary = localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||||
) || domain
|
||||
: domainToName(localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
return createDomains.map((domain) => ({
|
||||
id: `__create-helper__${domain}`,
|
||||
primary: localize("ui.components.entity.entity-picker.create_helper", {
|
||||
domain: isHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||||
) || domain
|
||||
: domainToName(localize, domain),
|
||||
}),
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
onSelect: ({ close }) => {
|
||||
close();
|
||||
this._openCreateHelper(domain);
|
||||
},
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _openCreateHelper(domain: string) {
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (!item.entityId) return;
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||
|
||||
private _getItems = () => {
|
||||
@@ -341,53 +354,67 @@ export class HaEntityPicker extends LitElement {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.entity.entity-picker.placeholder");
|
||||
const items = this._getItems();
|
||||
const actions = this._getCreateActions(
|
||||
this.hass.localize,
|
||||
this.createDomains
|
||||
);
|
||||
const hideClearIcon = this.hideClearIcon || this._shouldHideClearIcon();
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.required=${this.required}
|
||||
.label=${this.label}
|
||||
.placeholder=${placeholder}
|
||||
.helper=${this.helper}
|
||||
.value=${this.addButton ? undefined : this.value}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.hideClearIcon=${this.hideClearIcon || this._shouldHideClearIcon()}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
use-top-label
|
||||
.addButtonLabel=${this.addButton
|
||||
? (this.addButtonLabel ??
|
||||
this.hass.localize("ui.components.entity.entity-picker.add"))
|
||||
: undefined}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.entity.entity-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
<div class="picker">
|
||||
<div class="trigger" @click=${this._openPicker}>
|
||||
<slot name="trigger">
|
||||
<ha-picker-field
|
||||
type="button"
|
||||
compact
|
||||
.label=${this.label}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.hideClearIcon=${hideClearIcon}
|
||||
?autofocus=${this.autofocus}
|
||||
@clear=${this._clear}
|
||||
></ha-picker-field>
|
||||
</slot>
|
||||
</div>
|
||||
<ha-picker-popover
|
||||
.open=${this._pickerOpen}
|
||||
.anchor=${this._trigger ?? null}
|
||||
.label=${this.label ?? ""}
|
||||
@closed=${this._handlePickerClosed}
|
||||
>
|
||||
<ha-picker-search-list
|
||||
autofocus
|
||||
.items=${items}
|
||||
.value=${this.value}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
.searchFn=${this._searchFn}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.actions=${actions}
|
||||
.searchPlaceholder=${this.searchLabel ??
|
||||
this.hass.localize("ui.common.search")}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
@item-selected=${this._handleItemSelected}
|
||||
></ha-picker-search-list>
|
||||
</ha-picker-popover>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
|
||||
private _searchFn: PickerSearchFn<EntityComboBoxItem> = (
|
||||
search,
|
||||
filteredItems
|
||||
) => {
|
||||
// If there is exact match for entity id, put it first
|
||||
const index = filteredItems.findIndex(
|
||||
(item) => item.stateObj?.entity_id === search
|
||||
);
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
@@ -395,46 +422,43 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
this._openPicker();
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
private _openPicker = () => {
|
||||
if (this.disabled) return;
|
||||
this._pickerOpen = true;
|
||||
};
|
||||
|
||||
private _handlePickerClosed = () => {
|
||||
if (this._pendingValue !== undefined) {
|
||||
const pending = this._pendingValue;
|
||||
this._pendingValue = undefined;
|
||||
this._setValue(pending);
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._searchList?.reset();
|
||||
};
|
||||
|
||||
private _handleItemSelected = (
|
||||
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith(CREATE_ID)) {
|
||||
const domain = value.substring(CREATE_ID.length);
|
||||
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const value = ev.detail.id;
|
||||
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
|
||||
this._pickerOpen = false;
|
||||
return;
|
||||
}
|
||||
this._pendingValue = value;
|
||||
this._pickerOpen = false;
|
||||
};
|
||||
|
||||
this._setValue(value);
|
||||
private _clear() {
|
||||
this._setValue(undefined);
|
||||
}
|
||||
|
||||
private _setValue(value: string | undefined) {
|
||||
this.value = value;
|
||||
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
}
|
||||
@@ -443,6 +467,18 @@ export class HaEntityPicker extends LitElement {
|
||||
this.hass.localize("ui.components.entity.entity-picker.no_match", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
ha-picker-field {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -6,11 +6,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { STATES_OFF } from "../../common/const";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import {
|
||||
UNAVAILABLE,
|
||||
UNKNOWN,
|
||||
isUnavailableState,
|
||||
} from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { forwardHaptic } from "../../data/haptics";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-formfield";
|
||||
@@ -20,7 +16,16 @@ import "../ha-switch";
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
!STATES_OFF.includes(stateObj.state) &&
|
||||
!isUnavailableState(stateObj.state);
|
||||
stateObj.state !== UNAVAILABLE &&
|
||||
stateObj.state !== UNKNOWN;
|
||||
|
||||
/**
|
||||
* @element ha-entity-toggle
|
||||
*
|
||||
* @cssprop --ha-entity-toggle-switch-width - Width of the switch track. Defaults to `38px`.
|
||||
* @cssprop --ha-entity-toggle-switch-size - Height of the switch track. Defaults to `20px`.
|
||||
* @cssprop --ha-entity-toggle-switch-thumb-size - Size of the switch thumb. Defaults to `14px`.
|
||||
*/
|
||||
|
||||
@customElement("ha-entity-toggle")
|
||||
export class HaEntityToggle extends LitElement {
|
||||
@@ -165,9 +170,9 @@ export class HaEntityToggle extends LitElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-switch {
|
||||
--ha-switch-width: 38px;
|
||||
--ha-switch-size: 20px;
|
||||
--ha-switch-thumb-size: 14px;
|
||||
--ha-switch-width: var(--ha-entity-toggle-switch-width, 38px);
|
||||
--ha-switch-size: var(--ha-entity-toggle-switch-size, 20px);
|
||||
--ha-switch-thumb-size: var(--ha-entity-toggle-switch-thumb-size, 14px);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
|
||||
@@ -9,7 +9,7 @@ import secondsToDuration from "../../common/datetime/seconds_to_duration";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
|
||||
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
|
||||
import { timerTimeRemaining } from "../../data/timer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -130,7 +130,6 @@ export class HaStateLabelBadge extends LitElement {
|
||||
? html`<ha-state-icon
|
||||
.icon=${this.icon}
|
||||
.stateObj=${entityState}
|
||||
.hass=${this.hass}
|
||||
></ha-state-icon>`
|
||||
: ""}
|
||||
${value && !image && !showIcon
|
||||
@@ -171,7 +170,8 @@ export class HaStateLabelBadge extends LitElement {
|
||||
}
|
||||
// eslint-disable-next-line: disable=no-fallthrough
|
||||
default:
|
||||
return isUnavailableState(entityState.state)
|
||||
return entityState.state === UNAVAILABLE ||
|
||||
entityState.state === UNKNOWN
|
||||
? "—"
|
||||
: this.hass!.formatEntityStateToParts(entityState).find(
|
||||
(part) => part.type === "value"
|
||||
@@ -210,7 +210,7 @@ export class HaStateLabelBadge extends LitElement {
|
||||
_timerTimeRemaining = 0
|
||||
) {
|
||||
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
|
||||
if (isUnavailableState(entityState.state)) {
|
||||
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
|
||||
return this.hass!.localize(`state_badge.default.${entityState.state}`);
|
||||
}
|
||||
const domainStateKey = getTruncatedKey(domain, entityState.state);
|
||||
|
||||
@@ -142,6 +142,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
private async _getStatisticIds() {
|
||||
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
|
||||
this._picker?.requestUpdate();
|
||||
this._valueRenderer = this._makeValueRenderer();
|
||||
}
|
||||
|
||||
private _getItems = () =>
|
||||
@@ -210,7 +211,10 @@ export class HaStatisticPicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
const isRTL = computeRTL(hass);
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const output: StatisticComboBoxItem[] = [];
|
||||
|
||||
@@ -314,7 +318,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value) => {
|
||||
private _renderValue(value: string) {
|
||||
const statisticId = value;
|
||||
|
||||
const item = this._computeItem(statisticId);
|
||||
@@ -338,7 +342,13 @@ export class HaStatisticPicker extends LitElement {
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
};
|
||||
}
|
||||
|
||||
private _makeValueRenderer(): PickerValueRenderer {
|
||||
return (value) => this._renderValue(value);
|
||||
}
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
|
||||
|
||||
private _computeItem(statisticId: string): StatisticComboBoxItem {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
@@ -353,7 +363,10 @@ export class HaStatisticPicker extends LitElement {
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
|
||||
@@ -98,7 +98,6 @@ export class StateBadge extends LitElement {
|
||||
const domain = stateObj ? computeStateDomain(stateObj) : undefined;
|
||||
|
||||
return html`<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
style=${styleMap(this._iconStyle)}
|
||||
data-domain=${ifDefined(domain)}
|
||||
data-state=${ifDefined(stateObj?.state)}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { listenMediaQuery } from "../common/dom/media_query";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-bottom-sheet";
|
||||
import "./ha-dialog-header";
|
||||
import "./ha-icon-button";
|
||||
@@ -82,8 +81,6 @@ export const ADAPTIVE_DIALOG_MEDIA_QUERY =
|
||||
*/
|
||||
@customElement("ha-adaptive-dialog")
|
||||
export class HaAdaptiveDialog extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: "aria-labelledby" })
|
||||
public ariaLabelledBy?: string;
|
||||
|
||||
@@ -202,7 +199,6 @@ export class HaAdaptiveDialog extends LitElement {
|
||||
.ariaLabelledBy=${this._defaultAriaLabelledBy}
|
||||
.ariaDescribedBy=${this.ariaDescribedBy}
|
||||
.flexContent=${this.flexContent}
|
||||
.hass=${this.hass}
|
||||
.open=${this.open}
|
||||
.preventScrimClose=${this.preventScrimClose}
|
||||
>
|
||||
@@ -221,7 +217,6 @@ export class HaAdaptiveDialog extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this.open}
|
||||
.type=${this.type}
|
||||
.width=${this.width}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { css, html, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { ScrollLockMixin } from "../mixins/scroll-lock-mixin";
|
||||
@@ -25,6 +25,8 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
|
||||
@state() private _shouldRenderPopover = false;
|
||||
|
||||
@query("wa-popover") private _popoverElement?: HTMLElement;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
changedProperties.has("dialogAnchor") ||
|
||||
@@ -188,7 +190,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
}
|
||||
|
||||
private _handlePopoverPointerDown(ev: PointerEvent) {
|
||||
const popover = this.renderRoot.querySelector("wa-popover");
|
||||
const popover = this._popoverElement;
|
||||
const dialog = popover?.shadowRoot?.querySelector(
|
||||
"dialog"
|
||||
) as HTMLDialogElement | null;
|
||||
@@ -215,7 +217,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
}
|
||||
|
||||
private _pulsePopover() {
|
||||
const popover = this.renderRoot.querySelector("wa-popover");
|
||||
const popover = this._popoverElement;
|
||||
const popup = popover?.shadowRoot?.querySelector("wa-popup") as {
|
||||
popup?: HTMLElement;
|
||||
} | null;
|
||||
|
||||
@@ -5,10 +5,10 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import "./ha-md-list-item";
|
||||
import "./ha-switch";
|
||||
import "./ha-tooltip";
|
||||
import type { HaSwitch } from "./ha-switch";
|
||||
import "./ha-tooltip";
|
||||
import "./item/ha-row-item";
|
||||
|
||||
const ADDITIONAL_PREFERENCES = ["usage", "statistics"] as const;
|
||||
|
||||
@@ -33,7 +33,7 @@ export class HaAnalytics extends LitElement {
|
||||
const baseEnabled = !loading && this.analytics!.preferences.base;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item>
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
|
||||
@@ -52,10 +52,10 @@ export class HaAnalytics extends LitElement {
|
||||
.disabled=${loading}
|
||||
name="base"
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
</ha-row-item>
|
||||
${ADDITIONAL_PREFERENCES.map(
|
||||
(preference) => html`
|
||||
<ha-md-list-item>
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
|
||||
@@ -81,10 +81,10 @@ export class HaAnalytics extends LitElement {
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
|
||||
)}
|
||||
</ha-tooltip>`}
|
||||
</ha-md-list-item>
|
||||
</ha-row-item>
|
||||
`
|
||||
)}
|
||||
<ha-md-list-item>
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
|
||||
@@ -103,7 +103,7 @@ export class HaAnalytics extends LitElement {
|
||||
.disabled=${loading}
|
||||
name="diagnostics"
|
||||
></ha-switch>
|
||||
</ha-md-list-item>
|
||||
</ha-row-item>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -139,10 +139,8 @@ export class HaAnalytics extends LitElement {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
--md-list-item-leading-space: 0;
|
||||
--md-list-item-trailing-space: 0;
|
||||
--md-item-overflow: visible;
|
||||
ha-row-item {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state as litState,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
@@ -31,6 +32,8 @@ export class HaAnsiToHtml extends LitElement {
|
||||
|
||||
@query("pre") private _pre?: HTMLPreElement;
|
||||
|
||||
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
|
||||
|
||||
@litState() private _filter = "";
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -320,7 +323,7 @@ export class HaAnsiToHtml extends LitElement {
|
||||
*/
|
||||
filterLines(filter: string): boolean {
|
||||
this._filter = filter;
|
||||
const lines = this.shadowRoot?.querySelectorAll("div") || [];
|
||||
const lines = this._divs;
|
||||
let numberOfFoundLines = 0;
|
||||
if (!filter) {
|
||||
lines.forEach((line) => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface AreaControlPickerItem extends PickerComboBoxItem {
|
||||
deviceClass?: string;
|
||||
}
|
||||
|
||||
const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
|
||||
const AREA_CONTROL_DOMAINS = [
|
||||
"light",
|
||||
"fan",
|
||||
"switch",
|
||||
@@ -43,7 +43,7 @@ const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
|
||||
"cover-door",
|
||||
"cover-window",
|
||||
"cover-damper",
|
||||
] as const;
|
||||
] as const satisfies readonly AreaControlDomain[];
|
||||
|
||||
@customElement("ha-area-controls-picker")
|
||||
export class HaAreaControlsPicker extends LitElement {
|
||||
@@ -130,7 +130,7 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
(excludeValues !== undefined && excludeValues.includes(id));
|
||||
|
||||
const controlEntities = getAreaControlEntities(
|
||||
AREA_CONTROL_DOMAINS as unknown as AreaControlDomain[],
|
||||
AREA_CONTROL_DOMAINS,
|
||||
areaId,
|
||||
excludeEntities,
|
||||
this.hass
|
||||
@@ -184,7 +184,10 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
const allEntityIds = Object.values(controlEntities).flat();
|
||||
const uniqueEntityIds = Array.from(new Set(allEntityIds));
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
uniqueEntityIds.forEach((entityId) => {
|
||||
if (isSelected(entityId)) {
|
||||
@@ -261,7 +264,6 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
${item.type === "entity" && item.stateObj
|
||||
? html`<ha-state-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${item.stateObj}
|
||||
></ha-state-icon>`
|
||||
: item.domain
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
@@ -24,11 +24,12 @@ class HaBluePrintPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-select") private _select?: HTMLElement;
|
||||
|
||||
public open() {
|
||||
const select = this.shadowRoot?.querySelector("ha-select");
|
||||
if (select) {
|
||||
if (this._select) {
|
||||
// @ts-expect-error
|
||||
select.menuOpen = true;
|
||||
this._select.menuOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import type WaDrawer from "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
|
||||
import { configContext } from "../data/context";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { isIosApp } from "../util/is_ios";
|
||||
|
||||
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
@@ -47,8 +49,6 @@ const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
|
||||
*/
|
||||
@customElement("ha-bottom-sheet")
|
||||
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: "aria-labelledby" })
|
||||
public ariaLabelledBy?: string;
|
||||
|
||||
@@ -67,10 +67,16 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _sliderInteractionActive = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
|
||||
@query("#drawer") private _drawer!: HTMLElement;
|
||||
|
||||
@query("#body") private _bodyElement!: HTMLDivElement;
|
||||
|
||||
@query("[autofocus]") private _autofocusElement?: HTMLElement;
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this._bodyElement;
|
||||
}
|
||||
@@ -89,25 +95,25 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
await this.updateComplete;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// if (this.hass && isIosApp(this.hass.auth.external)) {
|
||||
// const element = this.renderRoot.querySelector("[autofocus]");
|
||||
// if (element !== null) {
|
||||
// if (!element.id) {
|
||||
// element.id = "ha-bottom-sheet-autofocus";
|
||||
// }
|
||||
// this.hass.auth.external?.fireMessage({
|
||||
// type: "focus_element",
|
||||
// payload: {
|
||||
// element_id: element.id,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
(
|
||||
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
|
||||
)?.focus();
|
||||
const element = this._autofocusElement;
|
||||
if (
|
||||
this._hassConfig?.auth.external &&
|
||||
isIosApp(this._hassConfig.auth.external)
|
||||
) {
|
||||
if (element) {
|
||||
if (!element.id) {
|
||||
element.id = "ha-bottom-sheet-autofocus";
|
||||
}
|
||||
this._hassConfig.auth.external.fireMessage({
|
||||
type: "focus_element",
|
||||
payload: {
|
||||
element_id: element.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
element?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { ClimateEntity } from "../data/climate";
|
||||
import { CLIMATE_PRESET_NONE } from "../data/climate";
|
||||
import { isUnavailableState, OFF } from "../data/entity/entity";
|
||||
import { OFF, UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@customElement("ha-climate-state")
|
||||
@@ -14,9 +14,11 @@ class HaClimateState extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const currentStatus = this._computeCurrentStatus();
|
||||
const noValue =
|
||||
this.stateObj.state === UNAVAILABLE || this.stateObj.state === UNKNOWN;
|
||||
|
||||
return html`<div class="target">
|
||||
${!isUnavailableState(this.stateObj.state)
|
||||
${!noValue
|
||||
? html`<span class="state-label">
|
||||
${this._localizeState()}
|
||||
${this.stateObj.attributes.preset_mode &&
|
||||
@@ -32,7 +34,7 @@ class HaClimateState extends LitElement {
|
||||
: this._localizeState()}
|
||||
</div>
|
||||
|
||||
${currentStatus && !isUnavailableState(this.stateObj.state)
|
||||
${currentStatus && !noValue
|
||||
? html`
|
||||
<div class="current">
|
||||
${this.hass.localize("ui.card.climate.currently")}:
|
||||
@@ -119,7 +121,10 @@ class HaClimateState extends LitElement {
|
||||
}
|
||||
|
||||
private _localizeState(): string {
|
||||
if (isUnavailableState(this.stateObj.state)) {
|
||||
if (
|
||||
this.stateObj.state === UNAVAILABLE ||
|
||||
this.stateObj.state === UNKNOWN
|
||||
) {
|
||||
return this.hass.localize(`state.default.${this.stateObj.state}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, ReactiveElement, render } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume } from "@lit/context";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
@@ -43,7 +44,14 @@ import type {
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import { labelsContext } from "../data/context";
|
||||
import {
|
||||
internationalizationContext,
|
||||
registriesContext,
|
||||
statesContext,
|
||||
labelsContext,
|
||||
configContext,
|
||||
formattersContext,
|
||||
} from "../data/context";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import "./ha-code-editor-completion-items";
|
||||
import type { CompletionItem } from "./ha-code-editor-completion-items";
|
||||
@@ -78,8 +86,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
@property() public mode = "yaml";
|
||||
|
||||
public hass?: HomeAssistant;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@@ -123,9 +129,29 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
@state() private _canCopy = false;
|
||||
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labels?: ContextType<typeof labelsContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: registriesContext, subscribe: true })
|
||||
private _registries?: ContextType<typeof registriesContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters?: ContextType<typeof formattersContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states?: ContextType<typeof statesContext>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
private _loadedCodeMirror?: typeof import("../resources/codemirror");
|
||||
@@ -162,6 +188,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this.codemirror.state,
|
||||
[this._loadedCodeMirror.tags.comment]
|
||||
);
|
||||
// eslint-disable-next-line lit/prefer-query-decorators
|
||||
return !!this.renderRoot.querySelector(`span.${className}`);
|
||||
}
|
||||
|
||||
@@ -189,9 +216,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this.hass?.localize("ui.components.yaml-editor.error") ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this.hass?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
|
||||
}
|
||||
this.codemirror.dispatch(
|
||||
@@ -396,8 +423,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror!.haJinjaHoverSource(
|
||||
view,
|
||||
pos,
|
||||
this.hass ? documentationUrl(this.hass, "") : undefined,
|
||||
this.hass ? this._hassArgHoverContext() : undefined
|
||||
this._config ? documentationUrl(this._config, "") : undefined,
|
||||
this._hassArgHoverContext()
|
||||
),
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
@@ -408,7 +435,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const completionSources: CompletionSource[] = [
|
||||
this._loadedCodeMirror.haJinjaCompletionSource,
|
||||
];
|
||||
if (this.autocompleteEntities && this.hass) {
|
||||
if (this.autocompleteEntities) {
|
||||
completionSources.push(this._entityCompletions.bind(this));
|
||||
}
|
||||
if (this.autocompleteIcons) {
|
||||
@@ -447,12 +474,12 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
private _fullscreenLabel(): string {
|
||||
if (this._isFullscreen) {
|
||||
return (
|
||||
this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.exit_fullscreen") ||
|
||||
"Exit fullscreen"
|
||||
);
|
||||
}
|
||||
return (
|
||||
this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.enter_fullscreen") ||
|
||||
"Enter fullscreen"
|
||||
);
|
||||
}
|
||||
@@ -507,7 +534,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
{
|
||||
id: "test",
|
||||
label:
|
||||
this.hass?.localize(
|
||||
this._i18n?.localize(
|
||||
`ui.components.yaml-editor.test_${this.testing ? "off" : "on"}`
|
||||
) || "Test",
|
||||
path: this.testing ? mdiBugOutline : mdiBug,
|
||||
@@ -518,14 +545,14 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
{
|
||||
id: "undo",
|
||||
disabled: !this._canUndo,
|
||||
label: this.hass?.localize("ui.common.undo") || "Undo",
|
||||
label: this._i18n?.localize("ui.common.undo") || "Undo",
|
||||
path: mdiUndo,
|
||||
action: (e: Event) => this._handleUndoClick(e),
|
||||
},
|
||||
{
|
||||
id: "redo",
|
||||
disabled: !this._canRedo,
|
||||
label: this.hass?.localize("ui.common.redo") || "Redo",
|
||||
label: this._i18n?.localize("ui.common.redo") || "Redo",
|
||||
path: mdiRedo,
|
||||
action: (e: Event) => this._handleRedoClick(e),
|
||||
},
|
||||
@@ -533,7 +560,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
id: "copy",
|
||||
disabled: !this._canCopy,
|
||||
label:
|
||||
this.hass?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
|
||||
"Copy to Clipboard",
|
||||
path: mdiContentCopy,
|
||||
action: (e: Event) => this._handleClipboardClick(e),
|
||||
@@ -541,7 +568,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
{
|
||||
id: "find-replace",
|
||||
label:
|
||||
this.hass?.localize("ui.components.yaml-editor.find_and_replace") ||
|
||||
this._i18n?.localize("ui.components.yaml-editor.find_and_replace") ||
|
||||
"Find and replace",
|
||||
path: mdiFindReplace,
|
||||
action: (e: Event) => this._handleFindReplaceClick(e),
|
||||
@@ -583,7 +610,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
await copyToClipboard(this.value);
|
||||
showToast(this, {
|
||||
message:
|
||||
this.hass?.localize("ui.common.copied_clipboard") ||
|
||||
this._i18n?.localize("ui.common.copied_clipboard") ||
|
||||
"Copied to clipboard",
|
||||
});
|
||||
}
|
||||
@@ -651,12 +678,11 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a HassArgHoverContext from the current hass object so that
|
||||
* Builds a HassArgHoverContext from the context objects so that
|
||||
* haJinjaHoverSource can resolve entity / device / area friendly names
|
||||
* without importing the full HomeAssistant type into the resource file.
|
||||
*/
|
||||
private _hassArgHoverContext(): HassArgHoverContext {
|
||||
const hass = this.hass!;
|
||||
const labelMap: Record<
|
||||
string,
|
||||
{ name: string; description?: string | null }
|
||||
@@ -668,27 +694,33 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
};
|
||||
}
|
||||
return {
|
||||
states: hass.states as HassArgHoverContext["states"],
|
||||
devices: hass.devices as HassArgHoverContext["devices"],
|
||||
areas: hass.areas as HassArgHoverContext["areas"],
|
||||
floors: hass.floors as HassArgHoverContext["floors"],
|
||||
entities: hass.entities as HassArgHoverContext["entities"],
|
||||
states: this._states as HassArgHoverContext["states"],
|
||||
devices: this._registries?.devices as HassArgHoverContext["devices"],
|
||||
areas: this._registries?.areas as HassArgHoverContext["areas"],
|
||||
floors: this._registries?.floors as HassArgHoverContext["floors"],
|
||||
entities: this._registries?.entities as HassArgHoverContext["entities"],
|
||||
labels: labelMap,
|
||||
formatEntityState: (entityId) =>
|
||||
hass.formatEntityState(hass.states[entityId]),
|
||||
this._formatters!.formatEntityState(this._states![entityId]),
|
||||
formatEntityName: (entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
const stateObj = this._states?.[entityId];
|
||||
return (
|
||||
(stateObj?.attributes.friendly_name as string | undefined) ??
|
||||
hass.entities[entityId]?.name ??
|
||||
this._registries?.entities?.[entityId]?.name ??
|
||||
undefined
|
||||
);
|
||||
},
|
||||
formatAttributeName: (entityId, attribute) =>
|
||||
hass.formatEntityAttributeName(hass.states[entityId], attribute),
|
||||
this._formatters!.formatEntityAttributeName(
|
||||
this._states![entityId],
|
||||
attribute
|
||||
),
|
||||
formatAttributeValue: (entityId, attribute) =>
|
||||
hass.formatEntityAttributeValue(hass.states[entityId], attribute),
|
||||
localize: (key) => hass.localize(key as never),
|
||||
this._formatters!.formatEntityAttributeValue(
|
||||
this._states![entityId],
|
||||
attribute
|
||||
),
|
||||
localize: (key) => this._i18n!.localize(key as never),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -698,49 +730,51 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
? completion.apply
|
||||
: completion.label;
|
||||
const context = getEntityContext(
|
||||
this.hass!.states[key],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
this._states![key],
|
||||
this._registries!.entities,
|
||||
this._registries!.devices,
|
||||
this._registries!.areas,
|
||||
this._registries!.floors
|
||||
);
|
||||
|
||||
const completionInfo = document.createElement("div");
|
||||
completionInfo.classList.add("completion-info");
|
||||
|
||||
const formattedState = this.hass!.formatEntityState(this.hass!.states[key]);
|
||||
const formattedState = this._formatters!.formatEntityState(
|
||||
this._states![key]
|
||||
);
|
||||
|
||||
const completionItems: CompletionItem[] = [
|
||||
{
|
||||
label: this.hass!.localize(
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.entity.entity-state-picker.state"
|
||||
),
|
||||
value: formattedState,
|
||||
subValue:
|
||||
// If the state exactly matches the formatted state, don't show the raw state
|
||||
this.hass!.states[key].state === formattedState
|
||||
this._states![key].state === formattedState
|
||||
? undefined
|
||||
: this.hass!.states[key].state,
|
||||
: this._states![key].state,
|
||||
},
|
||||
];
|
||||
|
||||
if (context.device && context.device.name) {
|
||||
completionItems.push({
|
||||
label: this.hass!.localize("ui.components.device-picker.device"),
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
value: context.device.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.area && context.area.name) {
|
||||
completionItems.push({
|
||||
label: this.hass!.localize("ui.components.area-picker.area"),
|
||||
label: this._i18n!.localize("ui.components.area-picker.area"),
|
||||
value: context.area.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.floor && context.floor.name) {
|
||||
completionItems.push({
|
||||
label: this.hass!.localize("ui.components.floor-picker.floor"),
|
||||
label: this._i18n!.localize("ui.components.floor-picker.floor"),
|
||||
value: context.floor.name,
|
||||
});
|
||||
}
|
||||
@@ -761,15 +795,15 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
entityId: string,
|
||||
attribute: string
|
||||
): CompletionInfo | null => {
|
||||
if (!this.hass) return null;
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!this._states || !this._formatters) return null;
|
||||
const stateObj = this._states[entityId];
|
||||
if (!stateObj) return null;
|
||||
|
||||
const translatedName = this.hass.formatEntityAttributeName(
|
||||
const translatedName = this._formatters.formatEntityAttributeName(
|
||||
stateObj,
|
||||
attribute
|
||||
);
|
||||
const formattedValue = this.hass.formatEntityAttributeValue(
|
||||
const formattedValue = this._formatters.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
attribute
|
||||
);
|
||||
@@ -809,9 +843,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
completion: Completion
|
||||
): CompletionInfo | Promise<CompletionInfo> | null => {
|
||||
if (
|
||||
this.hass &&
|
||||
this._states &&
|
||||
typeof completion.apply === "string" &&
|
||||
completion.apply in this.hass.states
|
||||
completion.apply in this._states
|
||||
) {
|
||||
return this._renderInfo(completion);
|
||||
}
|
||||
@@ -1020,7 +1054,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
private _statesDotNotationCompletions(
|
||||
context: CompletionContext
|
||||
): CompletionResult | null | undefined {
|
||||
if (!this.hass) return undefined;
|
||||
if (!this._states) return undefined;
|
||||
|
||||
const { state: editorState, pos } = context;
|
||||
const tree = this._loadedCodeMirror!.syntaxTree(editorState);
|
||||
@@ -1129,9 +1163,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
case 0: {
|
||||
// states. → offer all unique domains
|
||||
const domains = [
|
||||
...new Set(
|
||||
Object.keys(this.hass.states).map((id) => id.split(".")[0])
|
||||
),
|
||||
...new Set(Object.keys(this._states).map((id) => id.split(".")[0])),
|
||||
].sort();
|
||||
return {
|
||||
from: completionFrom,
|
||||
@@ -1142,7 +1174,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
case 1: {
|
||||
// states.<domain>. → offer entity object_ids for that domain
|
||||
const [domain] = segments;
|
||||
const entities = Object.keys(this.hass.states)
|
||||
const entities = Object.keys(this._states)
|
||||
.filter((id) => id.startsWith(`${domain}.`))
|
||||
.map((id) => id.split(".").slice(1).join("."));
|
||||
if (!entities.length) return { from: completionFrom, options: [] };
|
||||
@@ -1172,7 +1204,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
// Offer attribute names from the entity's state object
|
||||
const entityId = `${domain}.${entity}`;
|
||||
const entityState = this.hass.states[entityId];
|
||||
const entityState = this._states[entityId];
|
||||
if (!entityState) return { from: completionFrom, options: [] };
|
||||
const attrNames = Object.keys(entityState.attributes).sort();
|
||||
return {
|
||||
@@ -1342,8 +1374,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
): CompletionResult {
|
||||
const from = stringNode.from + 1;
|
||||
const empty: CompletionResult = { from, options: [] };
|
||||
if (!entityId || !this.hass) return empty;
|
||||
const entityState = this.hass.states[entityId];
|
||||
if (!entityId || !this._states) return empty;
|
||||
const entityState = this._states[entityId];
|
||||
if (!entityState) return empty;
|
||||
const attrs = Object.keys(entityState.attributes).sort();
|
||||
if (!attrs.length) return empty;
|
||||
@@ -1363,7 +1395,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
const states = this._getStates(this.hass!.states);
|
||||
const states = this._getStates(this._states!);
|
||||
if (!states?.length) return null;
|
||||
// from is stringNode.from + 1 to skip the opening quote character.
|
||||
const from = stringNode.from + 1;
|
||||
@@ -1397,8 +1429,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
if (!this.hass?.devices) return null;
|
||||
const devices = this._getDevices(this.hass.devices);
|
||||
if (!this._registries?.devices) return null;
|
||||
const devices = this._getDevices(this._registries.devices);
|
||||
if (!devices.length) return null;
|
||||
return {
|
||||
from: stringNode.from + 1,
|
||||
@@ -1426,8 +1458,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
if (!this.hass?.areas) return null;
|
||||
const areas = this._getAreas(this.hass.areas);
|
||||
if (!this._registries?.areas) return null;
|
||||
const areas = this._getAreas(this._registries.areas);
|
||||
if (!areas.length) return null;
|
||||
return {
|
||||
from: stringNode.from + 1,
|
||||
@@ -1455,8 +1487,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
if (!this.hass?.floors) return null;
|
||||
const floors = this._getFloors(this.hass.floors);
|
||||
if (!this._registries?.floors) return null;
|
||||
const floors = this._getFloors(this._registries.floors);
|
||||
if (!floors.length) return null;
|
||||
return {
|
||||
from: stringNode.from + 1,
|
||||
@@ -1556,7 +1588,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
// If cursor is after the entity field, show all entities
|
||||
if (context.pos >= afterField) {
|
||||
const states = this._getStates(this.hass!.states);
|
||||
const states = this._getStates(this._states!);
|
||||
|
||||
if (!states || !states.length) {
|
||||
return null;
|
||||
@@ -1611,7 +1643,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const afterListMarker = currentLine.from + listItemMatch[0].length;
|
||||
|
||||
if (context.pos >= afterListMarker) {
|
||||
const states = this._getStates(this.hass!.states);
|
||||
const states = this._getStates(this._states!);
|
||||
|
||||
if (!states || !states.length) {
|
||||
return null;
|
||||
@@ -1671,7 +1703,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
const states = this._getStates(this.hass!.states);
|
||||
const states = this._getStates(this._states!);
|
||||
|
||||
if (!states || !states.length) {
|
||||
return null;
|
||||
|
||||
@@ -54,6 +54,7 @@ export class HaControlSelect extends LitElement {
|
||||
this._activeIndex = index;
|
||||
this.requestUpdate();
|
||||
this.updateComplete.then(() => {
|
||||
// eslint-disable-next-line lit/prefer-query-decorators
|
||||
const option = this.shadowRoot?.querySelector(
|
||||
`#option-${this.options![index].value}`
|
||||
) as HTMLElement;
|
||||
|
||||
+23
-21
@@ -15,9 +15,10 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { withViewTransition } from "../common/util/view-transition";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { isIosApp } from "../util/is_ios";
|
||||
import "./ha-dialog-header";
|
||||
import "./ha-icon-button";
|
||||
|
||||
@@ -127,10 +128,9 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// @state()
|
||||
// @consume({ context: configContext, subscribe: true })
|
||||
// private _hassConfig?: ContextType<typeof configContext>;
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
private _bodyScrolled = false;
|
||||
@@ -221,22 +221,24 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
|
||||
await this.updateComplete;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// if (this._hassConfig?.auth.external && isIosApp(this._hassConfig.auth.external)) {
|
||||
// const element = this.querySelector("[autofocus]");
|
||||
// if (element !== null) {
|
||||
// if (!element.id) {
|
||||
// element.id = "ha-dialog-autofocus";
|
||||
// }
|
||||
// this._hassConfig.auth.external.fireMessage({
|
||||
// type: "focus_element",
|
||||
// payload: {
|
||||
// element_id: element.id,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
if (
|
||||
this._hassConfig?.auth.external &&
|
||||
isIosApp(this._hassConfig.auth.external)
|
||||
) {
|
||||
const element = this.querySelector("[autofocus]");
|
||||
if (element !== null) {
|
||||
if (!element.id) {
|
||||
element.id = "ha-dialog-autofocus";
|
||||
}
|
||||
this._hassConfig.auth.external.fireMessage({
|
||||
type: "focus_element",
|
||||
payload: {
|
||||
element_id: element.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
+265
-125
@@ -1,36 +1,115 @@
|
||||
import { DrawerBase } from "@material/mwc-drawer/mwc-drawer-base";
|
||||
import { styles } from "@material/mwc-drawer/mwc-drawer.css";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"hass-drawer-closed": undefined;
|
||||
"hass-layout-transition": { active: boolean; reason?: string };
|
||||
}
|
||||
interface HTMLElementEventMap {
|
||||
"hass-drawer-closed": HASSDomEvent<HASSDomEvents["hass-drawer-closed"]>;
|
||||
"hass-layout-transition": HASSDomEvent<
|
||||
HASSDomEvents["hass-layout-transition"]
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
const blockingElements = (document as any).$blockingElements;
|
||||
|
||||
@customElement("ha-drawer")
|
||||
export class HaDrawer extends DrawerBase {
|
||||
@property() public direction: "ltr" | "rtl" = "ltr";
|
||||
export class HaDrawer extends LitElement {
|
||||
private static readonly _SWIPE_AXIS_TOLERANCE = 32;
|
||||
|
||||
private _mc?: HammerManager;
|
||||
@property({ reflect: true }) public direction: "ltr" | "rtl" = "ltr";
|
||||
|
||||
private _rtlStyle?: HTMLElement;
|
||||
@property({ reflect: true }) public type: "" | "dismissible" | "modal" = "";
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public open = false;
|
||||
|
||||
@query("wa-drawer") private _modalDrawer?: HTMLElement;
|
||||
|
||||
@query(".sidebar-shell") private _sidebarShell?: HTMLElement;
|
||||
|
||||
private _sidebarTransitionActive = false;
|
||||
|
||||
private _transitionTarget?: HTMLElement;
|
||||
|
||||
private _gestureRecognizer = new SwipeGestureRecognizer({
|
||||
velocitySwipeThreshold: 0.35,
|
||||
});
|
||||
|
||||
private _touchStartY = 0;
|
||||
|
||||
private _touchDeltaY = 0;
|
||||
|
||||
private get _modal() {
|
||||
return this.type === "modal";
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return this._modal
|
||||
? html`
|
||||
<slot name="appContent"></slot>
|
||||
<wa-drawer
|
||||
placement="start"
|
||||
.open=${this.open}
|
||||
light-dismiss
|
||||
without-header
|
||||
@touchstart=${this._handleTouchStart}
|
||||
@wa-after-hide=${this._handleAfterHide}
|
||||
>
|
||||
<slot></slot>
|
||||
</wa-drawer>
|
||||
`
|
||||
: html`
|
||||
<div class="layout">
|
||||
<div class="sidebar-shell">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<div class="app-content">
|
||||
<slot name="appContent"></slot>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(_: PropertyValues<this>) {
|
||||
this._syncTransitionListeners();
|
||||
|
||||
if (!this.open) {
|
||||
this._resetSwipeTracking();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._syncTransitionListeners();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._removeTransitionListeners();
|
||||
this._unregisterSwipeHandlers();
|
||||
}
|
||||
|
||||
private _handleAfterHide(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this.open = false;
|
||||
fireEvent(this, "hass-drawer-closed");
|
||||
}
|
||||
|
||||
private _closeModalDrawer() {
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
private _handleDrawerTransitionStart = (ev: TransitionEvent) => {
|
||||
if (ev.propertyName !== "width" || this._sidebarTransitionActive) {
|
||||
if (
|
||||
ev.propertyName !==
|
||||
(this.type === "dismissible" ? "transform" : "width") ||
|
||||
this._sidebarTransitionActive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._sidebarTransitionActive = true;
|
||||
@@ -41,7 +120,11 @@ export class HaDrawer extends DrawerBase {
|
||||
};
|
||||
|
||||
private _handleDrawerTransitionEnd = (ev: TransitionEvent) => {
|
||||
if (ev.propertyName !== "width" || !this._sidebarTransitionActive) {
|
||||
if (
|
||||
ev.propertyName !==
|
||||
(this.type === "dismissible" ? "transform" : "width") ||
|
||||
!this._sidebarTransitionActive
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._sidebarTransitionActive = false;
|
||||
@@ -51,150 +134,207 @@ export class HaDrawer extends DrawerBase {
|
||||
});
|
||||
};
|
||||
|
||||
protected createAdapter() {
|
||||
return {
|
||||
...super.createAdapter(),
|
||||
trapFocus: () => {
|
||||
blockingElements.push(this);
|
||||
this.appContent.inert = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
},
|
||||
releaseFocus: () => {
|
||||
blockingElements.remove(this);
|
||||
this.appContent.inert = false;
|
||||
document.body.style.overflow = "";
|
||||
},
|
||||
};
|
||||
private _handleTouchStart = (ev: TouchEvent) => {
|
||||
if (!this._modal || !this.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drawer = this._modalDrawer;
|
||||
const dialog = drawer?.shadowRoot?.querySelector(
|
||||
"dialog"
|
||||
) as HTMLDialogElement | null;
|
||||
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const path = ev.composedPath();
|
||||
|
||||
if (!path.includes(dialog)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.stopPropagation();
|
||||
this._startSwipeTracking(ev.touches[0].clientX, ev.touches[0].clientY);
|
||||
};
|
||||
|
||||
private _startSwipeTracking(clientX: number, clientY: number) {
|
||||
document.addEventListener("touchmove", this._handleTouchMove, {
|
||||
passive: true,
|
||||
});
|
||||
document.addEventListener("touchend", this._handleTouchEnd);
|
||||
document.addEventListener("touchcancel", this._handleTouchEnd);
|
||||
|
||||
this._touchStartY = clientY;
|
||||
this._touchDeltaY = 0;
|
||||
this._gestureRecognizer.start(clientX);
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
if (changedProps.has("direction")) {
|
||||
this.mdcRoot.dir = this.direction;
|
||||
if (this.direction === "rtl") {
|
||||
this._rtlStyle = document.createElement("style");
|
||||
this._rtlStyle.innerHTML = `
|
||||
.mdc-drawer--animate {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
.mdc-drawer--opening {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.mdc-drawer--closing {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
`;
|
||||
private _handleTouchMove = (ev: TouchEvent) => {
|
||||
const currentX = ev.touches[0].clientX;
|
||||
const currentY = ev.touches[0].clientY;
|
||||
this._touchDeltaY = Math.abs(currentY - this._touchStartY);
|
||||
this._gestureRecognizer.move(currentX);
|
||||
};
|
||||
|
||||
this.shadowRoot!.appendChild(this._rtlStyle);
|
||||
} else if (this._rtlStyle) {
|
||||
this.shadowRoot!.removeChild(this._rtlStyle);
|
||||
private _handleTouchEnd = () => {
|
||||
this._unregisterSwipeHandlers();
|
||||
|
||||
const result = this._gestureRecognizer.end();
|
||||
const isHorizontalGesture =
|
||||
Math.abs(result.delta) >
|
||||
this._touchDeltaY + HaDrawer._SWIPE_AXIS_TOLERANCE;
|
||||
|
||||
if (!isHorizontalGesture) {
|
||||
this._resetSwipeTracking();
|
||||
return;
|
||||
}
|
||||
|
||||
const drawerDialog = this._modalDrawer?.shadowRoot?.querySelector(
|
||||
'[part="dialog"]'
|
||||
) as HTMLElement | null;
|
||||
const drawerWidth = drawerDialog?.offsetWidth || 0;
|
||||
|
||||
if (result.isSwipe) {
|
||||
const closeByVelocity =
|
||||
this.direction === "rtl"
|
||||
? result.isDownwardSwipe
|
||||
: !result.isDownwardSwipe;
|
||||
|
||||
if (closeByVelocity) {
|
||||
this._closeModalDrawer();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (changedProps.has("open") && this.open && this.type === "modal") {
|
||||
this._setupSwipe();
|
||||
} else if (this._mc) {
|
||||
this._mc.destroy();
|
||||
this._mc = undefined;
|
||||
const closeByDistance =
|
||||
drawerWidth > 0 &&
|
||||
(this.direction === "rtl"
|
||||
? result.delta > 0 && Math.abs(result.delta) > drawerWidth * 0.5
|
||||
: result.delta < 0 && Math.abs(result.delta) > drawerWidth * 0.5);
|
||||
|
||||
if (closeByDistance) {
|
||||
this._closeModalDrawer();
|
||||
}
|
||||
};
|
||||
|
||||
private _unregisterSwipeHandlers() {
|
||||
document.removeEventListener("touchmove", this._handleTouchMove);
|
||||
document.removeEventListener("touchend", this._handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", this._handleTouchEnd);
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
super.firstUpdated();
|
||||
this.mdcRoot?.addEventListener(
|
||||
private _resetSwipeTracking() {
|
||||
this._unregisterSwipeHandlers();
|
||||
this._gestureRecognizer.reset();
|
||||
this._touchStartY = 0;
|
||||
this._touchDeltaY = 0;
|
||||
}
|
||||
|
||||
private _syncTransitionListeners() {
|
||||
if (this._transitionTarget === this._sidebarShell) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._removeTransitionListeners();
|
||||
|
||||
if (!this._sidebarShell) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._transitionTarget = this._sidebarShell;
|
||||
this._transitionTarget.addEventListener(
|
||||
"transitionstart",
|
||||
this._handleDrawerTransitionStart
|
||||
);
|
||||
this.mdcRoot?.addEventListener(
|
||||
this._transitionTarget.addEventListener(
|
||||
"transitionend",
|
||||
this._handleDrawerTransitionEnd
|
||||
);
|
||||
this.mdcRoot?.addEventListener(
|
||||
this._transitionTarget.addEventListener(
|
||||
"transitioncancel",
|
||||
this._handleDrawerTransitionEnd
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.mdcRoot?.removeEventListener(
|
||||
private _removeTransitionListeners() {
|
||||
if (!this._transitionTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._transitionTarget.removeEventListener(
|
||||
"transitionstart",
|
||||
this._handleDrawerTransitionStart
|
||||
);
|
||||
this.mdcRoot?.removeEventListener(
|
||||
this._transitionTarget.removeEventListener(
|
||||
"transitionend",
|
||||
this._handleDrawerTransitionEnd
|
||||
);
|
||||
this.mdcRoot?.removeEventListener(
|
||||
this._transitionTarget.removeEventListener(
|
||||
"transitioncancel",
|
||||
this._handleDrawerTransitionEnd
|
||||
);
|
||||
this._transitionTarget = undefined;
|
||||
}
|
||||
|
||||
private async _setupSwipe() {
|
||||
const hammer = await import("../resources/hammer");
|
||||
this._mc = new hammer.Manager(document, {
|
||||
touchAction: "pan-y",
|
||||
});
|
||||
this._mc.add(
|
||||
new hammer.Swipe({
|
||||
direction:
|
||||
this.direction === "rtl"
|
||||
? hammer.DIRECTION_RIGHT
|
||||
: hammer.DIRECTION_LEFT,
|
||||
})
|
||||
);
|
||||
this._mc.on("swipeleft swiperight", () => {
|
||||
fireEvent(this, "hass-toggle-menu", { open: false });
|
||||
});
|
||||
}
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
styles,
|
||||
css`
|
||||
.mdc-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
border-color: var(--divider-color, rgba(0, 0, 0, 0.12));
|
||||
inset-inline-start: 0 !important;
|
||||
inset-inline-end: initial !important;
|
||||
transition-property: transform, width;
|
||||
transition-duration:
|
||||
var(--mdc-drawer-transition-duration, 0.2s),
|
||||
var(--ha-animation-duration-normal);
|
||||
transition-timing-function:
|
||||
var(
|
||||
--mdc-drawer-transition-timing-function,
|
||||
cubic-bezier(0.4, 0, 0.2, 1)
|
||||
),
|
||||
ease;
|
||||
}
|
||||
.mdc-drawer.mdc-drawer--modal.mdc-drawer--open {
|
||||
z-index: 200;
|
||||
}
|
||||
.mdc-drawer-app-content {
|
||||
overflow: unset;
|
||||
flex: none;
|
||||
padding-left: var(--mdc-drawer-width);
|
||||
padding-inline-start: var(--mdc-drawer-width);
|
||||
padding-inline-end: initial;
|
||||
direction: var(--direction);
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
padding-left var(--ha-animation-duration-normal) ease,
|
||||
padding-inline-start var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Use 1ms instead of "none" so the transitionend event still fires.
|
||||
The MDC drawer foundation relies on it to complete the close cycle. */
|
||||
.mdc-drawer,
|
||||
.mdc-drawer-app-content {
|
||||
transition: 1ms;
|
||||
}
|
||||
}
|
||||
`,
|
||||
];
|
||||
.layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-shell {
|
||||
position: fixed;
|
||||
width: var(--ha-sidebar-width);
|
||||
height: 100%;
|
||||
border-inline-end: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
|
||||
box-sizing: border-box;
|
||||
transition: width var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
overflow: unset;
|
||||
min-width: 0;
|
||||
padding-inline-start: var(--ha-sidebar-width);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
padding-inline-start var(--ha-animation-duration-normal) ease,
|
||||
width var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
|
||||
:host([type="dismissible"]) .sidebar-shell {
|
||||
transition: transform var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
|
||||
:host([type="dismissible"]:not([open])) .sidebar-shell {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
:host([type="dismissible"][direction="rtl"]:not([open])) .sidebar-shell {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
:host([type="dismissible"]:not([open])) .app-content {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
|
||||
wa-drawer {
|
||||
--size: var(--ha-sidebar-width, 256px);
|
||||
--show-duration: var(--ha-animation-duration-normal);
|
||||
--hide-duration: var(--ha-animation-duration-normal);
|
||||
}
|
||||
|
||||
wa-drawer::part(body) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -6,11 +6,18 @@ import type { HaIconButton } from "./ha-icon-button";
|
||||
|
||||
/**
|
||||
* Event type for the ha-dropdown component when an item is selected.
|
||||
* @param T - The type of the value of the selected item.
|
||||
* @param TValue - The type of the selected item's `value`.
|
||||
* @param TData - The type of the selected item's `data` when set on `ha-dropdown-item`.
|
||||
*/
|
||||
export type HaDropdownSelectEvent<T = string> = CustomEvent<{
|
||||
item: Omit<HaDropdownItem, "value"> & { value: T };
|
||||
}>;
|
||||
export type HaDropdownSelectEvent<TValue = string, TData = undefined> = [
|
||||
TData,
|
||||
] extends [undefined]
|
||||
? CustomEvent<{
|
||||
item: Omit<HaDropdownItem, "value"> & { value: TValue };
|
||||
}>
|
||||
: CustomEvent<{
|
||||
item: Omit<HaDropdownItem, "value"> & { value: TValue; data: TData };
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Home Assistant dropdown component
|
||||
|
||||
@@ -39,7 +39,12 @@ export class HaEntitiesDisplayEditor extends LitElement {
|
||||
const items: DisplayItem[] = entities.map((entity) => ({
|
||||
value: entity.entity_id,
|
||||
label: computeStateName(entity),
|
||||
icon: entityIcon(this.hass, entity),
|
||||
icon: entityIcon(
|
||||
this.hass.entities,
|
||||
this.hass.config,
|
||||
this.hass.connection,
|
||||
entity
|
||||
),
|
||||
}));
|
||||
|
||||
const value: DisplayValue = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import type { Blueprints } from "../data/blueprint";
|
||||
@@ -32,6 +32,8 @@ export class HaFilterBlueprints extends LitElement {
|
||||
|
||||
@state() private _blueprints?: Blueprints;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -96,8 +98,7 @@ export class HaFilterBlueprints extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (this.narrow || !this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - 49}px`;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import type { CategoryRegistryEntry } from "../data/category_registry";
|
||||
@@ -49,6 +49,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["scope"];
|
||||
|
||||
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
|
||||
@@ -169,8 +171,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - (49 + 48)}px`;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
|
||||
@@ -34,6 +34,8 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -135,8 +137,7 @@ export class HaFilterDevices extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - 49 - 4 - 32}px`;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -31,6 +32,8 @@ export class HaFilterDomains extends LitElement {
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
@@ -62,7 +65,7 @@ export class HaFilterDomains extends LitElement {
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._domains(this.hass.states, this._filter),
|
||||
this._domains(this.hass.states, this._filter, this.value),
|
||||
(i) => i,
|
||||
(domain) =>
|
||||
html`<ha-check-list-item
|
||||
@@ -84,7 +87,7 @@ export class HaFilterDomains extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _domains = memoizeOne((states, filter) => {
|
||||
private _domains = memoizeOne((states, filter, _value) => {
|
||||
const domains = new Set<string>();
|
||||
Object.keys(states).forEach((entityId) => {
|
||||
domains.add(computeDomain(entityId));
|
||||
@@ -109,8 +112,7 @@ export class HaFilterDomains extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - 49 - 4 - 32}px`;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
@@ -126,19 +128,19 @@ export class HaFilterDomains extends LitElement {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemSelected(
|
||||
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
|
||||
) {
|
||||
const domains = this._domains(this.hass.states, this._filter);
|
||||
if (ev.detail.diff.added.length) {
|
||||
this.value = [...(this.value || []), domains[ev.detail.diff.added[0]]];
|
||||
} else if (ev.detail.diff.removed.length) {
|
||||
const removedDomain = domains[ev.detail.diff.removed[0]];
|
||||
this.value = this.value?.filter((value) => value !== removedDomain);
|
||||
}
|
||||
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
const domains = this._domains(this.hass.states, this._filter, this.value);
|
||||
|
||||
const visibleDomains = new Set(domains);
|
||||
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
|
||||
const selected = [...ev.detail.index]
|
||||
.map((i) => domains[i])
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
this.value = [...preserved, ...selected];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value,
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
@@ -36,6 +36,8 @@ export class HaFilterEntities extends LitElement {
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -102,8 +104,7 @@ export class HaFilterEntities extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - 49 - 4 - 32}px`;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
@@ -122,11 +123,7 @@ export class HaFilterEntities extends LitElement {
|
||||
.selected=${this.value?.includes(entity.entity_id) ?? false}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-state-icon
|
||||
slot="graphic"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${entity}
|
||||
></ha-state-icon>
|
||||
<ha-state-icon slot="graphic" .stateObj=${entity}></ha-state-icon>
|
||||
${computeStateName(entity)}
|
||||
</ha-check-list-item>`;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mdiFilterVariantRemove, mdiTextureBox } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -42,6 +42,8 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -137,7 +139,10 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
.selected=${this.value?.areas?.includes(area.area_id) || false}
|
||||
.type=${"areas"}
|
||||
class=${classMap({
|
||||
rtl: computeRTL(this.hass),
|
||||
rtl: computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
),
|
||||
floor: hasFloor,
|
||||
})}
|
||||
>
|
||||
@@ -204,8 +209,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
|
||||
`${this.clientHeight - 49}px`;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -34,6 +35,8 @@ export class HaFilterIntegrations extends LitElement {
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
@@ -98,8 +101,7 @@ export class HaFilterIntegrations extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - 49 - 4 - 32}px`;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
@@ -147,9 +149,7 @@ export class HaFilterIntegrations extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
private _itemSelected(
|
||||
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
|
||||
) {
|
||||
private _itemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
const integrations = this._integrations(
|
||||
this.hass.localize,
|
||||
this._manifests!,
|
||||
@@ -157,18 +157,16 @@ export class HaFilterIntegrations extends LitElement {
|
||||
this.value
|
||||
);
|
||||
|
||||
if (ev.detail.diff.added.length) {
|
||||
this.value = [
|
||||
...(this.value || []),
|
||||
integrations[ev.detail.diff.added[0]].domain,
|
||||
];
|
||||
} else if (ev.detail.diff.removed.length) {
|
||||
const removedDomain = integrations[ev.detail.diff.removed[0]].domain;
|
||||
this.value = this.value?.filter((val) => val !== removedDomain);
|
||||
}
|
||||
const visibleDomains = new Set(integrations.map((i) => i.domain));
|
||||
const preserved = (this.value || []).filter((d) => !visibleDomains.has(d));
|
||||
const selected = [...ev.detail.index]
|
||||
.map((i) => integrations[i]?.domain)
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
this.value = [...preserved, ...selected];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value,
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -41,6 +41,8 @@ export class HaFilterLabels extends LitElement {
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
private _filteredLabels = memoizeOne(
|
||||
// `_value` used to recalculate the memoization when the selection changes
|
||||
(labels: LabelRegistryEntry[], filter: string | undefined, _value) =>
|
||||
@@ -137,8 +139,7 @@ export class HaFilterLabels extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - (49 + 48 + 32 + 4)}px`;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
@@ -33,6 +33,8 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
@@ -93,8 +95,7 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - 49}px`;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { mdiPlus } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
@@ -49,14 +49,15 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
|
||||
|
||||
@state() private _displayActions?: string[];
|
||||
|
||||
@query("ha-form") private _form?: HaForm;
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
this.renderRoot.querySelector("ha-form")?.focus();
|
||||
this._form?.focus();
|
||||
}
|
||||
|
||||
public reportValidity(): boolean {
|
||||
const form = this.renderRoot.querySelector<HaForm>("ha-form");
|
||||
return form ? form.reportValidity() : true;
|
||||
return this._form ? this._form.reportValidity() : true;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>): void {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -83,8 +83,10 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
@query(".root") private _root?: HTMLElement;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
const root = this.renderRoot.querySelector(".root");
|
||||
const root = this._root;
|
||||
if (!root) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiPlaylistPlus } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
@@ -13,8 +14,10 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext } from "../data/context";
|
||||
import { PickerMixin } from "../mixins/picker-mixin";
|
||||
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
|
||||
import { isIosApp } from "../util/is_ios";
|
||||
import "./ha-bottom-sheet";
|
||||
import "./ha-button";
|
||||
import "./ha-combo-box-item";
|
||||
@@ -110,10 +113,9 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// @state()
|
||||
// @consume({ context: authContext, subscribe: true })
|
||||
// private auth?: ContextType<typeof authContext>;
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
@@ -319,16 +321,18 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
this._comboBox?.setFieldValue(this._initialFieldValue);
|
||||
this._initialFieldValue = undefined;
|
||||
}
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// if (this.auth?.external && isIosApp(this.auth.external)) {
|
||||
// this.auth.external.fireMessage({
|
||||
// type: "focus_element",
|
||||
// payload: {
|
||||
// element_id: "combo-box",
|
||||
// },
|
||||
// });
|
||||
// return;
|
||||
// }
|
||||
if (
|
||||
this._hassConfig?.auth.external &&
|
||||
isIosApp(this._hassConfig.auth.external)
|
||||
) {
|
||||
this._hassConfig.auth.external.fireMessage({
|
||||
type: "focus_element",
|
||||
payload: {
|
||||
element_id: "combo-box",
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._comboBox?.focus();
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { isUnavailableState, OFF } from "../data/entity/entity";
|
||||
import { OFF, UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
|
||||
import type { HumidifierEntity } from "../data/humidifier";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
@@ -13,9 +13,11 @@ class HaHumidifierState extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const currentStatus = this._computeCurrentStatus();
|
||||
const noValue =
|
||||
this.stateObj.state === UNAVAILABLE || this.stateObj.state === UNKNOWN;
|
||||
|
||||
return html`<div class="target">
|
||||
${!isUnavailableState(this.stateObj.state)
|
||||
${!noValue
|
||||
? html`<span class="state-label">
|
||||
${this._localizeState()}
|
||||
${this.stateObj.attributes.mode
|
||||
@@ -30,7 +32,7 @@ class HaHumidifierState extends LitElement {
|
||||
: this._localizeState()}
|
||||
</div>
|
||||
|
||||
${currentStatus && !isUnavailableState(this.stateObj.state)
|
||||
${currentStatus && !noValue
|
||||
? html`<div class="current">
|
||||
${this.hass.localize("ui.card.humidifier.currently")}:
|
||||
<div class="unit">${currentStatus}</div>
|
||||
@@ -69,7 +71,10 @@ class HaHumidifierState extends LitElement {
|
||||
}
|
||||
|
||||
private _localizeState(): string {
|
||||
if (isUnavailableState(this.stateObj.state)) {
|
||||
if (
|
||||
this.stateObj.state === UNAVAILABLE ||
|
||||
this.stateObj.state === UNKNOWN
|
||||
) {
|
||||
return this.hass.localize(`state.default.${this.stateObj.state}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -314,6 +314,7 @@ export class HaItemDisplayEditor extends LitElement {
|
||||
// refocus the item after the sort
|
||||
setTimeout(async () => {
|
||||
await this.updateComplete;
|
||||
// eslint-disable-next-line lit/prefer-query-decorators
|
||||
const selectedElement = this.shadowRoot?.querySelector(
|
||||
`ha-md-list-item:nth-child(${this._dragIndex! + 1})`
|
||||
) as HTMLElement | null;
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
type CSSResultGroup,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
} from "../common/dom/fire_event";
|
||||
import { loadVirtualizer } from "../resources/virtualizer";
|
||||
import "./ha-combo-box-item";
|
||||
import {
|
||||
DEFAULT_ROW_RENDERER_CONTENT,
|
||||
type PickerComboBoxItem,
|
||||
} from "./ha-picker-combo-box";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
const EMPTY_ROW_ID = "___empty___";
|
||||
|
||||
export interface PickerActionContext {
|
||||
host: HTMLElement;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** Items with `onSelect` are action rows: the callback fires instead of `item-selected`. */
|
||||
export interface PickerListItem extends PickerComboBoxItem {
|
||||
onSelect?: (ctx: PickerActionContext) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type PickerListEntry = PickerListItem | string;
|
||||
|
||||
interface PickerListRowElement extends HTMLDivElement {
|
||||
index: number;
|
||||
item: PickerListItem;
|
||||
}
|
||||
|
||||
const DEFAULT_ROW: RenderItemFunction<PickerListItem> = (item) =>
|
||||
html`<ha-combo-box-item type="button" compact>
|
||||
${DEFAULT_ROW_RENDERER_CONTENT(item)}
|
||||
</ha-combo-box-item>`;
|
||||
|
||||
/**
|
||||
* Headless virtualized list for picker UIs. Receives pre-filtered `items`,
|
||||
* renders rows via `rowRenderer`. String entries are section titles.
|
||||
*/
|
||||
@customElement("ha-picker-list")
|
||||
export class HaPickerList extends LitElement {
|
||||
@property({ attribute: false }) public items: PickerListEntry[] = [];
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<PickerListItem>;
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public notFoundLabel?:
|
||||
| string
|
||||
| TemplateResult
|
||||
| ((search: string) => string | TemplateResult);
|
||||
|
||||
/** Current search string. Picks between empty/notFound placeholders; filtering is the consumer's job. */
|
||||
@property({ attribute: "current-search" }) public currentSearch = "";
|
||||
|
||||
@state() private _highlightedIndex = -1;
|
||||
|
||||
@state() private _valuePinned = true;
|
||||
|
||||
@query("lit-virtualizer") private _virtualizer?: LitVirtualizer;
|
||||
|
||||
private _unsubscribeKeys?: () => void;
|
||||
|
||||
public willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._registerKeys();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeKeys?.();
|
||||
}
|
||||
|
||||
public selectNext = (ev?: KeyboardEvent) => this._next(ev);
|
||||
|
||||
public selectPrev = (ev?: KeyboardEvent) => this._prev(ev);
|
||||
|
||||
public selectFirst = (ev?: KeyboardEvent) => this._first(ev);
|
||||
|
||||
public selectLast = (ev?: KeyboardEvent) => this._last(ev);
|
||||
|
||||
public commitHighlighted = (newTab = false) =>
|
||||
this._commitAt(this._highlightedIndex, newTab);
|
||||
|
||||
protected render() {
|
||||
const items = this.items.length
|
||||
? this.items
|
||||
: [{ id: EMPTY_ROW_ID, primary: "" } as PickerListItem];
|
||||
return html`
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${items}
|
||||
.renderItem=${this._renderEntry}
|
||||
.layout=${this.value && this._valuePinned
|
||||
? {
|
||||
pin: {
|
||||
index: this._initialPinIndex(),
|
||||
block: "center",
|
||||
},
|
||||
}
|
||||
: undefined}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetHighlight}
|
||||
></lit-virtualizer>
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (item: PickerListEntry) =>
|
||||
typeof item === "string" ? item : item.id;
|
||||
|
||||
private _renderEntry: RenderItemFunction<PickerListEntry> = (item, index) => {
|
||||
if (typeof item === "string") {
|
||||
return html`<div class="title">${item}</div>`;
|
||||
}
|
||||
if (item.id === EMPTY_ROW_ID) {
|
||||
return this._renderEmptyRow();
|
||||
}
|
||||
const renderer = this.rowRenderer ?? DEFAULT_ROW;
|
||||
return html`<div
|
||||
id=${`list-item-${index}`}
|
||||
class="row ${this.value === item.id ? "current-value" : ""}"
|
||||
.item=${item}
|
||||
.index=${index}
|
||||
@click=${this._handleClick}
|
||||
>
|
||||
${renderer(item as PickerListItem, index)}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
private _renderEmptyRow() {
|
||||
const search = this.currentSearch;
|
||||
const message = search
|
||||
? typeof this.notFoundLabel === "function"
|
||||
? this.notFoundLabel(search)
|
||||
: (this.notFoundLabel ?? "No matching items found")
|
||||
: (this.emptyLabel ?? "No items available");
|
||||
return html`
|
||||
<div class="row empty">
|
||||
<ha-combo-box-item type="text" compact>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${search ? mdiMagnify : mdiMinusBoxOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${message}</span>
|
||||
</ha-combo-box-item>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleClick = (
|
||||
ev: MouseEvent & HASSDomCurrentTargetEvent<PickerListRowElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const row = ev.currentTarget;
|
||||
if (row.item.disabled) return;
|
||||
this._dispatchSelection(row.item, row.index, ev.ctrlKey || ev.metaKey);
|
||||
};
|
||||
|
||||
private _dispatchSelection(
|
||||
item: PickerListItem,
|
||||
index: number,
|
||||
newTab: boolean
|
||||
) {
|
||||
if (item.onSelect) {
|
||||
void item.onSelect({
|
||||
host: this,
|
||||
close: () => fireEvent(this, "picker-close-request"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "item-selected", { id: item.id, index, newTab });
|
||||
}
|
||||
|
||||
private _handleUnpinned = () => {
|
||||
this._valuePinned = false;
|
||||
};
|
||||
|
||||
private _registerKeys() {
|
||||
this._unsubscribeKeys = tinykeys(this, {
|
||||
ArrowDown: this._next,
|
||||
ArrowUp: this._prev,
|
||||
Home: this._first,
|
||||
End: this._last,
|
||||
Enter: this._commitHighlight,
|
||||
"$mod+Enter": this._commitHighlightNewTab,
|
||||
});
|
||||
}
|
||||
|
||||
private _focusList = () => {
|
||||
if (this._highlightedIndex === -1) this._initializeHighlight();
|
||||
};
|
||||
|
||||
private _resetHighlight = () => {
|
||||
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._highlightedIndex = -1;
|
||||
};
|
||||
|
||||
private _initializeHighlight() {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
if (this.value) {
|
||||
const i = items.findIndex(
|
||||
(item) => typeof item !== "string" && item.id === this.value
|
||||
);
|
||||
if (i !== -1) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._first();
|
||||
}
|
||||
|
||||
private _initialPinIndex(): number {
|
||||
if (!this.value) return 0;
|
||||
const i = this.items.findIndex(
|
||||
(item) => typeof item !== "string" && item.id === this.value
|
||||
);
|
||||
return i === -1 ? 0 : i;
|
||||
}
|
||||
|
||||
private _isPickable(item: PickerListEntry | undefined): boolean {
|
||||
return !!item && typeof item !== "string" && item.id !== EMPTY_ROW_ID;
|
||||
}
|
||||
|
||||
private _step(direction: 1 | -1) {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
if (!items.length) return;
|
||||
let i = this._highlightedIndex + direction;
|
||||
const guard = items.length;
|
||||
let n = 0;
|
||||
while (n++ < guard && i >= 0 && i < items.length) {
|
||||
if (this._isPickable(items[i])) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
i += direction;
|
||||
}
|
||||
}
|
||||
|
||||
private _next = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (this._highlightedIndex === -1) {
|
||||
this._initializeHighlight();
|
||||
return;
|
||||
}
|
||||
this._step(1);
|
||||
};
|
||||
|
||||
private _prev = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (this._highlightedIndex === -1) {
|
||||
this._initializeHighlight();
|
||||
return;
|
||||
}
|
||||
this._step(-1);
|
||||
};
|
||||
|
||||
private _first = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
this._jumpTo(0, 1);
|
||||
};
|
||||
|
||||
private _last = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (!this._virtualizer) return;
|
||||
this._jumpTo(this._virtualizer.items.length - 1, -1);
|
||||
};
|
||||
|
||||
private _jumpTo(start: number, direction: 1 | -1) {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
for (let i = start; i >= 0 && i < items.length; i += direction) {
|
||||
if (this._isPickable(items[i])) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _commitHighlight = (ev: KeyboardEvent) => {
|
||||
this._commitAt(this._highlightedIndex, ev.ctrlKey || ev.metaKey);
|
||||
};
|
||||
|
||||
private _commitHighlightNewTab = () => {
|
||||
this._commitAt(this._highlightedIndex, true);
|
||||
};
|
||||
|
||||
private _commitAt(index: number, newTab: boolean) {
|
||||
if (index === -1 || !this._virtualizer) return;
|
||||
const item = this._virtualizer.items[index] as PickerListEntry;
|
||||
if (typeof item === "string" || item.disabled) return;
|
||||
this._dispatchSelection(item, index, newTab);
|
||||
}
|
||||
|
||||
private _scrollToHighlight() {
|
||||
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._virtualizer
|
||||
?.element(this._highlightedIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
requestAnimationFrame(() => {
|
||||
this._virtualizer
|
||||
?.querySelector(`#list-item-${this._highlightedIndex}`)
|
||||
?.classList.add("selected");
|
||||
});
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
lit-virtualizer {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-height: 36px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row.empty {
|
||||
cursor: default;
|
||||
}
|
||||
.row ha-combo-box-item {
|
||||
width: 100%;
|
||||
}
|
||||
.row.current-value {
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
}
|
||||
.row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-normal-hover);
|
||||
}
|
||||
}
|
||||
.title {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
padding: var(--ha-space-1) var(--ha-space-4);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
color: var(--secondary-text-color);
|
||||
min-height: var(--ha-space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-list": HaPickerList;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"item-selected": { id: string; index: number; newTab?: boolean };
|
||||
"picker-close-request": undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type CSSResultGroup,
|
||||
type PropertyValues,
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-bottom-sheet";
|
||||
|
||||
/**
|
||||
* Responsive popover for picker UIs: anchored `wa-popover` on desktop,
|
||||
* `ha-bottom-sheet` on narrow viewports. Anchor drives the width.
|
||||
*/
|
||||
@customElement("ha-picker-popover")
|
||||
export class HaPickerPopover extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public open = false;
|
||||
|
||||
@property({ attribute: false }) public anchor?: HTMLElement | null;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public placement:
|
||||
| "bottom"
|
||||
| "top"
|
||||
| "left"
|
||||
| "right"
|
||||
| "top-start"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right-end"
|
||||
| "bottom-start"
|
||||
| "bottom-end"
|
||||
| "left-start"
|
||||
| "left-end" = "bottom-start";
|
||||
|
||||
@state() private _bodyWidth = 0;
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
// Kept true across the hide animation so wa-popover can finish its
|
||||
// transition; cleared on wa-after-hide.
|
||||
@state() private _mounted = false;
|
||||
|
||||
// Flipped one rAF after mount so wa-popover sees a false→true edge
|
||||
// and runs the show flow.
|
||||
@state() private _showing = false;
|
||||
|
||||
// Defers slot projection until after the show animation so a
|
||||
// virtualized list inside isn't measured at the scaled size.
|
||||
@state() private _contentReady = false;
|
||||
|
||||
private _openFrame?: number;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("open")) {
|
||||
if (this.open) {
|
||||
this._measureAnchor();
|
||||
this._openedNarrow = this._narrow;
|
||||
this._mounted = true;
|
||||
} else {
|
||||
this._showing = false;
|
||||
}
|
||||
}
|
||||
if (changedProperties.has("anchor") && this.open) {
|
||||
this._measureAnchor();
|
||||
}
|
||||
}
|
||||
|
||||
private _measureAnchor() {
|
||||
if (this.anchor) {
|
||||
this._bodyWidth = this.anchor.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
if (this.open && this._mounted && !this._showing) {
|
||||
this._scheduleShow();
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleShow() {
|
||||
if (this._openFrame !== undefined) return;
|
||||
this._openFrame = requestAnimationFrame(() => {
|
||||
this._openFrame = undefined;
|
||||
if (this.open && this._mounted) {
|
||||
this._showing = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _cancelShow() {
|
||||
if (this._openFrame === undefined) return;
|
||||
cancelAnimationFrame(this._openFrame);
|
||||
this._openFrame = undefined;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._handleResize();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
this.addEventListener("picker-close-request", this._handleCloseRequest);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
this.removeEventListener("picker-close-request", this._handleCloseRequest);
|
||||
this._cancelShow();
|
||||
}
|
||||
|
||||
private _handleCloseRequest = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._showing = false;
|
||||
};
|
||||
|
||||
private _handleResize = () => {
|
||||
this._narrow =
|
||||
window.matchMedia("(max-width: 870px)").matches ||
|
||||
window.matchMedia("(max-height: 500px)").matches;
|
||||
|
||||
if (!this._openedNarrow && this.open) {
|
||||
this._measureAnchor();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleShown = () => {
|
||||
this._contentReady = true;
|
||||
fireEvent(this, "opened");
|
||||
// Native [autofocus] fires before the popover is visible; refocus
|
||||
// a slotted search component after projection.
|
||||
requestAnimationFrame(() => {
|
||||
const focusable = this.querySelector(
|
||||
"ha-picker-search-list, ha-picker-search"
|
||||
) as (HTMLElement & { focus?: () => void }) | null;
|
||||
focusable?.focus?.();
|
||||
});
|
||||
};
|
||||
|
||||
private _handleHidden = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._mounted = false;
|
||||
this._showing = false;
|
||||
this._contentReady = false;
|
||||
fireEvent(this, "closed");
|
||||
};
|
||||
|
||||
protected render() {
|
||||
if (!this._mounted) return nothing;
|
||||
|
||||
if (this._openedNarrow) {
|
||||
return html`
|
||||
<ha-bottom-sheet
|
||||
flexcontent
|
||||
.open=${this._showing}
|
||||
@wa-after-show=${this._handleShown}
|
||||
@closed=${this._handleHidden}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.label ?? ""}
|
||||
>
|
||||
<div class="content">
|
||||
${this._contentReady ? html`<slot></slot>` : nothing}
|
||||
</div>
|
||||
</ha-bottom-sheet>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<wa-popover
|
||||
.open=${this._showing}
|
||||
style=${styleMap({ "--body-width": `${this._bodyWidth}px` })}
|
||||
without-arrow
|
||||
distance="-4"
|
||||
.placement=${this.placement}
|
||||
.anchor=${this.anchor ?? null}
|
||||
auto-size="vertical"
|
||||
auto-size-padding="16"
|
||||
@wa-after-show=${this._handleShown}
|
||||
@wa-after-hide=${this._handleHidden}
|
||||
trap-focus
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.label ?? ""}
|
||||
>
|
||||
<div class="content">
|
||||
${this._contentReady ? html`<slot></slot>` : nothing}
|
||||
</div>
|
||||
</wa-popover>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
/* Disable wa-popover's built-in 25rem cap. */
|
||||
--max-width: none;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
width: var(--ha-picker-popover-width, max(var(--body-width), 250px));
|
||||
max-width: var(
|
||||
--ha-picker-popover-max-width,
|
||||
var(--ha-picker-popover-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
ha-bottom-sheet {
|
||||
--ha-bottom-sheet-height: 90vh;
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
|
||||
--ha-bottom-sheet-max-width: 600px;
|
||||
--ha-bottom-sheet-padding: 0;
|
||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
||||
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
|
||||
--ha-bottom-sheet-content-padding: 0 var(--safe-area-inset-right)
|
||||
var(--safe-area-inset-bottom) var(--safe-area-inset-left);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-popover": HaPickerPopover;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import Fuse from "fuse.js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
type CSSResultGroup,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import { DEFAULT_SEARCH_KEYS } from "./ha-picker-combo-box";
|
||||
import type { HaPickerList, PickerListItem } from "./ha-picker-list";
|
||||
import "./ha-picker-list";
|
||||
import "./ha-picker-search";
|
||||
import type { HaPickerSearch } from "./ha-picker-search";
|
||||
|
||||
export type PickerSearchFn<T extends PickerListItem = PickerListItem> = (
|
||||
search: string,
|
||||
filtered: T[],
|
||||
all: T[]
|
||||
) => T[];
|
||||
|
||||
/**
|
||||
* Search input + virtualized list with built-in Fuse.js filtering.
|
||||
* For custom filtering pipelines, compose `ha-picker-search` and
|
||||
* `ha-picker-list` directly instead.
|
||||
*/
|
||||
@customElement("ha-picker-search-list")
|
||||
export class HaPickerSearchList<
|
||||
T extends PickerListItem = PickerListItem,
|
||||
> extends LitElement {
|
||||
@property({ attribute: false }) public items: T[] = [];
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property({ attribute: false }) public searchKeys?: FuseWeightedKey[];
|
||||
|
||||
@property({ attribute: false }) public searchFn?: PickerSearchFn<T>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<T>;
|
||||
|
||||
@property({ attribute: false }) public actions?: PickerListItem[];
|
||||
|
||||
@property({ attribute: "search-placeholder" })
|
||||
public searchPlaceholder?: string;
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public notFoundLabel?:
|
||||
| string
|
||||
| TemplateResult
|
||||
| ((search: string) => string | TemplateResult);
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@query("ha-picker-search") private _searchEl?: HaPickerSearch;
|
||||
|
||||
@query("ha-picker-list") private _listEl?: HaPickerList;
|
||||
|
||||
public focus() {
|
||||
this._searchEl?.focus();
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this._search = "";
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const displayItems = this._computeDisplayItems(
|
||||
this.items,
|
||||
this._search,
|
||||
this.searchKeys,
|
||||
this.searchFn,
|
||||
this.actions
|
||||
);
|
||||
return html`
|
||||
<ha-picker-search
|
||||
?autofocus=${this.autofocus}
|
||||
.value=${this._search}
|
||||
.placeholder=${this.searchPlaceholder ?? ""}
|
||||
@search-changed=${this._handleSearchChanged}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
></ha-picker-search>
|
||||
<ha-picker-list
|
||||
.items=${displayItems}
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this.rowRenderer as RenderItemFunction<PickerListItem>}
|
||||
.currentSearch=${this._search}
|
||||
.notFoundLabel=${this.notFoundLabel}
|
||||
.emptyLabel=${this.emptyLabel}
|
||||
></ha-picker-list>
|
||||
`;
|
||||
}
|
||||
|
||||
// Forward nav keys from search input to list (focus stays in search).
|
||||
private _handleSearchKeydown = (ev: KeyboardEvent) => {
|
||||
const list = this._listEl;
|
||||
if (!list) return;
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
list.selectNext(ev);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
ev.preventDefault();
|
||||
list.selectPrev(ev);
|
||||
break;
|
||||
case "Home":
|
||||
ev.preventDefault();
|
||||
list.selectFirst(ev);
|
||||
break;
|
||||
case "End":
|
||||
ev.preventDefault();
|
||||
list.selectLast(ev);
|
||||
break;
|
||||
case "Enter":
|
||||
ev.preventDefault();
|
||||
list.commitHighlighted(ev.ctrlKey || ev.metaKey);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
private _fuseIndex = memoizeOne(
|
||||
(items: T[], searchKeys?: FuseWeightedKey[]) =>
|
||||
Fuse.createIndex(searchKeys ?? DEFAULT_SEARCH_KEYS, items)
|
||||
);
|
||||
|
||||
private _computeDisplayItems = memoizeOne(
|
||||
(
|
||||
items: T[],
|
||||
search: string,
|
||||
searchKeys: FuseWeightedKey[] | undefined,
|
||||
searchFn: PickerSearchFn<T> | undefined,
|
||||
actions: PickerListItem[] | undefined
|
||||
): PickerListItem[] => {
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
const keys = searchKeys ?? DEFAULT_SEARCH_KEYS;
|
||||
const index = this._fuseIndex(items, keys);
|
||||
filtered = multiTermSortedSearch<T>(
|
||||
items,
|
||||
search,
|
||||
keys,
|
||||
(item) => item.id,
|
||||
index
|
||||
);
|
||||
if (searchFn) {
|
||||
filtered = searchFn(search, filtered, items);
|
||||
}
|
||||
}
|
||||
if (actions?.length) {
|
||||
return [...filtered, ...actions];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
);
|
||||
|
||||
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
|
||||
this._search = ev.detail.value;
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-search-list": HaPickerSearchList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { css, html, LitElement, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
/** Search input for picker UIs; emits `search-changed`. */
|
||||
@customElement("ha-picker-search")
|
||||
export class HaPickerSearch extends LitElement {
|
||||
@property() public value = "";
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@query("ha-input-search") private _input?: HaInputSearch;
|
||||
|
||||
public focus() {
|
||||
// ha-input doesn't expose focus(); reach the wa-input it wraps.
|
||||
this._input?.shadowRoot?.querySelector<HTMLElement>("wa-input")?.focus();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this.value}
|
||||
.placeholder=${this.placeholder ?? ""}
|
||||
?autofocus=${this.autofocus}
|
||||
@input=${this._handleInput}
|
||||
></ha-input-search>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleInput = (ev: Event) => {
|
||||
const value = (ev.target as HaInputSearch).value ?? "";
|
||||
this.value = value;
|
||||
fireEvent(this, "search-changed", { value });
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
ha-input-search {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-search": HaPickerSearch;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"search-changed": { value: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { isTouch } from "../util/is_touch";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-filter-chip";
|
||||
|
||||
export interface PickerSection {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type PickerSectionDef = PickerSection | "separator";
|
||||
|
||||
/** Section filter chip bar; emits `section-changed`. Toggling the active chip clears the filter. */
|
||||
@customElement("ha-picker-section-chips")
|
||||
export class HaPickerSectionChips extends LitElement {
|
||||
@property({ attribute: false }) public sections?: PickerSectionDef[];
|
||||
|
||||
@property() public selected?: string;
|
||||
|
||||
protected render() {
|
||||
if (!this.sections?.length) return nothing;
|
||||
return html`
|
||||
<ha-chip-set>
|
||||
${this.sections.map((section) =>
|
||||
section === "separator"
|
||||
? html`<div class="separator"></div>`
|
||||
: html`<ha-filter-chip
|
||||
@mousedown=${isTouch ? undefined : this._preventBlur}
|
||||
@click=${this._handleClick}
|
||||
data-section-id=${section.id}
|
||||
.selected=${this.selected === section.id}
|
||||
.label=${section.label}
|
||||
></ha-filter-chip>`
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`;
|
||||
}
|
||||
|
||||
private _preventBlur = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
private _handleClick = (ev: Event) => {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.sectionId;
|
||||
if (!id) return;
|
||||
const next = this.selected === id ? undefined : id;
|
||||
this.selected = next;
|
||||
fireEvent(this, "section-changed", { section: next });
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
ha-chip-set {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--ha-space-2);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
/* Room for the chip's focus ring (clipped by overflow-y: hidden). */
|
||||
padding: var(--ha-space-1) 0;
|
||||
margin: calc(-1 * var(--ha-space-1)) 0;
|
||||
}
|
||||
ha-chip-set::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
ha-chip-set ha-filter-chip {
|
||||
flex-shrink: 0;
|
||||
--md-filter-chip-selected-container-color: var(
|
||||
--ha-color-fill-primary-normal-hover
|
||||
);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.separator {
|
||||
height: var(--ha-space-8);
|
||||
width: 0;
|
||||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-section-chips": HaPickerSectionChips;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"section-changed": { section: string | undefined };
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,6 @@ export class HaRelatedItems extends LitElement {
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${entity}
|
||||
slot="graphic"
|
||||
></ha-state-icon>
|
||||
@@ -322,7 +321,6 @@ export class HaRelatedItems extends LitElement {
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${group}
|
||||
slot="graphic"
|
||||
></ha-state-icon>
|
||||
@@ -347,7 +345,6 @@ export class HaRelatedItems extends LitElement {
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${scene}
|
||||
slot="graphic"
|
||||
></ha-state-icon>
|
||||
@@ -400,7 +397,6 @@ export class HaRelatedItems extends LitElement {
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${automation}
|
||||
slot="graphic"
|
||||
></ha-state-icon>
|
||||
@@ -452,7 +448,6 @@ export class HaRelatedItems extends LitElement {
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-state-icon
|
||||
.hass=${this.hass}
|
||||
.stateObj=${script}
|
||||
slot="graphic"
|
||||
></ha-state-icon>
|
||||
|
||||
@@ -63,7 +63,12 @@ export class HaSelectBox extends LitElement {
|
||||
const selected = option.value === this.value;
|
||||
|
||||
const isDark = this.hass?.themes.darkMode || false;
|
||||
const isRTL = this.hass ? computeRTL(this.hass) : false;
|
||||
const isRTL = this.hass
|
||||
? computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
: false;
|
||||
|
||||
const imageSrc =
|
||||
typeof option.image === "object"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import type { ColorTempSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-labeled-slider";
|
||||
@@ -94,10 +94,10 @@ export class HaColorTempSelector extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
private _valueChanged(ev: HASSDomEvent<HASSDomEvents["value-changed"]>) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: Number((ev.detail as any).value),
|
||||
value: Number(ev.detail.value),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,15 @@ export class HaIconSelector extends LitElement {
|
||||
const placeholder =
|
||||
this.selector.icon?.placeholder ||
|
||||
stateObj?.attributes.icon ||
|
||||
(stateObj && until(entityIcon(this.hass, stateObj)));
|
||||
(stateObj &&
|
||||
until(
|
||||
entityIcon(
|
||||
this.hass.entities,
|
||||
this.hass.config,
|
||||
this.hass.connection,
|
||||
stateObj
|
||||
)
|
||||
));
|
||||
|
||||
return html`
|
||||
<ha-icon-picker
|
||||
@@ -51,11 +59,7 @@ export class HaIconSelector extends LitElement {
|
||||
>
|
||||
${!placeholder && stateObj
|
||||
? html`
|
||||
<ha-state-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
></ha-state-icon>
|
||||
<ha-state-icon slot="start" .stateObj=${stateObj}></ha-state-icon>
|
||||
`
|
||||
: nothing}
|
||||
</ha-icon-picker>
|
||||
|
||||
@@ -188,7 +188,6 @@ export class HaObjectSelector extends LitElement {
|
||||
}
|
||||
|
||||
return html`<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
.readonly=${this.disabled}
|
||||
.label=${this.label}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -101,7 +101,6 @@ export class HaTemplateSelector extends LitElement {
|
||||
: nothing}
|
||||
<ha-code-editor
|
||||
mode="jinja2"
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.readOnly=${this.disabled}
|
||||
.placeholder=${this.placeholder || "{{ ... }}"}
|
||||
|
||||
@@ -86,9 +86,6 @@ export class HaServiceControl extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: "show-advanced", type: Boolean })
|
||||
public showAdvanced = false;
|
||||
|
||||
@property({ attribute: "show-service-id", type: Boolean })
|
||||
public showServiceId = false;
|
||||
|
||||
@@ -545,7 +542,6 @@ export class HaServiceControl extends LitElement {
|
||||
: ""}
|
||||
${shouldRenderServiceDataYaml
|
||||
? html`<ha-yaml-editor
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.service-control.action_data"
|
||||
)}
|
||||
@@ -667,10 +663,7 @@ export class HaServiceControl extends LitElement {
|
||||
? this.hass.services[domain][serviceName].description_placeholders
|
||||
: undefined;
|
||||
|
||||
return dataField.selector &&
|
||||
(!dataField.advanced ||
|
||||
this.showAdvanced ||
|
||||
(this._value?.data && this._value.data[dataField.key] !== undefined))
|
||||
return dataField.selector
|
||||
? html`<ha-settings-row .narrow=${this.narrow}>
|
||||
${!showOptional
|
||||
? hasOptional
|
||||
@@ -844,7 +837,7 @@ export class HaServiceControl extends LitElement {
|
||||
if (targetDevices.length) {
|
||||
targetDevices = targetDevices.filter((device) =>
|
||||
deviceMeetsTargetSelector(
|
||||
this.hass,
|
||||
this.hass.states,
|
||||
Object.values(this.hass.entities),
|
||||
this.hass.devices[device],
|
||||
targetSelector
|
||||
|
||||
@@ -30,6 +30,7 @@ export class HaSettingsRow extends LitElement {
|
||||
<slot name="prefix"></slot>
|
||||
<div
|
||||
class="body"
|
||||
part="heading"
|
||||
?two-line=${!this.threeLine && hasDescription}
|
||||
?three-line=${this.threeLine}
|
||||
>
|
||||
|
||||
@@ -523,7 +523,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
}
|
||||
|
||||
private _renderUserItem(selectedPanel: string) {
|
||||
const isRTL = computeRTL(this.hass);
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
const isSelected = selectedPanel === "profile";
|
||||
|
||||
return html`
|
||||
@@ -561,9 +564,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
id="sidebar-external-config"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
|
||||
>
|
||||
<span class="item-text" slot="headline">
|
||||
${this.hass.localize("ui.sidebar.external_app_configuration")}
|
||||
</span>
|
||||
</ha-list-item-button>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
@@ -740,6 +743,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--ha-row-item-min-height: var(--ha-space-10);
|
||||
--ha-row-item-padding-block: 0;
|
||||
--ha-row-item-padding-inline: var(--ha-space-3);
|
||||
width: var(--ha-space-12);
|
||||
position: relative;
|
||||
transition: width var(--ha-animation-duration-normal) ease;
|
||||
@@ -840,21 +844,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
}
|
||||
|
||||
ha-user-badge {
|
||||
width: var(--ha-space-10);
|
||||
height: var(--ha-space-10);
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
ha-list-item-button.user {
|
||||
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-list-item-button.user.rtl {
|
||||
--ha-row-item-padding-inline: var(--ha-space-4) var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-user-badge {
|
||||
flex-shrink: 0;
|
||||
margin-right: calc(var(--ha-space-2) * -1);
|
||||
--ha-row-item-padding-inline: var(--ha-space-1) 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
|
||||
@@ -1,31 +1,46 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { until } from "lit/directives/until";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
entitiesContext,
|
||||
} from "../data/context";
|
||||
import {
|
||||
DEFAULT_DOMAIN_ICON,
|
||||
entityIcon,
|
||||
FALLBACK_DOMAIN_ICONS,
|
||||
} from "../data/icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-state-icon")
|
||||
export class HaStateIcon extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public stateObj?: HassEntity;
|
||||
|
||||
@property({ attribute: false }) public stateValue?: string;
|
||||
|
||||
@property() public icon?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
protected _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: connectionContext, subscribe: true })
|
||||
protected _connection?: ContextType<typeof connectionContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: entitiesContext, subscribe: true })
|
||||
protected _entities?: ContextType<typeof entitiesContext>;
|
||||
|
||||
protected render() {
|
||||
const overrideIcon =
|
||||
this.icon ||
|
||||
(this.stateObj && this.hass?.entities[this.stateObj.entity_id]?.icon) ||
|
||||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
|
||||
this.stateObj?.attributes.icon;
|
||||
if (overrideIcon) {
|
||||
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
|
||||
@@ -33,17 +48,21 @@ export class HaStateIcon extends LitElement {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
if (!this.hass) {
|
||||
if (!this._config || !this._connection || !this._entities) {
|
||||
return this._renderFallback();
|
||||
}
|
||||
const icon = entityIcon(this.hass, this.stateObj, this.stateValue).then(
|
||||
(icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
return this._renderFallback();
|
||||
const icon = entityIcon(
|
||||
this._entities,
|
||||
this._config.config,
|
||||
this._connection.connection,
|
||||
this.stateObj,
|
||||
this.stateValue
|
||||
).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
);
|
||||
return this._renderFallback();
|
||||
});
|
||||
return html`${until(icon)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { mdiStarFourPoints } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type {
|
||||
@@ -52,6 +52,10 @@ export class HaSuggestWithAIButton extends LitElement {
|
||||
@state()
|
||||
private _minWidth?: string;
|
||||
|
||||
@query("ha-assist-chip") private _chip?: HTMLElement & {
|
||||
offsetWidth: number;
|
||||
};
|
||||
|
||||
private _intervalId?: number;
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues<this>): void {
|
||||
@@ -109,9 +113,8 @@ export class HaSuggestWithAIButton extends LitElement {
|
||||
}
|
||||
|
||||
// Capture current width before changing state
|
||||
const chip = this.shadowRoot?.querySelector("ha-assist-chip");
|
||||
if (chip) {
|
||||
this._minWidth = `${chip.offsetWidth}px`;
|
||||
if (this._chip) {
|
||||
this._minWidth = `${this._chip.offsetWidth}px`;
|
||||
}
|
||||
|
||||
// Reset to suggesting state
|
||||
|
||||
+150
-133
@@ -1,6 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import { mdiPlaylistPlus, mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -53,19 +52,22 @@ import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { brandsUrl } from "../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-button";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-picker-list";
|
||||
import type { PickerListEntry, PickerListItem } from "./ha-picker-list";
|
||||
import "./ha-picker-popover";
|
||||
import "./ha-picker-search";
|
||||
import "./ha-picker-section-chips";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
const isTargetType = (value: string): value is TargetType =>
|
||||
value === "entity" ||
|
||||
value === "device" ||
|
||||
@@ -122,11 +124,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
@state() private _pickerOpen = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLElement;
|
||||
|
||||
private _newTarget?: TargetItem;
|
||||
|
||||
@@ -412,56 +418,92 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
},
|
||||
];
|
||||
|
||||
const items = this._buildListEntries(
|
||||
this._search,
|
||||
this._selectedSection,
|
||||
this.createDomains
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="add-target-wrapper">
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
<ha-button
|
||||
class="add-target-button"
|
||||
size="small"
|
||||
appearance="filled"
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.helper=${this.helper}
|
||||
.sections=${sections}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
.sectionTitleFunction=${this._sectionTitleFunction}
|
||||
.selectedSection=${this._selectedSection}
|
||||
.popoverAnchor=${this._replaceTargetAnchor}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.getItems=${this._getItems}
|
||||
@value-changed=${this._targetPicked}
|
||||
@picker-closed=${this._handlePickerClosed}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.add_target"
|
||||
)}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.target-picker.add_target")}
|
||||
</ha-button>
|
||||
<ha-picker-popover
|
||||
.open=${this._pickerOpen}
|
||||
.anchor=${this._replaceTargetAnchor ?? this._addTargetWrapper}
|
||||
.label=${this.hass.localize("ui.components.target-picker.add_target")}
|
||||
@closed=${this._handlePickerClosed}
|
||||
>
|
||||
<div class="picker-body">
|
||||
<ha-picker-search
|
||||
autofocus
|
||||
.value=${this._search}
|
||||
.placeholder=${this.hass.localize("ui.common.search")}
|
||||
@search-changed=${this._handleSearchChanged}
|
||||
></ha-picker-search>
|
||||
<ha-picker-section-chips
|
||||
.sections=${sections}
|
||||
.selected=${this._selectedSection}
|
||||
@section-changed=${this._handleSectionChanged}
|
||||
></ha-picker-section-chips>
|
||||
<ha-picker-list
|
||||
.items=${items}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.currentSearch=${this._search}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
@item-selected=${this._handleItemSelected}
|
||||
></ha-picker-list>
|
||||
</div>
|
||||
</ha-picker-popover>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _targetPicked(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
if (value.startsWith(CREATE_ID)) {
|
||||
this._createNewDomainElement(value.substring(CREATE_ID.length));
|
||||
return;
|
||||
}
|
||||
private _openPicker = () => {
|
||||
if (this.disabled) return;
|
||||
this._pickerOpen = true;
|
||||
};
|
||||
|
||||
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
|
||||
this._search = ev.detail.value;
|
||||
};
|
||||
|
||||
private _handleSectionChanged = (
|
||||
ev: HASSDomEvent<{ section: string | undefined }>
|
||||
) => {
|
||||
this._selectedSection = ev.detail.section as
|
||||
| TargetTypeFloorless
|
||||
| undefined;
|
||||
};
|
||||
|
||||
private _handleItemSelected = (
|
||||
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.id;
|
||||
const [rawType, id] = value.split(SEPARATOR);
|
||||
|
||||
if (!id || !isTargetType(rawType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._replaceTarget) {
|
||||
this._replaceTargetItem(this._replaceTarget, { type: rawType, id });
|
||||
return;
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._pendingPick = { type: rawType, id };
|
||||
};
|
||||
|
||||
this._addTarget(id, rawType);
|
||||
}
|
||||
// Commit fires on @closed (after the hide animation) to avoid flicker.
|
||||
private _pendingPick?: TargetItem;
|
||||
|
||||
private _replaceTargetItem(currentTarget: TargetItem, newTarget: TargetItem) {
|
||||
const value = this._replaceTargetInValue(
|
||||
@@ -486,6 +528,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
fireEvent(this, "value-changed", { value });
|
||||
|
||||
// eslint-disable-next-line lit/prefer-query-decorators
|
||||
this.shadowRoot
|
||||
?.querySelector(
|
||||
`ha-target-picker-item-group[type='${this._newTarget?.type}']`
|
||||
@@ -733,18 +776,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
this._replaceTarget = { type, id: ev.detail.id };
|
||||
this._picker?.open(undefined, {
|
||||
selectedValue: `${type}${SEPARATOR}${ev.detail.id}`,
|
||||
});
|
||||
this._pickerOpen = true;
|
||||
}
|
||||
|
||||
private _handlePickerClosed() {
|
||||
private _handlePickerClosed = () => {
|
||||
if (this._pendingPick) {
|
||||
const pick = this._pendingPick;
|
||||
this._pendingPick = undefined;
|
||||
if (this._replaceTarget) {
|
||||
this._replaceTargetItem(this._replaceTarget, pick);
|
||||
} else {
|
||||
this._addTarget(pick.id, pick.type);
|
||||
}
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._search = "";
|
||||
if (this._replaceTarget) {
|
||||
this._selectedSection = undefined;
|
||||
}
|
||||
this._replaceTarget = undefined;
|
||||
this._replaceTargetAnchor = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
private _addItems(
|
||||
value: this["value"],
|
||||
@@ -781,55 +833,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _sectionTitleFunction = ({
|
||||
firstIndex,
|
||||
lastIndex,
|
||||
firstItem,
|
||||
secondItem,
|
||||
itemsCount,
|
||||
}: {
|
||||
firstIndex: number;
|
||||
lastIndex: number;
|
||||
firstItem: PickerComboBoxItem | string;
|
||||
secondItem: PickerComboBoxItem | string;
|
||||
itemsCount: number;
|
||||
}) => {
|
||||
if (
|
||||
firstItem === undefined ||
|
||||
secondItem === undefined ||
|
||||
typeof firstItem === "string" ||
|
||||
(typeof secondItem === "string" && secondItem !== "padding") ||
|
||||
(firstIndex === 0 && lastIndex === itemsCount - 1)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const type = getTargetComboBoxItemType(firstItem as PickerComboBoxItem);
|
||||
const translationType:
|
||||
| "areas"
|
||||
| "entities"
|
||||
| "devices"
|
||||
| "labels"
|
||||
| undefined =
|
||||
type === "area" || type === "floor"
|
||||
? "areas"
|
||||
: type === "entity"
|
||||
? "entities"
|
||||
: type && type !== "empty"
|
||||
? `${type}s`
|
||||
: undefined;
|
||||
|
||||
return translationType
|
||||
? this.hass.localize(
|
||||
`ui.components.target-picker.type.${translationType}`
|
||||
)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
private _getItems = (searchString: string, section: string) => {
|
||||
this._selectedSection = section as TargetTypeFloorless | undefined;
|
||||
|
||||
return this._getItemsMemoized(
|
||||
private _buildListEntries(
|
||||
searchString: string,
|
||||
section: TargetTypeFloorless | undefined,
|
||||
createDomains: this["createDomains"]
|
||||
): PickerListEntry[] {
|
||||
const items = this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this.entityFilter,
|
||||
this.deviceFilter,
|
||||
@@ -839,9 +848,37 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTarget,
|
||||
searchString,
|
||||
this._configEntryLookup,
|
||||
this._selectedSection
|
||||
);
|
||||
};
|
||||
section
|
||||
) as PickerListEntry[];
|
||||
|
||||
const actions = this._buildActionEntries(createDomains);
|
||||
return actions.length ? [...items, ...actions] : items;
|
||||
}
|
||||
|
||||
private _buildActionEntries = memoizeOne(
|
||||
(createDomains: this["createDomains"]): PickerListItem[] => {
|
||||
if (!createDomains?.length) return [];
|
||||
return createDomains.map((domain) => ({
|
||||
id: `__create-helper__${SEPARATOR}${domain}`,
|
||||
primary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(this.hass.localize, domain),
|
||||
}
|
||||
),
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
onSelect: ({ close }) => {
|
||||
close();
|
||||
this._createNewDomainElement(domain);
|
||||
},
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _getItemsMemoized = memoizeOne(
|
||||
(
|
||||
@@ -1082,36 +1119,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
(createDomains: this["createDomains"]) => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return createDomains.map((domain) => {
|
||||
const primary = this.hass.localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
|
||||
: domainToName(this.hass.localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
@@ -1136,7 +1143,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
if (type === "area" || type === "floor") {
|
||||
rtl = computeRTL(this.hass);
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
hasFloor =
|
||||
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
|
||||
}
|
||||
@@ -1252,14 +1262,21 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.add-target-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
.picker-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.items {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import "@home-assistant/webawesome/dist/components/textarea/textarea";
|
||||
import type WaTextarea from "@home-assistant/webawesome/dist/components/textarea/textarea";
|
||||
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { WaInputMixin, waInputStyles } from "./input/wa-input-mixin";
|
||||
|
||||
/**
|
||||
* Home Assistant textarea component
|
||||
@@ -84,6 +85,20 @@ export class HaTextArea extends WaInputMixin(LitElement) {
|
||||
this.removeEventListener("keydown", stopPropagation);
|
||||
}
|
||||
|
||||
protected override async firstUpdated(
|
||||
changedProperties: PropertyValues<this>
|
||||
): Promise<void> {
|
||||
super.firstUpdated(changedProperties);
|
||||
if (this.autofocus) {
|
||||
await this._textarea?.updateComplete;
|
||||
this._textarea?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
public override focus(options?: FocusOptions): void {
|
||||
this._textarea?.focus(options);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const hasLabelSlot = this.label
|
||||
? false
|
||||
|
||||
@@ -3,14 +3,16 @@ import { DEFAULT_SCHEMA, dump, load } from "js-yaml";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume } from "@lit/context";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import "./ha-button";
|
||||
import "./ha-code-editor";
|
||||
import type { HaCodeEditor } from "./ha-code-editor";
|
||||
import { internationalizationContext } from "../data/context";
|
||||
|
||||
const isEmpty = (obj: Record<string, unknown>): boolean => {
|
||||
if (typeof obj !== "object" || obj === null) {
|
||||
@@ -26,8 +28,6 @@ const isEmpty = (obj: Record<string, unknown>): boolean => {
|
||||
|
||||
@customElement("ha-yaml-editor")
|
||||
export class HaYamlEditor extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public value?: any;
|
||||
|
||||
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
|
||||
@@ -59,6 +59,10 @@ export class HaYamlEditor extends LitElement {
|
||||
|
||||
@state() private _yaml = "";
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@query("ha-code-editor") _codeEditor?: HaCodeEditor;
|
||||
|
||||
public setValue(value): void {
|
||||
@@ -112,7 +116,6 @@ export class HaYamlEditor extends LitElement {
|
||||
? html`<p>${this.label}${this.required ? " *" : ""}</p>`
|
||||
: nothing}
|
||||
<ha-code-editor
|
||||
.hass=${this.hass}
|
||||
.value=${this._yaml}
|
||||
.readOnly=${this.readOnly}
|
||||
.disableFullscreen=${this.disableFullscreen}
|
||||
@@ -132,7 +135,7 @@ export class HaYamlEditor extends LitElement {
|
||||
${this.copyClipboard
|
||||
? html`
|
||||
<ha-button appearance="plain" @click=${this._copyYaml}>
|
||||
${this.hass.localize(
|
||||
${this._i18n!.localize(
|
||||
"ui.components.yaml-editor.copy_to_clipboard"
|
||||
)}
|
||||
</ha-button>
|
||||
@@ -163,7 +166,7 @@ export class HaYamlEditor extends LitElement {
|
||||
// Invalid YAML
|
||||
isValid = false;
|
||||
yamlError = err;
|
||||
errorMsg = `${this.hass.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this.hass.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
errorMsg = `${this._i18n!.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this._i18n!.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
}
|
||||
} else {
|
||||
parsed = {};
|
||||
@@ -201,7 +204,7 @@ export class HaYamlEditor extends LitElement {
|
||||
if (this.yaml) {
|
||||
await copyToClipboard(this.yaml);
|
||||
showToast(this, {
|
||||
message: this.hass.localize("ui.common.copied_clipboard"),
|
||||
message: this._i18n!.localize("ui.common.copied_clipboard"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
@@ -67,6 +67,8 @@ class HaInputMulti extends LitElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@query("ha-input[data-last]") private _lastInput?: HaInput;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-sortable
|
||||
@@ -163,10 +165,7 @@ class HaInputMulti extends LitElement {
|
||||
const items = [...this._items, ""];
|
||||
this._fireChanged(items);
|
||||
await this.updateComplete;
|
||||
const field = this.shadowRoot?.querySelector(`ha-input[data-last]`) as
|
||||
| HaInput
|
||||
| undefined;
|
||||
field?.focus();
|
||||
this._lastInput?.focus();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import "../ha-ripple";
|
||||
import { HaListItemBase } from "./ha-list-item-base";
|
||||
@@ -34,8 +34,10 @@ export class HaListItemButton extends HaListItemBase {
|
||||
|
||||
@property({ type: String }) public download?: string;
|
||||
|
||||
@query("#item") private _item?: HTMLElement;
|
||||
|
||||
public override activate(): void {
|
||||
this.renderRoot.querySelector<HTMLElement>("#item")?.click();
|
||||
this._item?.click();
|
||||
}
|
||||
|
||||
protected _renderBase(inner: TemplateResult): TemplateResult {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* @element ha-row-item
|
||||
@@ -46,13 +46,34 @@ export class HaRowItem extends LitElement {
|
||||
|
||||
protected readonly _slotController = new HasSlotController(
|
||||
this,
|
||||
"start",
|
||||
"end",
|
||||
"headline",
|
||||
"supporting-text",
|
||||
"content"
|
||||
);
|
||||
|
||||
@state() private _hasStart = false;
|
||||
|
||||
@state() private _hasEnd = false;
|
||||
|
||||
private _onSlotChange(name: "start" | "end") {
|
||||
return (ev: Event) => {
|
||||
const slot = ev.target as HTMLSlotElement;
|
||||
const hasContent = slot
|
||||
.assignedNodes({ flatten: true })
|
||||
.some(
|
||||
(node) =>
|
||||
node.nodeType === Node.ELEMENT_NODE ||
|
||||
(node.nodeType === Node.TEXT_NODE &&
|
||||
(node as Text).textContent?.trim() !== "")
|
||||
);
|
||||
if (name === "start") {
|
||||
this._hasStart = hasContent;
|
||||
} else {
|
||||
this._hasEnd = hasContent;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return this._renderBase(this._renderInner());
|
||||
}
|
||||
@@ -65,16 +86,16 @@ export class HaRowItem extends LitElement {
|
||||
const hasContent = this._slotController.test("content");
|
||||
|
||||
return html`
|
||||
<div part="start" class="start">
|
||||
<slot name="start"></slot>
|
||||
<div part="start" class="start" ?hidden=${!this._hasStart}>
|
||||
<slot name="start" @slotchange=${this._onSlotChange("start")}></slot>
|
||||
</div>
|
||||
<div part="content" class="content">
|
||||
${hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()}
|
||||
</div>
|
||||
<div part="end" class="end">
|
||||
<slot name="end"></slot>
|
||||
<div part="end" class="end" ?hidden=${!this._hasEnd}>
|
||||
<slot name="end" @slotchange=${this._onSlotChange("end")}></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -109,10 +130,6 @@ export class HaRowItem extends LitElement {
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
--ha-row-item-padding-block: var(--ha-space-3);
|
||||
--ha-row-item-padding-inline: var(--ha-space-4);
|
||||
--ha-row-item-gap: var(--ha-space-4);
|
||||
--ha-row-item-min-height: 48px;
|
||||
}
|
||||
:host([disabled]) {
|
||||
color: var(--disabled-text-color);
|
||||
@@ -123,10 +140,10 @@ export class HaRowItem extends LitElement {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ha-row-item-gap);
|
||||
padding-block: var(--ha-row-item-padding-block);
|
||||
padding-inline: var(--ha-row-item-padding-inline);
|
||||
min-height: var(--ha-row-item-min-height);
|
||||
gap: var(--ha-row-item-gap, var(--ha-space-4));
|
||||
padding-block: var(--ha-row-item-padding-block, var(--ha-space-3));
|
||||
padding-inline: var(--ha-row-item-padding-inline, var(--ha-space-4));
|
||||
min-height: var(--ha-row-item-min-height, 48px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.content {
|
||||
@@ -142,8 +159,8 @@ export class HaRowItem extends LitElement {
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
:host(:not(:has([slot="start"]))) .start,
|
||||
:host(:not(:has([slot="end"]))) .end {
|
||||
.start[hidden],
|
||||
.end[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.headline {
|
||||
|
||||
@@ -292,14 +292,12 @@ export class HaListBase extends LitElement {
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
--ha-list-gap: 0;
|
||||
--ha-list-padding: 0;
|
||||
}
|
||||
.base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-list-gap);
|
||||
padding: var(--ha-list-padding);
|
||||
gap: var(--ha-list-gap, 0);
|
||||
padding: var(--ha-list-padding, 0);
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@@ -121,15 +121,15 @@ export class HaListSelectable extends HaListBase {
|
||||
|
||||
public updateListItems() {
|
||||
super.updateListItems();
|
||||
this._syncItemSelectedState();
|
||||
this._syncItemSelectedState(true);
|
||||
}
|
||||
|
||||
private _sortedSelectedIndices(): number[] {
|
||||
return [...this._selectedIndices!].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private _syncItemSelectedState() {
|
||||
if (!this._selectedIndices) {
|
||||
private _syncItemSelectedState(reset = false): void {
|
||||
if (!this._selectedIndices || reset) {
|
||||
this._selectedIndices = new Set<number>();
|
||||
this.items.forEach((item, i) => {
|
||||
const opt = item as HaListItemOption;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user