Compare commits

..

8 Commits

Author SHA1 Message Date
Petar Petrov 490893616b Update HTTP config form to new WebSocket API 2026-05-19 14:57:18 +03:00
Petar Petrov 0c188a88c4 Only apply add-button margin when helper text is present 2026-05-14 16:54:27 +03:00
Petar Petrov 770254b920 Add bottom margin to ha-input-multi add button 2026-05-14 16:53:41 +03:00
Petar Petrov 414d84ba83 Group HTTP form fields into collapsible sections 2026-05-14 16:47:58 +03:00
Petar Petrov 94bc9d8567 Update src/panels/config/network/ha-config-http-form.ts
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
2026-05-12 08:39:01 +03:00
Petar Petrov 18268c67dc Surface fetch errors and validate before saving in HTTP config form 2026-05-11 14:50:20 +03:00
Petar Petrov 75a2331c87 Add HTTP server settings to the network panel 2026-05-11 14:06:52 +03:00
Petar Petrov 40fa954542 Fix focus loss in ha-input-multi when items change 2026-05-11 14:06:27 +03:00
745 changed files with 11255 additions and 25741 deletions
-2
View File
@@ -58,8 +58,6 @@ 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
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
# ️ 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@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
+1 -1
View File
@@ -10,6 +10,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply labels
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with:
sync-labels: true
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
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@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
with:
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
token: ${{ github.token }}
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -1 +1 @@
24.16.0
24.15.0
File diff suppressed because one or more lines are too long
+3 -8
View File
@@ -1,16 +1,11 @@
approvedGitRepositories:
- "**"
compressionLevel: mixed
npmMinimalAgeGate: "3d"
defaultSemverRangePrefix: ""
enableGlobalCache: false
enableScripts: true
nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.15.0.cjs
yarnPath: .yarn/releases/yarn-4.14.1.cjs
+1 -7
View File
@@ -5,7 +5,6 @@ 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";
@@ -37,12 +36,7 @@ gulp.task(
process.env.NODE_ENV = "production";
},
"clean",
gulp.parallel(
"gen-icons-json",
"build-translations",
"build-locale-data",
"gen-licenses"
),
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-app",
"rspack-prod-app",
gulp.parallel("gen-pages-app-prod", "gen-service-worker-app-prod"),
-2
View File
@@ -1,4 +1,3 @@
/* global process */
// Tasks to generate entry HTML
import {
@@ -26,7 +25,6 @@ const SAFARI_TO_MACOS = {
16: [11, 0, 0],
17: [12, 0, 0],
18: [13, 0, 0],
26: [26, 0, 0],
};
const getCommonTemplateVars = () => {
-81
View File
@@ -1,81 +0,0 @@
// 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 }
);
});
+2
View File
@@ -1,3 +1,5 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../src/components/ha-icon-button";
+13 -33
View File
@@ -1,3 +1,5 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
@@ -5,12 +7,9 @@ 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";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import { PAGES, SIDEBAR } from "../build/import-pages";
@@ -40,8 +39,8 @@ class HaGallery extends LitElement {
@query("notification-manager")
private _notifications!: HTMLElementTagNameMap["notification-manager"];
@query("ha-drawer")
private _drawer!: HaDrawer;
@query("mwc-drawer")
private _drawer!: HTMLElementTagNameMap["mwc-drawer"];
private _narrow = window.matchMedia("(max-width: 600px)").matches;
@@ -76,15 +75,16 @@ class HaGallery extends LitElement {
}
return html`
<ha-drawer
.direction=${this._rtl ? "rtl" : "ltr"}
<mwc-drawer
hasHeader
.open=${!this._narrow}
.type=${this._narrow ? "modal" : "dismissible"}
>
<div class="drawer-title">Home Assistant Design</div>
<span slot="title">Home Assistant Design</span>
<!-- <span slot="subtitle">subtitle</span> -->
<div class="sidebar">${sidebar}</div>
<div slot="appContent" class="app-content">
<ha-top-app-bar-fixed>
<div slot="appContent">
<mwc-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@click=${this._menuTapped}
@@ -94,7 +94,7 @@ class HaGallery extends LitElement {
<div slot="title">
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
</div>
</ha-top-app-bar-fixed>
</mwc-top-app-bar-fixed>
<div class="content">
${PAGES[this._page].description
? html`
@@ -144,7 +144,7 @@ class HaGallery extends LitElement {
</div>
</div>
</div>
</ha-drawer>
</mwc-drawer>
<notification-manager
.hass=${FAKE_HASS}
id="notifications"
@@ -226,28 +226,12 @@ class HaGallery extends LitElement {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
--header-height: 64px;
}
.sidebar {
box-sizing: border-box;
max-height: calc(100vh - var(--header-height));
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: var(--header-height);
padding: 0 16px;
}
.sidebar a {
color: var(--primary-text-color);
display: block;
@@ -271,17 +255,13 @@ class HaGallery extends LitElement {
opacity: 0.12;
}
.app-content {
div[slot="appContent"] {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--primary-background-color);
}
ha-drawer[type="dismissible"][open] ha-top-app-bar-fixed {
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
}
.content {
flex: 1;
}
+10 -21
View File
@@ -1,12 +1,10 @@
import { provide } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import type { TemplateResult, PropertyValues } from "lit";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../../../src/components/ha-tip";
import { internationalizationContext } from "../../../../src/data/context";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import "../../../../src/components/ha-card";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import { provideHass } from "../../../../src/fake_data/provide_hass";
const tips: (string | TemplateResult)[] = [
"Test tip",
@@ -16,25 +14,16 @@ const tips: (string | TemplateResult)[] = [
@customElement("demo-components-ha-tip")
export class DemoHaTip extends LitElement {
@provide({ context: internationalizationContext })
@state()
protected _i18n: HomeAssistantInternationalization = {
localize: ((key: string) => key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
};
protected render(): TemplateResult {
return html` ${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-tip ${mode} demo">
<div class="card-content">
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
${tips.map(
(tip) =>
html`<ha-tip .hass=${provideHass(this)}>${tip}</ha-tip>`
)}
</div>
</ha-card>
</div>
+1
View File
@@ -1,2 +1,3 @@
[build.environment]
YARN_VERSION = "1.22.11"
NODE_OPTIONS = "--max_old_space_size=6144"
+46 -44
View File
@@ -14,7 +14,6 @@
"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",
@@ -27,7 +26,7 @@
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.29.7",
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/commands": "6.10.3",
@@ -37,42 +36,47 @@
"@codemirror/lint": "6.9.6",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@codemirror/view": "6.42.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.6",
"@formatjs/intl-displaynames": "7.3.8",
"@formatjs/intl-durationformat": "0.10.12",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.8",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.9",
"@formatjs/intl-pluralrules": "6.3.8",
"@formatjs/intl-relativetimeformat": "12.3.8",
"@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",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
"@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",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
"@material/web": "2.4.1",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.5",
"@tsparticles/preset-links": "4.0.5",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -83,27 +87,27 @@
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.3.0",
"date-fns": "4.1.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.3.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.4",
"intl-messageformat": "11.2.7",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.4",
"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.3",
"lit-html": "3.3.3",
"lit": "3.3.2",
"lit-html": "3.3.2",
"luxon": "3.7.2",
"marked": "18.0.4",
"marked": "18.0.3",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -115,7 +119,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "4.0.0",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -126,19 +130,19 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.7",
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.7",
"@babel/preset-env": "7.29.7",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.61.0",
"@lokalise/node-api": "16.0.0",
"@html-eslint/eslint-plugin": "0.60.0",
"@lokalise/node-api": "15.7.1",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.4",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.2",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -157,22 +161,21 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.7",
"@vitest/coverage-v8": "4.1.5",
"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.4.0",
"eslint": "10.3.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.3.1",
"eslint-plugin-lit": "2.2.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.2.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -183,8 +186,7 @@
"husky": "9.1.7",
"jsdom": "29.1.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "4.4.2",
"lint-staged": "17.0.5",
"lint-staged": "17.0.2",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -195,19 +197,19 @@
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.15",
"terser-webpack-plugin": "5.6.0",
"terser-webpack-plugin": "5.5.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.4",
"typescript-eslint": "8.59.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.7",
"vitest": "4.1.5",
"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.3",
"lit-html": "3.3.3",
"lit": "3.3.2",
"lit-html": "3.3.2",
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.2",
"@fullcalendar/daygrid": "6.1.20",
@@ -216,8 +218,8 @@
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.15.0",
"packageManager": "yarn@4.14.1",
"volta": {
"node": "24.16.0"
"node": "24.15.0"
}
}

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.0"
version = "20260429.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
-39
View File
@@ -18,46 +18,7 @@
"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"],
-91
View File
@@ -1,91 +0,0 @@
#!/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.`
);
}
);
+3 -4
View File
@@ -54,8 +54,6 @@ export class HaAuthFlow extends LitElement {
@query("ha-auth-form") private _form?: HaAuthForm;
@query("ha-form") private _haForm?: HTMLElement;
createRenderRoot() {
return this;
}
@@ -162,8 +160,9 @@ export class HaAuthFlow extends LitElement {
// 100ms to give all the form elements time to initialize.
setTimeout(() => {
if (this._haForm) {
(this._haForm as any).focus();
const form = this.renderRoot.querySelector("ha-form");
if (form) {
(form as any).focus();
}
}, 100);
}
+1 -1
View File
@@ -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: readonly T[]): T[][] {
export function getAllCombinations<T>(arr: T[]) {
return arr.reduce<T[][]>(
(combinations, element) =>
combinations.concat(
+6
View File
@@ -5,6 +5,7 @@ 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 = (
@@ -26,3 +27,8 @@ 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);
-9
View File
@@ -114,15 +114,6 @@ 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";
@@ -1,59 +0,0 @@
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 -15
View File
@@ -1,20 +1,6 @@
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
// instead of an IANA zone name. Only accept values that are known IANA zones,
// matching the list used by ha-timezone-picker.
const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
+2 -22
View File
@@ -1,16 +1,8 @@
import { consume } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../types";
import {
entitiesContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { HomeAssistant } from "../../types";
import { entitiesContext, statesContext } from "../../data/context";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { LocalizeFunc } from "../translations/localize";
import { transform } from "./transform";
interface ConsumeEntryConfig {
@@ -99,15 +91,3 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
return typeof id === "string" ? entities?.[id] : undefined;
}
);
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed — the decorated property updates
* whenever the i18n context changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
internationalizationContext,
undefined,
({ localize }) => localize
);
-38
View File
@@ -1,17 +1,3 @@
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 = {
@@ -26,18 +12,6 @@ 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",
@@ -83,15 +57,3 @@ 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,10 +137,7 @@ export const computeEntityPickerDisplay = (
hass.floors
);
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const isRTL = computeRTL(hass);
const primary = entityName || deviceName || stateObj.entity_id;
const secondary =
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
interface EntityUnitStubConfig {
@@ -21,24 +21,32 @@ export const computeEntityUnitDisplay = (
stateObj: HassEntity | undefined,
config: EntityUnitStubConfig
): string => {
let unit;
if (
!stateObj ||
stateObj.state === UNAVAILABLE ||
stateObj.state === UNKNOWN ||
(!config.attribute && stateObj.attributes.device_class === "duration")
stateObj &&
!isUnavailableState(stateObj.state) &&
(config.attribute || stateObj.attributes.device_class !== "duration")
) {
return "";
// 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 ?? "";
}
// 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 ?? "";
return "";
};
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { UNAVAILABLE_STATES } 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, UNKNOWN);
result.push(...UNAVAILABLE_STATES);
}
if (!attribute && domain in FIXED_DOMAIN_STATES) {
+5 -11
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import type { HomeAssistant } from "../../types";
import { computeStateDomain } from "./compute_state_domain";
@@ -8,18 +8,12 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
return UNAVAILABLE;
}
const allUnavailable = states.every(
(stateObj) => stateObj.state === UNAVAILABLE
const validState = states.some(
(stateObj) => !isUnavailableState(stateObj.state)
);
if (allUnavailable) {
return UNAVAILABLE;
}
const hasValidState = states.some(
(stateObj) => stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
);
if (!hasValidState) {
return UNKNOWN;
if (!validState) {
return UNAVAILABLE;
}
// Use the first state to determine the domain
+2 -2
View File
@@ -1,5 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState, OFF, UNAVAILABLE } 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 (compareState === UNAVAILABLE || compareState === UNKNOWN) {
if (isUnavailableState(compareState)) {
return false;
}
@@ -1,17 +0,0 @@
/**
* @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}`;
};
+6 -10
View File
@@ -1,20 +1,16 @@
import type { LitElement } from "lit";
import type { HomeAssistant, Translation } from "../../types";
import type { HomeAssistant } from "../../types";
export function computeRTL(
language = "en",
translations: Record<string, Translation>
) {
if (translations[language]) {
return translations[language].isRTL || false;
export function computeRTL(hass: HomeAssistant) {
const lang = hass.language || "en";
if (hass.translationMetadata.translations[lang]) {
return hass.translationMetadata.translations[lang].isRTL || false;
}
return false;
}
export function computeRTLDirection(hass: HomeAssistant) {
return emitRTLDirection(
computeRTL(hass.language, hass.translationMetadata.translations)
);
return emitRTLDirection(computeRTL(hass));
}
export function emitRTLDirection(rtl: boolean) {
@@ -121,7 +121,6 @@ export class HaAutomationRowEventChip extends LitElement {
align-items: center;
--mdc-icon-size: 16px;
line-height: 1;
box-shadow: var(--ha-box-shadow-s);
}
button {
@@ -1,75 +0,0 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
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.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@property({ reflect: true }) public state: LiveTestState = "unknown";
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
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,13 +124,10 @@ export class HaAutomationRow extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
.row {
display: flex;
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
padding: 0 0 0 var(--ha-space-3);
min-height: 48px;
align-items: flex-start;
cursor: pointer;
@@ -146,8 +143,6 @@ export class HaAutomationRow extends LitElement {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-inline-start: calc(var(--ha-space-2) * -1);
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
@@ -191,6 +186,7 @@ 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;
@@ -198,6 +194,7 @@ export class HaAutomationRow extends LitElement {
}
::slotted([slot="event"]) {
position: absolute;
top: 13px;
inset-inline-end: 0;
}
.icons {
+1 -1
View File
@@ -116,7 +116,7 @@ export class HaProgressButton extends LitElement {
visibility: hidden;
}
:host([appearance="brand"]) ha-svg-icon {
ha-svg-icon {
color: var(--white-color);
}
`;
@@ -1,40 +0,0 @@
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];
};
+50 -86
View File
@@ -14,13 +14,12 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
@@ -30,59 +29,22 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -104,9 +66,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public options?: HaECOption;
@property({ attribute: false }) public options?: ECOption;
@property({ type: String }) public height?: string;
@@ -140,8 +102,6 @@ export class HaChartBase extends LitElement {
@state() private _hiddenDatasets = new Set<string>();
@query(".chart") private _chartContainer?: HTMLDivElement;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -509,6 +469,7 @@ 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) {
@@ -523,7 +484,7 @@ export class HaChartBase extends LitElement {
const style = getComputedStyle(this);
echarts.registerTheme("custom", this._createTheme(style));
this.chart = echarts.init(this._chartContainer!, "custom");
this.chart = echarts.init(container, "custom");
this.chart.on("datazoom", (e: any) => {
this._handleDataZoomEvent(e);
});
@@ -652,7 +613,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: HaECOption | undefined,
options: ECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -672,7 +633,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -795,34 +756,22 @@ export class HaChartBase extends LitElement {
xAxis,
};
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
}
return options as ECOption;
return options;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -1006,16 +955,30 @@ export class HaChartBase extends LitElement {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if ((s as LineSeriesOption).sampling === "minmax") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: (data as LineSeriesOption["data"])!.map((v) =>
Array.isArray(v)
? [
v[0],
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
...v.slice(2),
]
: v
),
};
}
if (s.sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
? xAxis.min.getTime()
@@ -1030,8 +993,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
result = {
...result,
return {
...s,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -1039,10 +1002,11 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
} as HaECSeriesItem;
};
}
}
return processSeriesTooltipFormatter(result);
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
});
return series as ECOption["series"];
}
@@ -1379,8 +1343,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -1,41 +0,0 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues, TemplateResult } from "lit";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => TemplateResult | typeof nothing | null;
) => string;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): HaECOption => ({
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
+6 -10
View File
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options: HaECOption = {
const options = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
@@ -103,16 +103,12 @@ export class HaSankeyChart extends LitElement {
: data.value;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${value}`;
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${value}`;
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
}
return null;
};
+5 -8
View File
@@ -5,10 +5,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import type { HaECOption } from "../../resources/echarts/echarts";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -50,13 +50,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options: HaECOption = {
const options = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -71,10 +71,7 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
};
private _createData = memoizeOne(
@@ -1,41 +0,0 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -11,10 +11,7 @@ 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 "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -25,6 +22,7 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@@ -118,7 +116,9 @@ export class StateHistoryChartLine extends LitElement {
private _chartTime: Date = new Date();
private _yAxisFractionDigits = 1;
private _previousYAxisLabelValue = 0;
private _yAxisMaximumFractionDigits = 0;
protected render() {
return html`
@@ -141,11 +141,12 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -176,44 +177,52 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
color: dataset.color,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
};
private _datasetHidden(ev: CustomEvent) {
@@ -284,10 +293,7 @@ export class StateHistoryChartLine extends LitElement {
(changedProps.has("hass") &&
this._hasEntityStatesChanged(changedProps.get("hass")))
) {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const rtl = computeRTL(this.hass);
let minYAxis: number | ((values: { min: number }) => number) | undefined =
this.minYAxis;
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
@@ -404,7 +410,8 @@ export class StateHistoryChartLine extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
@@ -426,14 +433,6 @@ 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;
}
@@ -469,7 +468,6 @@ export class StateHistoryChartLine extends LitElement {
d.data!.push([timestamp, prevValues[i]]);
}
d.data!.push([timestamp, datavalues[i]]);
trackY(datavalues[i]);
});
prevValues = datavalues;
};
@@ -820,7 +818,6 @@ export class StateHistoryChartLine extends LitElement {
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
if (currentValue !== null) {
data[0].data!.push([now, currentValue]);
trackY(currentValue);
}
}
@@ -828,7 +825,6 @@ 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;
@@ -862,8 +858,20 @@ 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._yAxisFractionDigits,
maximumFractionDigits: this._yAxisMaximumFractionDigits,
});
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
@@ -873,6 +881,7 @@ export class StateHistoryChartLine extends LitElement {
chartIndex: this.chartIndex,
});
}
this._previousYAxisLabelValue = value;
return label;
};
@@ -1,10 +1,11 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -13,10 +14,8 @@ 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 "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +56,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@@ -69,7 +68,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as HaECSeries}
.data=${this._chartData as ECOption["series"]}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,44 +131,47 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
const markerLocalized = !computeRTL(this.hass)
? 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>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
public willUpdate(changedProps: PropertyValues) {
if (
this.isConnected &&
(changedProps.has("startTime") ||
changedProps.has("endTime") ||
changedProps.has("data") ||
this._chartTime <
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES))
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
@@ -196,10 +198,7 @@ export class StateHistoryChartTimeline extends LitElement {
? Math.max(this.paddingYAxis, this._yWidth)
: 0;
const labelMargin = 5;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const rtl = computeRTL(this.hass);
this._chartOptions = {
xAxis: {
type: "time",
@@ -257,7 +256,8 @@ export class StateHistoryChartTimeline extends LitElement {
},
tooltip: {
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
+11 -14
View File
@@ -2,13 +2,7 @@ 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,
queryAll,
state,
} from "lit/decorators";
import { customElement, eventOptions, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { restoreScroll } from "../../common/decorators/restore-scroll";
import type {
@@ -110,11 +104,6 @@ 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
@@ -338,7 +327,11 @@ export class StateHistoryCharts extends LitElement {
this._isSyncing = true;
requestAnimationFrame(() => {
this._chartComponents.forEach((chartComponent, index) => {
const chartComponents = this.renderRoot.querySelectorAll(
"state-history-chart-line, state-history-chart-timeline"
) as unknown as (StateHistoryChartLine | StateHistoryChartTimeline)[];
chartComponents.forEach((chartComponent, index) => {
if (index === sourceChartIndex) {
return;
}
@@ -357,7 +350,11 @@ export class StateHistoryCharts extends LitElement {
this._isSyncing = true;
requestAnimationFrame(() => {
this._chartComponents.forEach((chartComponent: any) => {
const chartComponents = this.renderRoot.querySelectorAll(
"state-history-chart-line, state-history-chart-timeline"
);
chartComponents.forEach((chartComponent: any) => {
const chartBase =
chartComponent.renderRoot?.querySelector("ha-chart-base");
+72 -165
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -13,9 +13,7 @@ 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,
@@ -34,15 +32,12 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
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 "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -127,13 +122,13 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _hiddenStats = new Set<string>();
private _computedStyle?: CSSStyleDeclaration;
private _yAxisFractionDigits = 1;
private _previousYAxisLabelValue = 0;
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return changedProps.size > 1 || !changedProps.has("hass");
@@ -145,8 +140,7 @@ export class StatisticsChart extends LitElement {
changedProps.has("statTypes") ||
changedProps.has("chartType") ||
changedProps.has("hideLegend") ||
changedProps.has("_hiddenStats") ||
changedProps.has("names")
changedProps.has("_hiddenStats")
) {
this._generateData();
}
@@ -247,106 +241,42 @@ 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}`
: "";
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
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)}`
: "");
} 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
)}`
: "");
}
} 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]),
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]);
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
this.hass.locale,
this.hass.config
);
}
options
)}${unit}`;
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
const time =
index === 0
? formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
) + "<br>"
: "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
};
private _createOptions() {
@@ -438,12 +368,7 @@ export class StatisticsChart extends LitElement {
nameTextStyle: {
align: "left",
},
position: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? "right"
: "left",
position: computeRTL(this.hass) ? "right" : "left",
scale:
this.chartType.startsWith("line") ||
this.logarithmicScale ||
@@ -473,7 +398,8 @@ export class StatisticsChart extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
@@ -508,14 +434,6 @@ 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;
@@ -588,57 +506,33 @@ 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, // Data point start time
end: Date, // Data point end time
limit: Date, // Limit for end time (e.g. now)
start: Date,
end: Date,
dataValues: (number | null)[][]
) => {
if (!dataValues.length) return;
// 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()) {
if (start > end) {
// 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") {
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]);
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]);
}
d.data!.push([start, ...dataValues[i]!]);
});
prevValues = dataValues;
prevEndTime = limit;
prevEndTime = end;
};
let color = colors[statistic_id];
@@ -798,7 +692,11 @@ export class StatisticsChart extends LitElement {
dataValues.push(val);
});
if (!this._hiddenStats.has(statistic_id)) {
pushData(startDate, endDate, endTime, dataValues);
pushData(
startDate,
endDate.getTime() < endTime.getTime() ? endDate : endTime,
dataValues
);
}
});
@@ -847,7 +745,6 @@ export class StatisticsChart extends LitElement {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
@@ -881,7 +778,6 @@ 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
@@ -915,10 +811,21 @@ export class StatisticsChart extends LitElement {
return Math.abs(value) < 1 ? value : roundingFn(value);
}
private _formatYAxisLabel = (value: number) =>
formatNumber(value, this.hass.locale, {
maximumFractionDigits: this._yAxisFractionDigits,
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,
});
this._previousYAxisLabelValue = value;
return label;
};
static styles = css`
:host {
@@ -1,9 +0,0 @@
// 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)));
}
@@ -1,14 +1,13 @@
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-dialog-footer";
import "../ha-icon-button";
@@ -25,9 +24,7 @@ import type { DataTableSettingsDialogParams } from "./show-dialog-data-table-set
@customElement("dialog-data-table-settings")
export class DialogDataTableSettings extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DataTableSettingsDialogParams;
@@ -120,7 +117,7 @@ export class DialogDataTableSettings extends LitElement {
return nothing;
}
const localize = this._params.localizeFunc || this._localize;
const localize = this._params.localizeFunc || this.hass.localize;
const columns = this._sortedColumns(
this._params.columns,
@@ -130,6 +127,7 @@ 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}
@@ -175,7 +173,7 @@ export class DialogDataTableSettings extends LitElement {
.hidden=${!isVisible}
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
.label=${localize(
.label=${this.hass!.localize(
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
{ title: typeof col.title === "string" ? col.title : "" }
)}
+31 -185
View File
@@ -1,8 +1,7 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } 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";
@@ -18,163 +17,40 @@ 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._sortedLabels;
const visible = labels.slice(0, this._visibleCount);
const hidden = labels.length - this._visibleCount;
const labels = this.labels.sort((a, b) => stringCompare(a.name, b.name));
return html`
<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>
<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>
`;
}
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
@@ -217,43 +93,13 @@ 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 {
display: flex;
position: fixed;
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,7 +24,6 @@ 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]>;
@@ -99,8 +98,6 @@ export class HaDateRangePicker extends LitElement {
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-textarea") private _textareaElement?: HaTextArea;
private _narrow = false;
private _unsubscribeTinyKeys?: () => void;
@@ -338,8 +335,9 @@ export class HaDateRangePicker extends LitElement {
};
private _setTextareaFocusStyle(focused: boolean) {
if (this._textareaElement) {
this._textareaElement.setFocused(focused);
const textarea = this.renderRoot.querySelector("ha-textarea");
if (textarea) {
textarea.setFocused(focused);
}
}
@@ -1,12 +1,10 @@
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 {
@@ -14,7 +12,7 @@ import {
sortDeviceAutomations,
} from "../../data/device/device_automation";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerValueRenderer } from "../ha-picker-field";
@@ -48,14 +46,13 @@ export abstract class HaDeviceAutomationPicker<
}
private _localizeDeviceAutomation: (
localize: LocalizeFunc,
states: HassEntities,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[],
automation: T
) => string;
private _fetchDeviceAutomations: (
callWS: CallWS,
hass: HomeAssistant,
deviceId: string
) => Promise<T[]>;
@@ -130,8 +127,7 @@ export abstract class HaDeviceAutomationPicker<
const automationListItems = automations.map((automation, idx) => {
const primary = this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this.hass,
this._entityReg,
automation
);
@@ -166,12 +162,7 @@ export abstract class HaDeviceAutomationPicker<
);
const text = automation
? this._localizeDeviceAutomation(
this.hass.localize,
this.hass.states,
this._entityReg,
automation
)
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
: value === NO_AUTOMATION_KEY
? this.NO_AUTOMATION_TEXT
: value;
@@ -181,9 +172,9 @@ export abstract class HaDeviceAutomationPicker<
private async _updateDeviceInfo() {
this._automations = this.deviceId
? (
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
).sort(sortDeviceAutomations)
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
sortDeviceAutomations
)
: // No device, clear the list of automations
[];
+9 -14
View File
@@ -6,7 +6,11 @@ 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 } from "../../data/entity/entity";
import {
UNAVAILABLE,
UNKNOWN,
isUnavailableState,
} from "../../data/entity/entity";
import { forwardHaptic } from "../../data/haptics";
import type { HomeAssistant } from "../../types";
import "../ha-formfield";
@@ -16,16 +20,7 @@ import "../ha-switch";
const isOn = (stateObj?: HassEntity) =>
stateObj !== undefined &&
!STATES_OFF.includes(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`.
*/
!isUnavailableState(stateObj.state);
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
@@ -170,9 +165,9 @@ export class HaEntityToggle extends LitElement {
white-space: nowrap;
}
ha-switch {
--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-switch-width: 38px;
--ha-switch-size: 20px;
--ha-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 { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import { timerTimeRemaining } from "../../data/timer";
import type { HomeAssistant } from "../../types";
@@ -130,6 +130,7 @@ export class HaStateLabelBadge extends LitElement {
? html`<ha-state-icon
.icon=${this.icon}
.stateObj=${entityState}
.hass=${this.hass}
></ha-state-icon>`
: ""}
${value && !image && !showIcon
@@ -170,8 +171,7 @@ export class HaStateLabelBadge extends LitElement {
}
// eslint-disable-next-line: disable=no-fallthrough
default:
return entityState.state === UNAVAILABLE ||
entityState.state === UNKNOWN
return isUnavailableState(entityState.state)
? "—"
: 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 (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
if (isUnavailableState(entityState.state)) {
return this.hass!.localize(`state_badge.default.${entityState.state}`);
}
const domainStateKey = getTruncatedKey(domain, entityState.state);
+4 -17
View File
@@ -142,7 +142,6 @@ 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 = () =>
@@ -211,10 +210,7 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const isRTL = computeRTL(hass);
const output: StatisticComboBoxItem[] = [];
@@ -318,7 +314,7 @@ export class HaStatisticPicker extends LitElement {
}
);
private _renderValue(value: string) {
private _valueRenderer: PickerValueRenderer = (value) => {
const statisticId = value;
const item = this._computeItem(statisticId);
@@ -342,13 +338,7 @@ 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];
@@ -363,10 +353,7 @@ export class HaStatisticPicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
const primary = entityName || deviceName || statisticId;
const secondary = [areaName, entityName ? deviceName : undefined]
+1
View File
@@ -98,6 +98,7 @@ 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)}
+5
View File
@@ -4,6 +4,7 @@ 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";
@@ -81,6 +82,8 @@ 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;
@@ -199,6 +202,7 @@ export class HaAdaptiveDialog extends LitElement {
.ariaLabelledBy=${this._defaultAriaLabelledBy}
.ariaDescribedBy=${this.ariaDescribedBy}
.flexContent=${this.flexContent}
.hass=${this.hass}
.open=${this.open}
.preventScrimClose=${this.preventScrimClose}
>
@@ -217,6 +221,7 @@ export class HaAdaptiveDialog extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this.open}
.type=${this.type}
.width=${this.width}
+3 -5
View File
@@ -1,6 +1,6 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { css, html, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, 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,8 +25,6 @@ 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") ||
@@ -190,7 +188,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
}
private _handlePopoverPointerDown(ev: PointerEvent) {
const popover = this._popoverElement;
const popover = this.renderRoot.querySelector("wa-popover");
const dialog = popover?.shadowRoot?.querySelector(
"dialog"
) as HTMLDialogElement | null;
@@ -217,7 +215,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
}
private _pulsePopover() {
const popover = this._popoverElement;
const popover = this.renderRoot.querySelector("wa-popover");
const popup = popover?.shadowRoot?.querySelector("wa-popup") as {
popup?: HTMLElement;
} | null;
+3 -6
View File
@@ -6,9 +6,8 @@ import {
mdiInformationOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
@@ -40,9 +39,7 @@ class HaAlert extends LitElement {
@property({ type: Boolean }) public dismissable = false;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
@@ -71,7 +68,7 @@ class HaAlert extends LitElement {
${this.dismissable
? html`<ha-icon-button
@click=${this._dismissClicked}
.label=${this._localize?.("ui.common.dismiss_alert")}
.label=${this.localize!("ui.common.dismiss_alert")}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
+12 -10
View File
@@ -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 type { HaSwitch } from "./ha-switch";
import "./ha-tooltip";
import "./item/ha-row-item";
import type { HaSwitch } from "./ha-switch";
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-row-item>
<ha-md-list-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-row-item>
</ha-md-list-item>
${ADDITIONAL_PREFERENCES.map(
(preference) => html`
<ha-row-item>
<ha-md-list-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-row-item>
</ha-md-list-item>
`
)}
<ha-row-item>
<ha-md-list-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-row-item>
</ha-md-list-item>
`;
}
@@ -139,8 +139,10 @@ export class HaAnalytics extends LitElement {
color: var(--error-color);
}
ha-row-item {
--ha-row-item-padding-inline: 0;
ha-md-list-item {
--md-list-item-leading-space: 0;
--md-list-item-trailing-space: 0;
--md-item-overflow: visible;
}
`,
];
+1 -4
View File
@@ -9,7 +9,6 @@ import {
customElement,
property,
query,
queryAll,
state as litState,
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -32,8 +31,6 @@ export class HaAnsiToHtml extends LitElement {
@query("pre") private _pre?: HTMLPreElement;
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
@litState() private _filter = "";
protected render(): TemplateResult {
@@ -323,7 +320,7 @@ export class HaAnsiToHtml extends LitElement {
*/
filterLines(filter: string): boolean {
this._filter = filter;
const lines = this._divs;
const lines = this.shadowRoot?.querySelectorAll("div") || [];
let numberOfFoundLines = 0;
if (!filter) {
lines.forEach((line) => {
+5 -7
View File
@@ -29,7 +29,7 @@ export interface AreaControlPickerItem extends PickerComboBoxItem {
deviceClass?: string;
}
const AREA_CONTROL_DOMAINS = [
const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
"light",
"fan",
"switch",
@@ -43,7 +43,7 @@ const AREA_CONTROL_DOMAINS = [
"cover-door",
"cover-window",
"cover-damper",
] as const satisfies readonly AreaControlDomain[];
] as const;
@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,
AREA_CONTROL_DOMAINS as unknown as AreaControlDomain[],
areaId,
excludeEntities,
this.hass
@@ -184,10 +184,7 @@ export class HaAreaControlsPicker extends LitElement {
const allEntityIds = Object.values(controlEntities).flat();
const uniqueEntityIds = Array.from(new Set(allEntityIds));
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
uniqueEntityIds.forEach((entityId) => {
if (isSelected(entityId)) {
@@ -264,6 +261,7 @@ 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
@@ -61,6 +61,7 @@ export class HaAreasDisplayEditor extends LitElement {
>
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._areaDisplayChanged}
@@ -107,6 +107,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
></ha-svg-icon>
`}
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedAreasItems[floor.floor_id]}
.value=${value}
.floorId=${floor.floor_id}
+4 -5
View File
@@ -1,6 +1,6 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -24,12 +24,11 @@ class HaBluePrintPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
@query("ha-select") private _select?: HTMLElement;
public open() {
if (this._select) {
const select = this.shadowRoot?.querySelector("ha-select");
if (select) {
// @ts-expect-error
this._select.menuOpen = true;
select.menuOpen = true;
}
}
+22 -28
View File
@@ -1,15 +1,13 @@
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 { isIosApp } from "../util/is_ios";
import type { HomeAssistant } from "../types";
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
@@ -49,6 +47,8 @@ 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,16 +67,10 @@ 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;
}
@@ -95,25 +89,25 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
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();
// 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();
});
};
-1
View File
@@ -20,7 +20,6 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+4 -9
View File
@@ -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 { OFF, UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
import { isUnavailableState, OFF } from "../data/entity/entity";
import type { HomeAssistant } from "../types";
@customElement("ha-climate-state")
@@ -14,11 +14,9 @@ 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">
${!noValue
${!isUnavailableState(this.stateObj.state)
? html`<span class="state-label">
${this._localizeState()}
${this.stateObj.attributes.preset_mode &&
@@ -34,7 +32,7 @@ class HaClimateState extends LitElement {
: this._localizeState()}
</div>
${currentStatus && !noValue
${currentStatus && !isUnavailableState(this.stateObj.state)
? html`
<div class="current">
${this.hass.localize("ui.card.climate.currently")}:
@@ -121,10 +119,7 @@ class HaClimateState extends LitElement {
}
private _localizeState(): string {
if (
this.stateObj.state === UNAVAILABLE ||
this.stateObj.state === UNKNOWN
) {
if (isUnavailableState(this.stateObj.state)) {
return this.hass.localize(`state.default.${this.stateObj.state}`);
}
+66 -98
View File
@@ -27,7 +27,6 @@ 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";
@@ -44,14 +43,7 @@ import type {
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
import {
internationalizationContext,
registriesContext,
statesContext,
labelsContext,
configContext,
formattersContext,
} from "../data/context";
import { labelsContext } 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";
@@ -86,6 +78,8 @@ 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;
@@ -129,29 +123,9 @@ export class HaCodeEditor extends ReactiveElement {
@state() private _canCopy = false;
@state()
@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>;
private _labels?: LabelRegistryEntry[];
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
private _loadedCodeMirror?: typeof import("../resources/codemirror");
@@ -188,7 +162,6 @@ 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}`);
}
@@ -216,9 +189,9 @@ export class HaCodeEditor extends ReactiveElement {
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this._i18n?.localize("ui.components.yaml-editor.error") ||
this.hass?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this._i18n?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
}${err.mark ? ` (${this.hass?.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(
@@ -423,8 +396,8 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.haJinjaHoverSource(
view,
pos,
this._config ? documentationUrl(this._config, "") : undefined,
this._hassArgHoverContext()
this.hass ? documentationUrl(this.hass, "") : undefined,
this.hass ? this._hassArgHoverContext() : undefined
),
{ hoverTime: 300 }
),
@@ -435,7 +408,7 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.autocompleteEntities) {
if (this.autocompleteEntities && this.hass) {
completionSources.push(this._entityCompletions.bind(this));
}
if (this.autocompleteIcons) {
@@ -474,12 +447,12 @@ export class HaCodeEditor extends ReactiveElement {
private _fullscreenLabel(): string {
if (this._isFullscreen) {
return (
this._i18n?.localize("ui.components.yaml-editor.exit_fullscreen") ||
this.hass?.localize("ui.components.yaml-editor.exit_fullscreen") ||
"Exit fullscreen"
);
}
return (
this._i18n?.localize("ui.components.yaml-editor.enter_fullscreen") ||
this.hass?.localize("ui.components.yaml-editor.enter_fullscreen") ||
"Enter fullscreen"
);
}
@@ -534,7 +507,7 @@ export class HaCodeEditor extends ReactiveElement {
{
id: "test",
label:
this._i18n?.localize(
this.hass?.localize(
`ui.components.yaml-editor.test_${this.testing ? "off" : "on"}`
) || "Test",
path: this.testing ? mdiBugOutline : mdiBug,
@@ -545,14 +518,14 @@ export class HaCodeEditor extends ReactiveElement {
{
id: "undo",
disabled: !this._canUndo,
label: this._i18n?.localize("ui.common.undo") || "Undo",
label: this.hass?.localize("ui.common.undo") || "Undo",
path: mdiUndo,
action: (e: Event) => this._handleUndoClick(e),
},
{
id: "redo",
disabled: !this._canRedo,
label: this._i18n?.localize("ui.common.redo") || "Redo",
label: this.hass?.localize("ui.common.redo") || "Redo",
path: mdiRedo,
action: (e: Event) => this._handleRedoClick(e),
},
@@ -560,7 +533,7 @@ export class HaCodeEditor extends ReactiveElement {
id: "copy",
disabled: !this._canCopy,
label:
this._i18n?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
this.hass?.localize("ui.components.yaml-editor.copy_to_clipboard") ||
"Copy to Clipboard",
path: mdiContentCopy,
action: (e: Event) => this._handleClipboardClick(e),
@@ -568,7 +541,7 @@ export class HaCodeEditor extends ReactiveElement {
{
id: "find-replace",
label:
this._i18n?.localize("ui.components.yaml-editor.find_and_replace") ||
this.hass?.localize("ui.components.yaml-editor.find_and_replace") ||
"Find and replace",
path: mdiFindReplace,
action: (e: Event) => this._handleFindReplaceClick(e),
@@ -610,7 +583,7 @@ export class HaCodeEditor extends ReactiveElement {
await copyToClipboard(this.value);
showToast(this, {
message:
this._i18n?.localize("ui.common.copied_clipboard") ||
this.hass?.localize("ui.common.copied_clipboard") ||
"Copied to clipboard",
});
}
@@ -678,11 +651,12 @@ export class HaCodeEditor extends ReactiveElement {
};
/**
* Builds a HassArgHoverContext from the context objects so that
* Builds a HassArgHoverContext from the current hass object 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 }
@@ -694,33 +668,27 @@ export class HaCodeEditor extends ReactiveElement {
};
}
return {
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"],
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"],
labels: labelMap,
formatEntityState: (entityId) =>
this._formatters!.formatEntityState(this._states![entityId]),
hass.formatEntityState(hass.states[entityId]),
formatEntityName: (entityId) => {
const stateObj = this._states?.[entityId];
const stateObj = hass.states[entityId];
return (
(stateObj?.attributes.friendly_name as string | undefined) ??
this._registries?.entities?.[entityId]?.name ??
hass.entities[entityId]?.name ??
undefined
);
},
formatAttributeName: (entityId, attribute) =>
this._formatters!.formatEntityAttributeName(
this._states![entityId],
attribute
),
hass.formatEntityAttributeName(hass.states[entityId], attribute),
formatAttributeValue: (entityId, attribute) =>
this._formatters!.formatEntityAttributeValue(
this._states![entityId],
attribute
),
localize: (key) => this._i18n!.localize(key as never),
hass.formatEntityAttributeValue(hass.states[entityId], attribute),
localize: (key) => hass.localize(key as never),
};
}
@@ -730,51 +698,49 @@ export class HaCodeEditor extends ReactiveElement {
? completion.apply
: completion.label;
const context = getEntityContext(
this._states![key],
this._registries!.entities,
this._registries!.devices,
this._registries!.areas,
this._registries!.floors
this.hass!.states[key],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const completionInfo = document.createElement("div");
completionInfo.classList.add("completion-info");
const formattedState = this._formatters!.formatEntityState(
this._states![key]
);
const formattedState = this.hass!.formatEntityState(this.hass!.states[key]);
const completionItems: CompletionItem[] = [
{
label: this._i18n!.localize(
label: this.hass!.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._states![key].state === formattedState
this.hass!.states[key].state === formattedState
? undefined
: this._states![key].state,
: this.hass!.states[key].state,
},
];
if (context.device && context.device.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.device-picker.device"),
label: this.hass!.localize("ui.components.device-picker.device"),
value: context.device.name,
});
}
if (context.area && context.area.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.area-picker.area"),
label: this.hass!.localize("ui.components.area-picker.area"),
value: context.area.name,
});
}
if (context.floor && context.floor.name) {
completionItems.push({
label: this._i18n!.localize("ui.components.floor-picker.floor"),
label: this.hass!.localize("ui.components.floor-picker.floor"),
value: context.floor.name,
});
}
@@ -795,15 +761,15 @@ export class HaCodeEditor extends ReactiveElement {
entityId: string,
attribute: string
): CompletionInfo | null => {
if (!this._states || !this._formatters) return null;
const stateObj = this._states[entityId];
if (!this.hass) return null;
const stateObj = this.hass.states[entityId];
if (!stateObj) return null;
const translatedName = this._formatters.formatEntityAttributeName(
const translatedName = this.hass.formatEntityAttributeName(
stateObj,
attribute
);
const formattedValue = this._formatters.formatEntityAttributeValue(
const formattedValue = this.hass.formatEntityAttributeValue(
stateObj,
attribute
);
@@ -843,9 +809,9 @@ export class HaCodeEditor extends ReactiveElement {
completion: Completion
): CompletionInfo | Promise<CompletionInfo> | null => {
if (
this._states &&
this.hass &&
typeof completion.apply === "string" &&
completion.apply in this._states
completion.apply in this.hass.states
) {
return this._renderInfo(completion);
}
@@ -1054,7 +1020,7 @@ export class HaCodeEditor extends ReactiveElement {
private _statesDotNotationCompletions(
context: CompletionContext
): CompletionResult | null | undefined {
if (!this._states) return undefined;
if (!this.hass) return undefined;
const { state: editorState, pos } = context;
const tree = this._loadedCodeMirror!.syntaxTree(editorState);
@@ -1163,7 +1129,9 @@ export class HaCodeEditor extends ReactiveElement {
case 0: {
// states. → offer all unique domains
const domains = [
...new Set(Object.keys(this._states).map((id) => id.split(".")[0])),
...new Set(
Object.keys(this.hass.states).map((id) => id.split(".")[0])
),
].sort();
return {
from: completionFrom,
@@ -1174,7 +1142,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._states)
const entities = Object.keys(this.hass.states)
.filter((id) => id.startsWith(`${domain}.`))
.map((id) => id.split(".").slice(1).join("."));
if (!entities.length) return { from: completionFrom, options: [] };
@@ -1204,7 +1172,7 @@ export class HaCodeEditor extends ReactiveElement {
}
// Offer attribute names from the entity's state object
const entityId = `${domain}.${entity}`;
const entityState = this._states[entityId];
const entityState = this.hass.states[entityId];
if (!entityState) return { from: completionFrom, options: [] };
const attrNames = Object.keys(entityState.attributes).sort();
return {
@@ -1374,8 +1342,8 @@ export class HaCodeEditor extends ReactiveElement {
): CompletionResult {
const from = stringNode.from + 1;
const empty: CompletionResult = { from, options: [] };
if (!entityId || !this._states) return empty;
const entityState = this._states[entityId];
if (!entityId || !this.hass) return empty;
const entityState = this.hass.states[entityId];
if (!entityState) return empty;
const attrs = Object.keys(entityState.attributes).sort();
if (!attrs.length) return empty;
@@ -1395,7 +1363,7 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
const states = this._getStates(this._states!);
const states = this._getStates(this.hass!.states);
if (!states?.length) return null;
// from is stringNode.from + 1 to skip the opening quote character.
const from = stringNode.from + 1;
@@ -1429,8 +1397,8 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
if (!this._registries?.devices) return null;
const devices = this._getDevices(this._registries.devices);
if (!this.hass?.devices) return null;
const devices = this._getDevices(this.hass.devices);
if (!devices.length) return null;
return {
from: stringNode.from + 1,
@@ -1458,8 +1426,8 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
if (!this._registries?.areas) return null;
const areas = this._getAreas(this._registries.areas);
if (!this.hass?.areas) return null;
const areas = this._getAreas(this.hass.areas);
if (!areas.length) return null;
return {
from: stringNode.from + 1,
@@ -1487,8 +1455,8 @@ export class HaCodeEditor extends ReactiveElement {
from: number;
to: number;
}): CompletionResult | null {
if (!this._registries?.floors) return null;
const floors = this._getFloors(this._registries.floors);
if (!this.hass?.floors) return null;
const floors = this._getFloors(this.hass.floors);
if (!floors.length) return null;
return {
from: stringNode.from + 1,
@@ -1588,7 +1556,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._states!);
const states = this._getStates(this.hass!.states);
if (!states || !states.length) {
return null;
@@ -1643,7 +1611,7 @@ export class HaCodeEditor extends ReactiveElement {
const afterListMarker = currentLine.from + listItemMatch[0].length;
if (context.pos >= afterListMarker) {
const states = this._getStates(this._states!);
const states = this._getStates(this.hass!.states);
if (!states || !states.length) {
return null;
@@ -1703,7 +1671,7 @@ export class HaCodeEditor extends ReactiveElement {
return null;
}
const states = this._getStates(this._states!);
const states = this._getStates(this.hass!.states);
if (!states || !states.length) {
return null;
-1
View File
@@ -54,7 +54,6 @@ 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;
+21 -23
View File
@@ -15,10 +15,9 @@ 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 { configContext, internationalizationContext } from "../data/context";
import { 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";
@@ -128,9 +127,10 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: configContext, subscribe: true })
// private _hassConfig?: ContextType<typeof configContext>;
@state()
private _bodyScrolled = false;
@@ -221,24 +221,22 @@ export class HaDialog extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
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;
}
// 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;
// }
(this.querySelector("[autofocus]") as HTMLElement | null)?.focus();
});
};
+125 -266
View File
@@ -1,115 +1,36 @@
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 { 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 { 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 LitElement {
private static readonly _SWIPE_AXIS_TOLERANCE = 32;
export class HaDrawer extends DrawerBase {
@property() public direction: "ltr" | "rtl" = "ltr";
@property({ reflect: true }) public direction: "ltr" | "rtl" = "ltr";
private _mc?: HammerManager;
@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 _rtlStyle?: 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 !==
(this.type === "dismissible" ? "transform" : "width") ||
this._sidebarTransitionActive
) {
if (ev.propertyName !== "width" || this._sidebarTransitionActive) {
return;
}
this._sidebarTransitionActive = true;
@@ -120,11 +41,7 @@ export class HaDrawer extends LitElement {
};
private _handleDrawerTransitionEnd = (ev: TransitionEvent) => {
if (
ev.propertyName !==
(this.type === "dismissible" ? "transform" : "width") ||
!this._sidebarTransitionActive
) {
if (ev.propertyName !== "width" || !this._sidebarTransitionActive) {
return;
}
this._sidebarTransitionActive = false;
@@ -134,208 +51,150 @@ export class HaDrawer extends LitElement {
});
};
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 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 _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);
};
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 _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();
this.shadowRoot!.appendChild(this._rtlStyle);
} else if (this._rtlStyle) {
this.shadowRoot!.removeChild(this._rtlStyle);
}
return;
}
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();
if (changedProps.has("open") && this.open && this.type === "modal") {
this._setupSwipe();
} else if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
};
private _unregisterSwipeHandlers() {
document.removeEventListener("touchmove", this._handleTouchMove);
document.removeEventListener("touchend", this._handleTouchEnd);
document.removeEventListener("touchcancel", this._handleTouchEnd);
}
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(
protected firstUpdated() {
super.firstUpdated();
this.mdcRoot?.addEventListener(
"transitionstart",
this._handleDrawerTransitionStart
);
this._transitionTarget.addEventListener(
this.mdcRoot?.addEventListener(
"transitionend",
this._handleDrawerTransitionEnd
);
this._transitionTarget.addEventListener(
this.mdcRoot?.addEventListener(
"transitioncancel",
this._handleDrawerTransitionEnd
);
}
private _removeTransitionListeners() {
if (!this._transitionTarget) {
return;
}
this._transitionTarget.removeEventListener(
public disconnectedCallback() {
super.disconnectedCallback();
this.mdcRoot?.removeEventListener(
"transitionstart",
this._handleDrawerTransitionStart
);
this._transitionTarget.removeEventListener(
this.mdcRoot?.removeEventListener(
"transitionend",
this._handleDrawerTransitionEnd
);
this._transitionTarget.removeEventListener(
this.mdcRoot?.removeEventListener(
"transitioncancel",
this._handleDrawerTransitionEnd
);
this._transitionTarget = undefined;
}
static styles = css`
:host {
display: block;
height: 100%;
}
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 });
});
}
.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;
z-index: 6;
}
.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;
}
`;
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;
}
}
`,
];
}
declare global {
+4 -11
View File
@@ -6,18 +6,11 @@ import type { HaIconButton } from "./ha-icon-button";
/**
* Event type for the ha-dropdown component when an item is selected.
* @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`.
* @param T - The type of the value of the selected item.
*/
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 };
}>;
export type HaDropdownSelectEvent<T = string> = CustomEvent<{
item: Omit<HaDropdownItem, "value"> & { value: T };
}>;
/**
* Home Assistant dropdown component
+2 -6
View File
@@ -39,12 +39,7 @@ export class HaEntitiesDisplayEditor extends LitElement {
const items: DisplayItem[] = entities.map((entity) => ({
value: entity.entity_id,
label: computeStateName(entity),
icon: entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
entity
),
icon: entityIcon(this.hass, entity),
}));
const value: DisplayValue = {
@@ -54,6 +49,7 @@ export class HaEntitiesDisplayEditor extends LitElement {
return html`
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._itemDisplayChanged}
+3 -4
View File
@@ -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, query, state } from "lit/decorators";
import { customElement, property, 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,8 +32,6 @@ export class HaFilterBlueprints extends LitElement {
@state() private _blueprints?: Blueprints;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -98,7 +96,8 @@ export class HaFilterBlueprints extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
+3 -4
View File
@@ -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, query, state } from "lit/decorators";
import { customElement, property, 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,8 +49,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@state() private _shouldRender = false;
@query("ha-list") private _list?: HTMLElement;
protected hassSubscribeRequiredHostProps = ["scope"];
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@@ -171,7 +169,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
this.renderRoot.querySelector("ha-list")!.style.height =
`${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
+3 -4
View File
@@ -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, query, state } from "lit/decorators";
import { customElement, property, 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,8 +34,6 @@ export class HaFilterDevices extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -137,7 +135,8 @@ export class HaFilterDevices extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
this.renderRoot.querySelector("ha-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
+16 -18
View File
@@ -1,8 +1,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, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
@@ -32,8 +31,6 @@ export class HaFilterDomains extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
@@ -65,7 +62,7 @@ export class HaFilterDomains extends LitElement {
multi
>
${repeat(
this._domains(this.hass.states, this._filter, this.value),
this._domains(this.hass.states, this._filter),
(i) => i,
(domain) =>
html`<ha-check-list-item
@@ -87,7 +84,7 @@ export class HaFilterDomains extends LitElement {
`;
}
private _domains = memoizeOne((states, filter, _value) => {
private _domains = memoizeOne((states, filter) => {
const domains = new Set<string>();
Object.keys(states).forEach((entityId) => {
domains.add(computeDomain(entityId));
@@ -112,7 +109,8 @@ export class HaFilterDomains extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
this.renderRoot.querySelector("ha-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
@@ -128,19 +126,19 @@ export class HaFilterDomains extends LitElement {
this.expanded = ev.detail.expanded;
}
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];
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);
}
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
value: this.value,
items: undefined,
});
}
+8 -5
View File
@@ -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, query, state } from "lit/decorators";
import { customElement, property, 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,8 +36,6 @@ export class HaFilterEntities extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -104,7 +102,8 @@ export class HaFilterEntities extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
this.renderRoot.querySelector("ha-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
@@ -123,7 +122,11 @@ export class HaFilterEntities extends LitElement {
.selected=${this.value?.includes(entity.entity_id) ?? false}
graphic="icon"
>
<ha-state-icon slot="graphic" .stateObj=${entity}></ha-state-icon>
<ha-state-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${entity}
></ha-state-icon>
${computeStateName(entity)}
</ha-check-list-item>`;
+4 -8
View File
@@ -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, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
@@ -42,8 +42,6 @@ export class HaFilterFloorAreas extends LitElement {
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -139,10 +137,7 @@ export class HaFilterFloorAreas extends LitElement {
.selected=${this.value?.areas?.includes(area.area_id) || false}
.type=${"areas"}
class=${classMap({
rtl: computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
),
rtl: computeRTL(this.hass),
floor: hasFloor,
})}
>
@@ -209,7 +204,8 @@ export class HaFilterFloorAreas extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
`${this.clientHeight - 49}px`;
}, 300);
}
}
+16 -14
View File
@@ -1,8 +1,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, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
@@ -35,8 +34,6 @@ export class HaFilterIntegrations extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
@@ -101,7 +98,8 @@ export class HaFilterIntegrations extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
this.renderRoot.querySelector("ha-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
@@ -149,7 +147,9 @@ export class HaFilterIntegrations extends LitElement {
)
);
private _itemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
private _itemSelected(
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
) {
const integrations = this._integrations(
this.hass.localize,
this._manifests!,
@@ -157,16 +157,18 @@ export class HaFilterIntegrations extends LitElement {
this.value
);
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];
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);
}
fireEvent(this, "data-table-filter-changed", {
value: this.value.length ? this.value : undefined,
value: this.value,
items: undefined,
});
}
+3 -4
View File
@@ -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, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
@@ -41,8 +41,6 @@ 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) =>
@@ -139,7 +137,8 @@ export class HaFilterLabels extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
this.renderRoot.querySelector("ha-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
+3 -4
View File
@@ -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, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
@@ -33,8 +33,6 @@ export class HaFilterVoiceAssistants extends LitElement {
@state() private _shouldRender = false;
@query("ha-list") private _list?: HTMLElement;
protected render() {
return html`
<ha-expansion-panel
@@ -95,7 +93,8 @@ export class HaFilterVoiceAssistants extends LitElement {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
this.renderRoot.querySelector("ha-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, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { stopPropagation } from "../../common/dom/stop_propagation";
import type { LocalizeFunc } from "../../common/translations/localize";
@@ -49,15 +49,14 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
@state() private _displayActions?: string[];
@query("ha-form") private _form?: HaForm;
public async focus() {
await this.updateComplete;
this._form?.focus();
this.renderRoot.querySelector("ha-form")?.focus();
}
public reportValidity(): boolean {
return this._form ? this._form.reportValidity() : true;
const form = this.renderRoot.querySelector<HaForm>("ha-form");
return form ? form.reportValidity() : true;
}
protected updated(changedProps: PropertyValues<this>): void {
+2 -4
View File
@@ -1,6 +1,6 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant } from "../../types";
@@ -83,10 +83,8 @@ export class HaForm extends LitElement implements HaFormElement {
delegatesFocus: true,
};
@query(".root") private _root?: HTMLElement;
public reportValidity(): boolean {
const root = this._root;
const root = this.renderRoot.querySelector(".root");
if (!root) {
return true;
}
+14 -21
View File
@@ -1,6 +1,5 @@
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,
@@ -14,10 +13,8 @@ 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";
@@ -109,15 +106,14 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
@state() private _opened = false;
@@ -273,7 +269,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
></ha-picker-combo-box>
`;
}
@@ -324,18 +319,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
this._hassConfig.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",
},
});
return;
}
// 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;
// }
this._comboBox?.focus();
});
+41 -59
View File
@@ -1,77 +1,59 @@
import { css, html, LitElement } from "lit";
// @ts-ignore
import topAppBarStyles from "@material/top-app-bar/dist/mdc.top-app-bar.min.css";
import { css, html, LitElement, unsafeCSS } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-header-bar")
export class HaHeaderBar extends LitElement {
protected render() {
return html`<header class="header-bar">
<div class="row">
<section class="section" id="navigation">
return html`<header class="mdc-top-app-bar">
<div class="mdc-top-app-bar__row">
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-start"
id="navigation"
>
<slot name="navigationIcon"></slot>
<span class="title">
<span class="mdc-top-app-bar__title">
<slot name="title"></slot>
</span>
</section>
<section class="section end" id="actions" role="toolbar">
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-end"
id="actions"
role="toolbar"
>
<slot name="actionItems"></slot>
</section>
</div>
</header>`;
}
static override styles = css`
:host {
display: block;
}
.header-bar {
box-sizing: border-box;
color: var(--app-header-text-color, var(--primary-text-color));
background-color: var(
--app-header-background-color,
var(--primary-background-color)
);
padding: var(--header-bar-padding);
}
.row {
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
height: var(--header-height);
}
.section {
display: flex;
align-items: center;
box-sizing: border-box;
min-width: 0;
height: 100%;
padding: 0 var(--ha-space-3);
}
#navigation {
flex: 1 1 auto;
}
.section.end {
flex: none;
justify-content: flex-end;
}
.title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--header-height);
padding-inline-start: var(--ha-space-6);
}
`;
static get styles() {
return [
unsafeCSS(topAppBarStyles),
css`
.mdc-top-app-bar__row {
height: var(--header-height);
}
.mdc-top-app-bar {
position: static;
color: var(--mdc-theme-on-primary, #fff);
padding: var(--header-bar-padding);
}
.mdc-top-app-bar__section.mdc-top-app-bar__section--align-start {
flex: 1;
}
.mdc-top-app-bar__section.mdc-top-app-bar__section--align-end {
flex: none;
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: 24px;
padding-inline-end: initial;
}
`,
];
}
}
declare global {
+4 -9
View File
@@ -1,7 +1,7 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { OFF, UNAVAILABLE, UNKNOWN } from "../data/entity/entity";
import { isUnavailableState, OFF } from "../data/entity/entity";
import type { HumidifierEntity } from "../data/humidifier";
import type { HomeAssistant } from "../types";
@@ -13,11 +13,9 @@ 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">
${!noValue
${!isUnavailableState(this.stateObj.state)
? html`<span class="state-label">
${this._localizeState()}
${this.stateObj.attributes.mode
@@ -32,7 +30,7 @@ class HaHumidifierState extends LitElement {
: this._localizeState()}
</div>
${currentStatus && !noValue
${currentStatus && !isUnavailableState(this.stateObj.state)
? html`<div class="current">
${this.hass.localize("ui.card.humidifier.currently")}:
<div class="unit">${currentStatus}</div>
@@ -71,10 +69,7 @@ class HaHumidifierState extends LitElement {
}
private _localizeState(): string {
if (
this.stateObj.state === UNAVAILABLE ||
this.stateObj.state === UNKNOWN
) {
if (isUnavailableState(this.stateObj.state)) {
return this.hass.localize(`state.default.${this.stateObj.state}`);
}
+4 -7
View File
@@ -2,11 +2,10 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiDotsVertical } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { customElement, property } from "lit/decorators";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-icon-button";
@@ -27,9 +26,7 @@ export interface IconOverflowMenuItem {
@customElement("ha-icon-overflow-menu")
export class HaIconOverflowMenu extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Array }) public items: IconOverflowMenuItem[] = [];
@@ -47,7 +44,7 @@ export class HaIconOverflowMenu extends LitElement {
@click=${stopPropagation}
>
<ha-icon-button
.label=${this._localize("ui.common.overflow_menu")}
.label=${this.hass.localize("ui.common.overflow_menu")}
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
+1 -1
View File
@@ -14,7 +14,7 @@ class InputHelperText extends LitElement {
:host {
display: block;
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
font-size: var(--ha-font-size-s);
font-size: 0.75rem;
padding-left: 16px;
padding-right: 16px;
padding-inline-start: 16px;
+3 -7
View File
@@ -8,11 +8,10 @@ import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { orderCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-icon-next";
@@ -47,9 +46,7 @@ declare global {
@customElement("ha-items-display-editor")
export class HaItemDisplayEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public items: DisplayItem[] = [];
@@ -164,7 +161,7 @@ export class HaItemDisplayEditor extends LitElement {
? html`<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
.label=${this._localize(
.label=${this.hass.localize(
`ui.components.items-display-editor.${isVisible ? "hide" : "show"}`,
{
label: label,
@@ -317,7 +314,6 @@ 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;
+1 -3
View File
@@ -167,8 +167,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public clearable = false;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -344,7 +342,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _getItems = () => {
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
if (!this.sections?.length && !this.noSort) {
if (!this.sections?.length) {
items = items.sort((entityA, entityB) => {
const sortLabelA =
typeof entityA === "string" ? entityA : entityA.sorting_label;
+5
View File
@@ -166,6 +166,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${entity}
slot="graphic"
></ha-state-icon>
@@ -321,6 +322,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${group}
slot="graphic"
></ha-state-icon>
@@ -345,6 +347,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${scene}
slot="graphic"
></ha-state-icon>
@@ -397,6 +400,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${automation}
slot="graphic"
></ha-state-icon>
@@ -448,6 +452,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${script}
slot="graphic"
></ha-state-icon>
+6 -18
View File
@@ -1,12 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import { internationalizationContext, uiContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
@@ -27,6 +26,8 @@ export interface SelectBoxOption {
@customElement("ha-select-box")
export class HaSelectBox extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public options: SelectBoxOption[] = [];
@property({ attribute: false }) public value?: string;
@@ -39,14 +40,6 @@ export class HaSelectBox extends LitElement {
@property({ type: Boolean, attribute: "stacked_image" })
public stackedImage = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
protected _ui?: ContextType<typeof uiContext>;
render() {
const maxColumns = this.maxColumns ?? 3;
const columns = Math.min(maxColumns, this.options.length);
@@ -69,13 +62,8 @@ export class HaSelectBox extends LitElement {
const disabled = option.disabled || this.disabled || false;
const selected = option.value === this.value;
const isDark = this._ui?.themes.darkMode || false;
const isRTL = this._i18n
? computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
)
: false;
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass ? computeRTL(this.hass) : false;
const imageSrc =
typeof option.image === "object"

Some files were not shown because too many files have changed in this diff Show More