Compare commits

..

23 Commits

Author SHA1 Message Date
Bram Kragten d3c68973a0 test(e2e): handle skip return from waitForLocator in all sidebar wait calls 2026-05-13 11:10:19 +02:00
Bram Kragten 141dce9773 test(e2e): skip on both dynamic-import errors and BrowserStack iOS Internal error 2026-05-13 11:05:22 +02:00
Bram Kragten d355d9e089 test(e2e): revert to locator.waitFor with BS iOS Internal error catch-and-skip 2026-05-13 11:00:45 +02:00
Bram Kragten ce5a135bd2 test(e2e): fix waitForShadow helper to use recursive shadow DOM traversal 2026-05-13 10:52:39 +02:00
Bram Kragten 8905123249 test(e2e): replace waitForSelector with evaluate polling in sidebar test for iOS 2026-05-13 10:47:53 +02:00
Bram Kragten 92e6f69fc7 test(e2e): use evaluate for card visibility check on iOS where locator can't pierce shadow DOM 2026-05-13 10:43:44 +02:00
Bram Kragten 3fbc11458a test(e2e): fix evaluate argument serialization on BrowserStack iOS 2026-05-13 10:39:27 +02:00
Bram Kragten 8b419646d1 test(e2e): use recursive shadow DOM evaluate poll instead of waitForSelector on iOS 2026-05-13 10:35:36 +02:00
Bram Kragten 5ca28bdc85 test(e2e): skip cards/dialog checks when dynamic imports fail over tunnel 2026-05-13 10:29:53 +02:00
Bram Kragten efe41b1e1a test(e2e): replace class-wildcard selector with explicit hui-* tag selectors for iPhone 2026-05-13 10:23:56 +02:00
Bram Kragten d16aa48f65 test(e2e): fix WebKit dynamic-import filter and avoid concurrent waitFor on mobile 2026-05-13 10:20:12 +02:00
Bram Kragten 99c7a427f1 test(e2e): reuse pre-existing BrowserStack page; filter infra dynamic-import errors 2026-05-13 10:16:09 +02:00
Bram Kragten 0ccc582aff test(e2e): use serial mode + shared page to fix BrowserStack iPhone context limit 2026-05-13 10:11:29 +02:00
Bram Kragten e32dd78f51 Use browserstack node SDK instead 2026-05-11 01:11:54 +02:00
Bram Kragten c3358e0825 use bs-local.com for browserstack 2026-05-10 23:41:08 +02:00
Bram Kragten 764961b28e Update browserstack.capabilities.ts 2026-05-10 23:17:44 +02:00
Bram Kragten 897a33963e fix browserstack ios tests 2026-05-10 23:10:01 +02:00
Bram Kragten 6eadf2ff15 fix gallery tests 2026-05-10 23:03:22 +02:00
Bram Kragten d32f5b6a50 fix 2026-05-08 16:46:51 +02:00
Bram Kragten a21cf5d995 fix browserstack tests 2026-05-08 16:23:00 +02:00
Bram Kragten 9aa6cd4154 fixes 2026-05-08 16:12:25 +02:00
Bram Kragten 2024ce0aef Update e2e.yaml 2026-05-08 16:05:58 +02:00
Bram Kragten 908a518f18 Add e2e playwright tests 2026-05-08 15:57:08 +02:00
727 changed files with 14442 additions and 24886 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
# ️ 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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+276
View File
@@ -0,0 +1,276 @@
name: E2E Tests
on:
push:
branches:
- dev
- master
pull_request:
branches:
- dev
- master
env:
NODE_OPTIONS: --max_old_space_size=6144
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
# ── Build the demo once and share it across test jobs via artifact ──────────
build-demo:
name: Build demo
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build demo
run: ./node_modules/.bin/gulp build-demo
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload demo build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: demo-dist
path: demo/dist/
if-no-files-found: error
retention-days: 3
# ── Build the e2e test app and share it via artifact ────────────────────────
build-e2e-test-app:
name: Build e2e test app
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build e2e test app
run: ./node_modules/.bin/gulp build-e2e-test-app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload e2e test app build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
if-no-files-found: error
retention-days: 3
# ── Build the gallery and share it via artifact ─────────────────────────────
build-gallery:
name: Build gallery
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Build gallery
run: ./node_modules/.bin/gulp build-gallery
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload gallery build
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: gallery-dist
path: gallery/dist/
if-no-files-found: error
retention-days: 3
# ── Run Playwright tests locally against Chromium ──────────────────────────
e2e-local:
name: E2E (local Chromium)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (local)
run: yarn test:e2e
- name: Upload blob report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
name: blob-report-local
path: test/e2e/reports/
retention-days: 3
# ── Run Playwright tests on BrowserStack (real devices + browsers) ─────────
# The BrowserStack SDK manages the Local tunnel and uploads results to the
# BrowserStack Automate dashboard automatically — no tunnel action needed.
e2e-browserstack:
name: E2E (BrowserStack)
needs: [build-demo, build-e2e-test-app, build-gallery]
runs-on: ubuntu-latest
environment: browserstack
env:
BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }}
BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download demo build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: demo-dist
path: demo/dist/
- name: Download e2e test app build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: e2e-test-app-dist
path: test/e2e/app/dist/
- name: Download gallery build
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
with:
name: gallery-dist
path: gallery/dist/
- name: Run Playwright tests (BrowserStack)
run: yarn test:e2e:browserstack
# ── Merge local blob reports and post PR comment ───────────────────────────
report:
name: Report
needs: [e2e-local, e2e-browserstack]
runs-on: ubuntu-latest
if: always()
permissions:
contents: read
pull-requests: write
steps:
- name: Check out files from GitHub
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version-file: ".nvmrc"
cache: yarn
- name: Install dependencies
run: yarn install --immutable
- name: Download blob report (local)
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
continue-on-error: true
with:
name: blob-report-local
path: test/e2e/reports/
- name: Stage blobs for merge
run: node test/e2e/collect-blob-reports.mjs
- name: Merge blob reports
run: npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob
- name: Upload merged HTML report
id: upload-report
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: test/e2e/reports/combined/
retention-days: 14
- name: Post report link to PR
if: github.event_name == 'pull_request'
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
with:
script: |
const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
const body = `## Playwright E2E test report\n\nThe combined HTML report is available as a workflow artifact.\n\n[View workflow run](${runUrl})`;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
+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@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7
View File
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
# test coverage
test/coverage/
# Playwright e2e output
test/e2e/reports/
test/e2e/test-results/
# E2E test app build output
test/e2e/app/dist/
# AI tooling
.claude
.cursor
.opencode
.serena
+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
+53
View File
@@ -0,0 +1,53 @@
# BrowserStack Automate configuration for Home Assistant frontend e2e tests.
# Credentials are read from BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY
# environment variables set in GitHub Actions (or locally).
# See: https://www.browserstack.com/docs/automate/playwright/getting-started/nodejs
userName: ${BROWSERSTACK_USERNAME}
accessKey: ${BROWSERSTACK_ACCESS_KEY}
projectName: Home Assistant Frontend
buildName: e2e tests
buildIdentifier: "CI #${BUILD_NUMBER}"
# ── Platforms ────────────────────────────────────────────────────────────────
platforms:
- os: Windows
osVersion: 11
browserName: chrome
browserVersion: latest
- os: OS X
osVersion: Ventura
browserName: playwright-firefox
browserVersion: latest
- deviceName: iPad 6th
osVersion: 12
browserName: playwright-webkit
- deviceName: iPhone 12
osVersion: 14
browserName: playwright-webkit
- deviceName: Samsung Galaxy S23
osVersion: 13
browserName: chrome
realMobile: true
parallelsPerPlatform: 1
# ── Local tunnel ─────────────────────────────────────────────────────────────
# The SDK manages the BrowserStack Local tunnel automatically.
browserstackLocal: true
framework: playwright
# Pin to the latest Playwright version BrowserStack supports. Our local
# @playwright/test is newer (1.59.x) which BrowserStack does not yet support,
# causing a "Malformed endpoint" connection error if left unset.
# Update this when BrowserStack adds support for a newer version.
# Supported versions: https://www.browserstack.com/docs/automate/playwright/browsers-and-os
playwrightVersion: 1.latest
# ── Debugging ────────────────────────────────────────────────────────────────
debug: false
networkLogs: false
consoleLogs: errors
testObservability: true
+18 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname, process */
const path = require("path");
const env = require("./env.cjs");
const paths = require("./paths.cjs");
@@ -321,4 +320,22 @@ module.exports.config = {
isLandingPageBuild: true,
};
},
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
return {
name: "e2e-test-app" + nameSuffix(latestBuild),
entry: {
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
},
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
publicPath: publicPath(latestBuild),
defineOverlay: {
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
__DEMO__: true,
},
isProdBuild,
latestBuild,
isStatsBuild,
};
},
};
+4
View File
@@ -1,9 +1,13 @@
// @ts-check
import globals from "globals";
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs";
export default tseslint.config(...rootConfig, {
languageOptions: {
globals: globals.node,
},
rules: {
"no-console": "off",
"import-x/no-extraneous-dependencies": "off",
@@ -1,4 +1,3 @@
/* global module */
// Browser-only replacement for core-js/internals/get-built-in-node-module.
// The original helper evaluates `Function('return require("...")')()`
// when it detects a Node environment, which causes a runtime
+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"),
+7
View File
@@ -45,3 +45,10 @@ gulp.task(
])
)
);
gulp.task(
"clean-e2e-test-app",
gulp.parallel("clean-translations", async () =>
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
)
);
+41
View File
@@ -0,0 +1,41 @@
import gulp from "gulp";
import "./clean.js";
import "./entry-html.js";
import "./gather-static.js";
import "./gen-icons-json.js";
import "./translations.js";
import "./rspack.js";
gulp.task(
"develop-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "development";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel(
"gen-icons-json",
"gen-pages-e2e-test-app-dev",
"build-translations",
"build-locale-data"
),
"copy-static-e2e-test-app",
"rspack-dev-server-e2e-test-app"
)
);
gulp.task(
"build-e2e-test-app",
gulp.series(
async function setEnv() {
process.env.NODE_ENV = "production";
},
"clean-e2e-test-app",
"translations-enable-merge-backend",
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
"copy-static-e2e-test-app",
"rspack-prod-e2e-test-app",
"gen-pages-e2e-test-app-prod"
)
);
+21 -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 = () => {
@@ -268,3 +266,24 @@ gulp.task(
paths.landingPage_output_es5
)
);
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
gulp.task(
"gen-pages-e2e-test-app-dev",
genPagesDevTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root
)
);
gulp.task(
"gen-pages-e2e-test-app-prod",
genPagesProdTask(
E2E_TEST_APP_PAGE_ENTRIES,
paths.e2eTestApp_dir,
paths.e2eTestApp_output_root,
paths.e2eTestApp_output_latest
)
);
+20
View File
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
copyFonts(paths.landingPage_output_static);
copyTranslations(paths.landingPage_output_static);
});
gulp.task("copy-static-e2e-test-app", async () => {
// Copy app static files (icons, polyfills, etc.)
fs.copySync(
polyPath("public/static"),
path.resolve(paths.e2eTestApp_output_root, "static")
);
// Copy e2e test app public files (manifest, sw stubs)
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
if (fs.existsSync(e2ePublic)) {
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
}
copyPolyfills(paths.e2eTestApp_output_static);
copyMapPanel(paths.e2eTestApp_output_static);
copyFonts(paths.e2eTestApp_output_static);
copyTranslations(paths.e2eTestApp_output_static);
copyLocaleData(paths.e2eTestApp_output_static);
copyMdiIcons(paths.e2eTestApp_output_static);
});
+1
View File
@@ -4,6 +4,7 @@ import "./clean.js";
import "./compress.js";
import "./demo.js";
import "./download-translations.js";
import "./e2e-test-app.js";
import "./entry-html.js";
import "./fetch-nightly-translations.js";
import "./gallery.js";
-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 }
);
});
+20
View File
@@ -14,6 +14,7 @@ import {
createDemoConfig,
createGalleryConfig,
createLandingPageConfig,
createE2eTestAppConfig,
} from "../rspack.cjs";
const bothBuilds = (createConfigFunc, params) => [
@@ -210,3 +211,22 @@ gulp.task("rspack-prod-landing-page", () =>
})
)
);
gulp.task("rspack-dev-server-e2e-test-app", () =>
runDevServer({
compiler: rspack(
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
),
contentBase: paths.e2eTestApp_output_root,
port: 8095,
})
);
gulp.task("rspack-prod-e2e-test-app", () =>
prodBuild(
bothBuilds(createE2eTestAppConfig, {
isProdBuild: true,
isStatsBuild: env.isStatsBuild(),
})
)
);
+11
View File
@@ -50,4 +50,15 @@ module.exports = {
),
translations_src: path.resolve(__dirname, "../src/translations"),
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
e2eTestApp_output_static: path.resolve(
__dirname,
"../test/e2e/app/dist/static"
),
e2eTestApp_output_latest: path.resolve(
__dirname,
"../test/e2e/app/dist/frontend_latest"
),
};
+6 -1
View File
@@ -1,4 +1,3 @@
/* global require, module, __dirname */
const { existsSync } = require("fs");
const path = require("path");
const rspack = require("@rspack/core");
@@ -338,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
createRspackConfig(
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
);
module.exports = {
createAppConfig,
createDemoConfig,
@@ -345,4 +349,5 @@ module.exports = {
createGalleryConfig,
createRspackConfig,
createLandingPageConfig,
createE2eTestAppConfig,
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAssist = (hass: MockHomeAssistant) => {
// Stub for assist pipeline list — returns empty so developer tools assist
// tab loads without errors.
hass.mockWS("assist_pipeline/pipeline/list", () => ({
pipelines: [],
preferred_pipeline: null,
}));
// Stub for assist pipeline run — immediately sends run-end event so
// the UI does not hang waiting for a response.
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
if (onChange) {
onChange({
type: "run-end",
});
}
return null;
});
};
+21
View File
@@ -0,0 +1,21 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockCloud = (hass: MockHomeAssistant) => {
// REST mock for cloud status — returns disconnected so config panel loads
// without errors but without requiring cloud integration.
hass.mockAPI("cloud/status", () => ({
logged_in: false,
cloud: "disconnected",
prefs: {
google_enabled: false,
alexa_enabled: false,
cloudhooks: {},
remote_enabled: false,
},
google_registered: false,
alexa_registered: false,
remote_domain: null,
remote_connected: false,
remote_certificate: null,
}));
};
+18 -25
View File
@@ -1,7 +1,6 @@
import {
addDays,
addHours,
addMinutes,
addMonths,
differenceInHours,
endOfDay,
@@ -13,19 +12,6 @@ import type {
} from "../../../src/data/recorder";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
const getNextDate = (
currentDate: Date,
period: "5minute" | "hour" | "day" | "month"
): Date => {
return period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: period === "hour"
? addHours(currentDate, 1)
: addMinutes(currentDate, 5);
};
const generateMeanStatistics = (
start: Date,
end: Date,
@@ -39,10 +25,9 @@ const generateMeanStatistics = (
while (end > currentDate && currentDate < now) {
const delta = Math.random() * maxDiff;
const mean = delta;
const nextDate = getNextDate(currentDate, period);
statistics.push({
start: currentDate.getTime(),
end: nextDate.getTime(),
end: currentDate.getTime(),
mean,
min: mean - Math.random() * maxDiff,
max: mean + Math.random() * maxDiff,
@@ -50,7 +35,12 @@ const generateMeanStatistics = (
state: mean,
sum: null,
});
currentDate = nextDate;
currentDate =
period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: addHours(currentDate, 1);
}
return statistics;
};
@@ -68,12 +58,11 @@ const generateSumStatistics = (
let sum = initValue;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const add = Math.random() * maxDiff;
sum += add;
statistics.push({
start: currentDate.getTime(),
end: nextDate.getTime(),
end: currentDate.getTime(),
mean: null,
min: null,
max: null,
@@ -82,7 +71,12 @@ const generateSumStatistics = (
state: initValue + sum,
sum,
});
currentDate = nextDate;
currentDate =
period === "day"
? addDays(currentDate, 1)
: period === "month"
? addMonths(currentDate, 1)
: addHours(currentDate, 1);
}
return statistics;
};
@@ -90,7 +84,7 @@ const generateSumStatistics = (
const generateCurvedStatistics = (
start: Date,
end: Date,
period: "5minute" | "hour" | "day" | "month" = "hour",
_period: "5minute" | "hour" | "day" | "month" = "hour",
initValue: number,
maxDiff: number,
metered: boolean
@@ -104,12 +98,11 @@ const generateCurvedStatistics = (
let half = false;
const now = new Date();
while (end > currentDate && currentDate < now) {
const nextDate = getNextDate(currentDate, period);
const add = i * (Math.random() * maxDiff);
sum += add;
statistics.push({
start: currentDate.getTime(),
end: nextDate.getTime(),
end: currentDate.getTime(),
mean: null,
min: null,
max: null,
@@ -118,7 +111,7 @@ const generateCurvedStatistics = (
state: initValue + sum,
sum: metered ? sum : null,
});
currentDate = nextDate;
currentDate = addHours(currentDate, 1);
if (!half && i > hours / 2) {
half = true;
}
@@ -296,7 +289,7 @@ const statisticsFunctions: Record<
end,
period,
productionFinalVal,
0.2
2
);
return [...morning, ...production, ...evening, ...rest];
},
+5
View File
@@ -0,0 +1,5 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockUpdate = (hass: MockHomeAssistant) => {
hass.mockWS("update/list", () => []);
};
+6
View File
@@ -228,6 +228,12 @@ export default tseslint.config(
globals: globals.serviceworker,
},
},
{
files: ["test/e2e/*.mjs"],
languageOptions: {
globals: globals.node,
},
},
{
plugins: {
html,
+1
View File
@@ -1,3 +1,4 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
+10 -25
View File
@@ -1,3 +1,4 @@
import "@material/mwc-drawer";
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
@@ -6,8 +7,6 @@ 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";
@@ -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,14 +75,15 @@ 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">
<div slot="appContent">
<mwc-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@@ -144,7 +144,7 @@ class HaGallery extends LitElement {
</div>
</div>
</div>
</ha-drawer>
</mwc-drawer>
<notification-manager
.hass=${FAKE_HASS}
id="notifications"
@@ -226,27 +226,12 @@ class HaGallery extends LitElement {
-ms-user-select: initial;
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
}
.sidebar {
box-sizing: border-box;
max-height: calc(100vh - 64px);
overflow-y: auto;
padding: 4px;
}
.drawer-title {
align-items: center;
box-sizing: border-box;
color: var(--primary-text-color);
display: flex;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
min-height: 64px;
padding: 0 16px;
}
.sidebar a {
color: var(--primary-text-color);
display: block;
@@ -270,7 +255,7 @@ class HaGallery extends LitElement {
opacity: 0.12;
}
.app-content {
div[slot="appContent"] {
display: flex;
flex-direction: column;
min-height: 100vh;
@@ -75,17 +75,7 @@ export class DemoAutomationDescribeCondition extends LitElement {
<div class="condition">
<span>
${this._condition
? describeCondition(
this._condition,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)
? describeCondition(this._condition, this.hass, [])
: "<invalid YAML>"}
</span>
<ha-yaml-editor
@@ -98,19 +88,7 @@ export class DemoAutomationDescribeCondition extends LitElement {
${conditions.map(
(conf) => html`
<div class="condition">
<span
>${describeCondition(
conf as any,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)}</span
>
<span>${describeCondition(conf as any, this.hass, [])}</span>
<pre>${dump(conf)}</pre>
</div>
`
@@ -99,17 +99,7 @@ export class DemoAutomationDescribeTrigger extends LitElement {
<div class="trigger">
<span>
${this._trigger
? describeTrigger(
this._trigger,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)
? describeTrigger(this._trigger, this.hass, [])
: "<invalid YAML>"}
</span>
<ha-yaml-editor
@@ -121,19 +111,7 @@ export class DemoAutomationDescribeTrigger extends LitElement {
${triggers.map(
(conf) => html`
<div class="trigger">
<span
>${describeTrigger(
conf as any,
this.hass.localize,
this.hass.locale,
[],
this.hass.states,
this.hass.entities,
this.hass.config,
this.hass.formatEntityState,
this.hass.formatEntityAttributeValue
)}</span
>
<span>${describeTrigger(conf as any, this.hass, [])}</span>
<pre>${dump(conf)}</pre>
</div>
`
+1
View File
@@ -1,2 +1,3 @@
[build.environment]
YARN_VERSION = "1.22.11"
NODE_OPTIONS = "--max_old_space_size=6144"
+63 -53
View File
@@ -14,14 +14,22 @@
"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",
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test:e2e": "node test/e2e/run-suites.mjs demo app gallery",
"test:e2e:browserstack": "node test/e2e/run-suites.mjs demo:browserstack app:browserstack gallery:browserstack",
"test:e2e:show-report": "yarn playwright show-report test/e2e/reports/combined",
"test:e2e:demo": "playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:demo:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.demo.config.ts",
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:app:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.app.config.ts",
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
"test:e2e:gallery:browserstack": "browserstack-node-sdk playwright test --config test/e2e/playwright.gallery.config.ts"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -29,40 +37,42 @@
"dependencies": {
"@babel/runtime": "7.29.2",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.2",
"@codemirror/autocomplete": "6.20.1",
"@codemirror/commands": "6.10.3",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.3",
"@codemirror/lint": "6.9.6",
"@codemirror/lint": "6.9.5",
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@codemirror/view": "6.41.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.1",
"@formatjs/intl-displaynames": "7.3.4",
"@formatjs/intl-durationformat": "0.10.7",
"@formatjs/intl-getcanonicallocales": "3.2.5",
"@formatjs/intl-listformat": "8.3.4",
"@formatjs/intl-locale": "5.3.4",
"@formatjs/intl-numberformat": "9.3.4",
"@formatjs/intl-pluralrules": "6.3.4",
"@formatjs/intl-relativetimeformat": "12.3.4",
"@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",
@@ -74,19 +84,19 @@
"@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",
"barcode-detector": "3.1.3",
"barcode-detector": "3.1.2",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
"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",
@@ -97,16 +107,16 @@
"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.3",
"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",
@@ -118,30 +128,31 @@
"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",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0",
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.29.0",
"@babel/helper-define-polyfill-provider": "0.6.8",
"@babel/plugin-transform-runtime": "7.29.0",
"@babel/preset-env": "7.29.5",
"@babel/preset-env": "7.29.3",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.60.0",
"@lokalise/node-api": "16.0.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",
"@playwright/test": "1.59.1",
"@rsdoctor/rspack-plugin": "1.5.9",
"@rspack/core": "2.0.1",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -160,22 +171,22 @@
"@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",
"browserstack-node-sdk": "1.53.2",
"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",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -186,8 +197,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": "16.4.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -196,21 +206,21 @@
"prettier": "3.8.3",
"rspack-manifest-plugin": "5.2.1",
"serve": "14.2.6",
"sinon": "22.0.0",
"tar": "7.5.15",
"terser-webpack-plugin": "5.6.0",
"sinon": "21.1.2",
"tar": "7.5.13",
"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.1",
"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"
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-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",
@@ -219,8 +229,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"
}
}
-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";
-54
View File
@@ -1,54 +0,0 @@
/**
* Walks up the composed tree (jumping shadow roots → their hosts), returning
* the ancestor chain top-down. Used to compare two nodes that may live in
* different shadow trees — `Node.compareDocumentPosition` only works within a
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
*/
const composedAncestorPath = (node: Node): Node[] => {
const path: Node[] = [];
let cur: Node | null = node;
while (cur) {
path.push(cur);
const parent = cur.parentNode;
if (parent instanceof ShadowRoot) {
cur = parent.host;
} else if (parent) {
cur = parent;
} else {
const root = cur.getRootNode();
cur = root instanceof ShadowRoot ? root.host : null;
}
}
return path.reverse();
};
/**
* Document-order comparator that works across shadow boundaries. Suitable as
* the `Array.prototype.sort` callback for collections of nodes that may live
* in different shadow trees.
*/
export const compareNodeOrder = (a: Node, b: Node): number => {
if (a === b) {
return 0;
}
const pa = composedAncestorPath(a);
const pb = composedAncestorPath(b);
let i = 0;
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
i++;
}
if (i === 0) {
return 0;
}
if (i === pa.length) {
return -1;
}
if (i === pb.length) {
return 1;
}
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
// eslint-disable-next-line no-bitwise
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
};
-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 "";
};
+3
View File
@@ -117,6 +117,9 @@ export const generateEntityFilter = (
}
}
if (entityCategories) {
if (!entity) {
return false;
}
const category = entity?.entity_category || "none";
if (!entityCategories.has(category)) {
return false;
+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,68 +0,0 @@
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
/**
* @element ha-automation-row-live-test
*
* @summary
* Small status indicator dot used in automation/condition rows to surface the
* live evaluation result.
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@property({ reflect: true }) public state: LiveTestState = "unknown";
@property() public label = "";
protected render() {
return html`
<div
id="indicator"
role="status"
tabindex="0"
aria-label=${this.label}
></div>
`;
}
static styles = css`
:host {
position: absolute;
inset-inline-end: -6px;
display: inline-block;
}
#indicator {
width: 12px;
height: 12px;
border-radius: var(--ha-border-radius-circle);
border: 3px solid;
box-sizing: border-box;
background-color: var(--card-background-color);
transition: all var(--ha-animation-duration-normal) ease-in-out;
}
:host([state="pass"]) #indicator {
background-color: var(--ha-color-fill-success-loud-resting);
border-color: var(--ha-color-fill-success-loud-resting);
}
:host([state="fail"]) #indicator {
border-color: var(--ha-color-fill-warning-loud-resting);
}
:host([state="invalid"]) #indicator {
border-color: var(--ha-color-fill-danger-loud-resting);
}
:host([state="unknown"]) #indicator {
border-color: var(--ha-color-fill-neutral-loud-resting);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-row-live-test": HaAutomationRowLiveTest;
}
}
@@ -124,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];
};
+4 -5
View File
@@ -19,7 +19,7 @@ import type {
} from "echarts/types/dist/shared";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, 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";
@@ -102,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;
@@ -471,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) {
@@ -485,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);
});
@@ -1111,7 +1110,7 @@ export class HaChartBase extends LitElement {
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s != null && s.type === "sankey"
(s: any) => s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({
@@ -11,8 +11,6 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
@@ -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`
@@ -293,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 =
@@ -413,7 +410,8 @@ export class StateHistoryChartLine extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
@@ -435,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;
}
@@ -478,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;
};
@@ -829,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);
}
}
@@ -837,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;
@@ -871,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) {
@@ -882,6 +881,7 @@ export class StateHistoryChartLine extends LitElement {
chartIndex: this.chartIndex,
});
}
this._previousYAxisLabelValue = value;
return label;
};
@@ -14,7 +14,6 @@ import { computeRTL } from "../../common/util/compute_rtl";
import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
@@ -145,10 +144,7 @@ export class StateHistoryChartTimeline extends LitElement {
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
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>`;
@@ -171,12 +167,11 @@ export class StateHistoryChartTimeline extends LitElement {
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
@@ -203,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",
@@ -264,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");
+49 -131
View File
@@ -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,
@@ -39,9 +37,7 @@ import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
mean: "mean",
@@ -132,7 +128,7 @@ export class StatisticsChart extends LitElement {
private _computedStyle?: CSSStyleDeclaration;
private _yAxisFractionDigits = 1;
private _previousYAxisLabelValue = 0;
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
return changedProps.size > 1 || !changedProps.has("hass");
@@ -144,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();
}
@@ -246,8 +241,6 @@ 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}`
: "";
@@ -259,67 +252,8 @@ export class StatisticsChart extends LitElement {
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
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
}
// 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,
@@ -331,7 +265,14 @@ export class StatisticsChart extends LitElement {
options
)}${unit}`;
const time = index === 0 ? rawTime : "";
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)
@@ -427,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 ||
@@ -462,7 +398,8 @@ export class StatisticsChart extends LitElement {
tooltip: {
trigger: "axis",
renderMode: "html",
position: sideTooltipPosition,
position: "bottom",
align: "center",
confine: true,
formatter: this._renderTooltip,
},
@@ -497,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;
@@ -577,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];
@@ -787,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
);
}
});
@@ -836,7 +745,6 @@ export class StatisticsChart extends LitElement {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
trackY(val[val.length - 1]);
});
}
}
@@ -870,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
@@ -904,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)));
}
@@ -127,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}
+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;
+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
+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();
});
};
+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;
+7 -14
View File
@@ -1,4 +1,3 @@
import { consume, type ContextType } from "@lit/context";
import {
mdiAmpersand,
mdiClockOutline,
@@ -13,11 +12,11 @@ import {
mdiWeatherSunny,
} from "@mdi/js";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { configContext, connectionContext } from "../data/context";
import { conditionIcon, FALLBACK_DOMAIN_ICONS } from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@@ -37,18 +36,12 @@ export const CONDITION_ICONS = {
@customElement("ha-condition-icon")
export class HaConditionIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public condition?: string;
@property() public icon?: string;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -58,13 +51,13 @@ export class HaConditionIcon extends LitElement {
return nothing;
}
if (!this._connection || !this._config) {
if (!this.hass) {
return this._renderFallback();
}
const icon = conditionIcon(
this._connection.connection,
this._config.config,
this.hass.connection,
this.hass.config,
this.condition
).then((icn) => {
if (icn) {
-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
+1 -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 = {
-1
View File
@@ -59,7 +59,6 @@ export class HaExpansionPanel extends LitElement {
<slot class="secondary" name="secondary">${this.secondary}</slot>
</div>
</slot>
<slot name="event"></slot>
${!this.leftChevron ? chevronIcon : nothing}
<slot name="icons"></slot>
</div>
+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;
}

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