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
358 changed files with 7165 additions and 8222 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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
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@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
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
@@ -18,6 +18,6 @@ jobs:
pull-requests: read
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@563bf132657a13ded0b01fcb723c5a58cdd824e2 # v7.2.1
- uses: release-drafter/release-drafter@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
+2 -7
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.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,
+43 -35
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,26 +37,26 @@
"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.42.1",
"@codemirror/view": "6.41.1",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.2",
"@formatjs/intl-displaynames": "7.3.5",
"@formatjs/intl-durationformat": "0.10.8",
"@formatjs/intl-getcanonicallocales": "3.2.6",
"@formatjs/intl-listformat": "8.3.5",
"@formatjs/intl-locale": "5.3.5",
"@formatjs/intl-numberformat": "9.3.5",
"@formatjs/intl-pluralrules": "6.3.5",
"@formatjs/intl-relativetimeformat": "12.3.5",
"@formatjs/intl-datetimeformat": "7.4.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",
@@ -81,7 +89,7 @@
"@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",
@@ -100,7 +108,7 @@
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.4",
"intl-messageformat": "11.2.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",
@@ -122,28 +130,29 @@
"superstruct": "2.0.2",
"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.10",
"@rspack/core": "2.0.2",
"@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",
@@ -166,6 +175,7 @@
"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.3.0",
"eslint-config-prettier": "10.1.8",
@@ -176,8 +186,7 @@
"eslint-plugin-unused-imports": "4.4.1",
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"generate-license-file": "4.1.1",
"fs-extra": "11.3.4",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -188,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.4",
"lint-staged": "16.4.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.18.1",
@@ -198,17 +206,17 @@
"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.2",
"typescript-eslint": "8.59.1",
"vite-tsconfig-paths": "6.1.1",
"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.2",
-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.`
);
}
);
-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 =
+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;
+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 {
@@ -124,7 +124,6 @@ export class HaAutomationRow extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
.row {
display: flex;
@@ -195,6 +194,7 @@ export class HaAutomationRow extends LitElement {
}
::slotted([slot="event"]) {
position: absolute;
top: 13px;
inset-inline-end: 0;
}
.icons {
+1 -1
View File
@@ -1110,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({
@@ -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 =
@@ -144,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>`;
@@ -170,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
@@ -202,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",
+31 -108
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,
@@ -243,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}`
: "";
@@ -256,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,
@@ -328,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)
@@ -424,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 ||
@@ -567,53 +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]!]);
} 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]);
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];
@@ -773,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
);
}
});
@@ -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}
+3 -11
View File
@@ -22,14 +22,6 @@ const isOn = (stateObj?: HassEntity) =>
!STATES_OFF.includes(stateObj.state) &&
!isUnavailableState(stateObj.state);
/**
* @element ha-entity-toggle
*
* @cssprop --ha-entity-toggle-switch-width - Width of the switch track. Defaults to `38px`.
* @cssprop --ha-entity-toggle-switch-size - Height of the switch track. Defaults to `20px`.
* @cssprop --ha-entity-toggle-switch-thumb-size - Size of the switch thumb. Defaults to `14px`.
*/
@customElement("ha-entity-toggle")
export class HaEntityToggle extends LitElement {
// hass is not a property so that we only re-render on stateObj changes
@@ -173,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;
@@ -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
+2 -8
View File
@@ -210,10 +210,7 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const isRTL = computeRTL(hass);
const output: StatisticComboBoxItem[] = [];
@@ -356,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}
+2 -4
View File
@@ -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
+19 -25
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,10 +67,6 @@ 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;
@@ -93,24 +89,22 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
await this.updateComplete;
requestAnimationFrame(() => {
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
const element = this.renderRoot.querySelector("[autofocus]");
if (element !== null) {
if (!element.id) {
element.id = "ha-bottom-sheet-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.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();
-146
View File
@@ -1,146 +0,0 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import type { YamlFieldSchema } from "../resources/yaml_field_schema";
/**
* Tooltip element rendered inside a CodeMirror hoverTooltip for YAML field
* keys in the automation / script / card YAML editors.
*
* Shows:
* - Field name (monospace)
* - "required" badge when applicable
* - Description paragraph
* - Selector type hint
* - Example value
* - Default value
*/
@customElement("ha-code-editor-yaml-hover")
export class HaCodeEditorYamlHover extends LitElement {
@property({ attribute: false }) public fieldName = "";
@property({ attribute: false }) public fieldSchema!: YamlFieldSchema;
/**
* Optional localize callback forwarded from the editor so translated
* descriptions can be rendered. When absent, strings are shown verbatim.
*/
@property({ attribute: false }) public localize?: (
key: string,
...args: unknown[]
) => string;
render() {
const schema = this.fieldSchema;
if (!schema) return nothing;
const description = schema.description
? (this.localize ? this.localize(schema.description) : "") ||
schema.description
: undefined;
const selectorType = schema.selector
? Object.keys(schema.selector)[0]
: undefined;
return html`
<div class="header">
<code class="key">${this.fieldName}</code>
${schema.required
? html`<span class="badge required">required</span>`
: nothing}
${selectorType
? html`<span class="badge type">${selectorType}</span>`
: nothing}
</div>
${description ? html`<div class="desc">${description}</div>` : nothing}
${schema.example != null
? html`<div class="meta">
<span class="meta-label">Example:</span>
<code>${String(schema.example)}</code>
</div>`
: nothing}
${schema.default != null
? html`<div class="meta">
<span class="meta-label">Default:</span>
<code>${String(schema.default)}</code>
</div>`
: nothing}
`;
}
static styles = css`
:host {
display: block;
padding: 6px 10px;
max-width: 320px;
line-height: 1.5;
font-size: 0.9em;
}
.header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
flex-wrap: wrap;
}
code.key {
font-family: var(--ha-font-family-code);
font-size: 1em;
font-weight: bold;
}
.badge {
display: inline-block;
border-radius: 4px;
padding: 0 5px;
font-size: 0.78em;
line-height: 1.6;
font-family: var(--ha-font-family-body);
}
.badge.required {
background: color-mix(
in srgb,
var(--error-color, #db4437) 15%,
transparent
);
color: var(--error-color, #db4437);
}
.badge.type {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-text-color);
}
.desc {
color: var(--secondary-text-color);
margin-bottom: 4px;
}
.meta {
display: flex;
gap: 6px;
align-items: baseline;
color: var(--secondary-text-color);
}
.meta-label {
font-size: 0.85em;
opacity: 0.75;
flex-shrink: 0;
}
.meta code {
font-family: var(--ha-font-family-code);
font-size: 0.9em;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-code-editor-yaml-hover": HaCodeEditorYamlHover;
}
}
+94 -136
View File
@@ -31,21 +31,15 @@ import { consume } from "@lit/context";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { getEntityContext } from "../common/entity/context/get_entity_context";
import { computeDeviceName } from "../common/entity/compute_device_name";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyleScrollbar } from "../resources/styles";
import {
buildEntityCompletions,
buildDeviceCompletions,
buildAreaCompletions,
buildFloorCompletions,
buildLabelCompletions,
} from "../resources/ha_completion_items";
import type {
JinjaArgType,
HassArgHoverContext,
} from "../resources/jinja_ha_completions";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import "./ha-code-editor-yaml-hover";
import type { HomeAssistant } from "../types";
import { showToast } from "../util/toast";
import { documentationUrl } from "../util/documentation-url";
@@ -86,14 +80,6 @@ export class HaCodeEditor extends ReactiveElement {
public hass?: HomeAssistant;
/**
* Optional field schema for YAML mode. When set, the editor will provide
* field-aware key/value completions, hover tooltips, and linting for the
* known fields described by this map.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -150,12 +136,6 @@ export class HaCodeEditor extends ReactiveElement {
private _completionInfoDestroy?: () => void;
// Stored YAML syntax error set by setYamlError(); consumed by _yamlSyntaxLinter.
private _yamlSyntaxError: {
mark?: { position: number; line: number; column: number };
reason?: string;
} | null = null;
private _completionInfoRequest = 0;
private _completionInfoKey?: string;
@@ -189,10 +169,6 @@ export class HaCodeEditor extends ReactiveElement {
* Push a YAML parse error (or null to clear) into the lint gutter as a
* diagnostic. Avoids re-parsing the document the caller (ha-yaml-editor)
* already has the error from its own js-yaml load() call.
*
* Stores the error and triggers forceLinting() so the yamlLintCompartment
* linter re-runs and returns it as a diagnostic rather than calling
* setDiagnostics() which would wipe diagnostics from other linters.
*/
public setYamlError(
err: {
@@ -200,10 +176,27 @@ export class HaCodeEditor extends ReactiveElement {
reason?: string;
} | null
): void {
this._yamlSyntaxError = err;
if (this.codemirror && this._loadedCodeMirror) {
this._loadedCodeMirror.forceLinting(this.codemirror);
if (!this.codemirror || !this._loadedCodeMirror) return;
let diagnostics: {
from: number;
to: number;
severity: "error";
message: string;
}[] = [];
if (err) {
const doc = this.codemirror.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this.hass?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this.hass?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
}
this.codemirror.dispatch(
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
);
}
public connectedCallback() {
@@ -264,7 +257,9 @@ export class HaCodeEditor extends ReactiveElement {
effects: [
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
@@ -276,23 +271,20 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
),
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
],
});
this._updateToolbarButtons();
}
if (changedProps.has("lint") || changedProps.has("yamlFieldSchema")) {
if (changedProps.has("lint")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
this._buildYamlSyntaxLinter()
),
});
}
if (changedProps.has("yamlFieldSchema") || changedProps.has("readOnly")) {
transactions.push({
effects: this._loadedCodeMirror!.yamlSchemaCompartment!.reconfigure(
this._buildSchemaLinter()
this.lint && !this.readOnly
? [this._loadedCodeMirror!.lintGutter()]
: []
),
});
}
@@ -345,60 +337,6 @@ export class HaCodeEditor extends ReactiveElement {
return this._loadedCodeMirror!.langs[this.mode];
}
private _buildSchemaLinter() {
if (!this._loadedCodeMirror || !this.yamlFieldSchema || this.readOnly) {
return [];
}
const schema = this.yamlFieldSchema;
return [
this._loadedCodeMirror.linter(
(view) => this._loadedCodeMirror!.haYamlLintSource(view, schema),
{ delay: 500 }
),
];
}
/**
* Builds the yamlLintCompartment extensions: a linter that surfaces the
* stored _yamlSyntaxError (set by setYamlError), plus the lint gutter when
* either syntax linting or schema linting is active.
*
* Using a linter() instead of setDiagnostics() means this linter's
* diagnostics are managed independently of the schema linter's diagnostics
* they don't overwrite each other.
*/
private _buildYamlSyntaxLinter() {
if (this.readOnly) return [];
const showGutter = this.lint || !!this.yamlFieldSchema;
const extensions: Extension[] = [];
if (showGutter) {
extensions.push(this._loadedCodeMirror!.lintGutter());
}
if (this.lint) {
extensions.push(
this._loadedCodeMirror!.linter(
(view) => {
const err = this._yamlSyntaxError;
if (!err) return [];
const doc = view.state.doc;
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
const line = doc.lineAt(pos);
const message = `${
err.reason ||
this.hass?.localize("ui.components.yaml-editor.error") ||
"YAML syntax error"
}${err.mark ? ` (${this.hass?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
return [
{ from: pos, to: line.to, severity: "error" as const, message },
];
},
{ delay: 0 }
)
);
}
return extensions;
}
private _createCodeMirror() {
if (!this._loadedCodeMirror) {
throw new Error("Cannot create editor before CodeMirror is loaded");
@@ -447,10 +385,7 @@ export class HaCodeEditor extends ReactiveElement {
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
),
this._loadedCodeMirror.yamlLintCompartment.of(
this._buildYamlSyntaxLinter()
),
this._loadedCodeMirror.yamlSchemaCompartment.of(
this._buildSchemaLinter()
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
),
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
this._loadedCodeMirror.tooltips({
@@ -466,23 +401,6 @@ export class HaCodeEditor extends ReactiveElement {
),
{ hoverTime: 300 }
),
...(this.mode === "yaml" && this.yamlFieldSchema
? [
this._loadedCodeMirror.hoverTooltip(
(view, pos) =>
this._loadedCodeMirror!.haYamlHoverSource(view, pos, {
schema: this.yamlFieldSchema!,
localize: this.hass?.localize.bind(this.hass) as
| ((key: string, ...args: unknown[]) => string)
| undefined,
hassContext: this.hass
? this._hassArgHoverContext()
: undefined,
}),
{ hoverTime: 300 }
),
]
: []),
...(this.placeholder ? [placeholder(this.placeholder)] : []),
];
@@ -490,18 +408,6 @@ export class HaCodeEditor extends ReactiveElement {
const completionSources: CompletionSource[] = [
this._loadedCodeMirror.haJinjaCompletionSource,
];
if (this.mode === "yaml" && this.yamlFieldSchema) {
completionSources.push(
this._loadedCodeMirror.haYamlCompletionSource({
schema: this.yamlFieldSchema,
states: this.hass?.states,
devices: this.hass?.devices,
areas: this.hass?.areas,
floors: this.hass?.floors,
labels: this._labels,
})
);
}
if (this.autocompleteEntities && this.hass) {
completionSources.push(this._entityCompletions.bind(this));
}
@@ -512,7 +418,6 @@ export class HaCodeEditor extends ReactiveElement {
this._loadedCodeMirror.autocompletion({
override: completionSources,
maxRenderedOptions: 10,
activateOnCompletion: (completion) => completion.type === "yaml-key",
}),
this._loadedCodeMirror.closeBrackets(),
this._loadedCodeMirror.closeBracketsOverride,
@@ -1060,9 +965,23 @@ export class HaCodeEditor extends ReactiveElement {
});
};
private _getStates = memoizeOne((states: HassEntities): Completion[] =>
buildEntityCompletions(states)
);
private _getStates = memoizeOne((states: HassEntities): Completion[] => {
if (!states) {
return [];
}
const options = Object.keys(states).map((key) => ({
type: "variable",
label: states[key].attributes.friendly_name
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
: key,
displayLabel: key,
detail: states[key].attributes.friendly_name,
apply: key,
}));
return options;
});
// Map of HA Jinja function name → (arg index → JinjaArgType).
// Derived from the snippet definitions in jinja_ha_completions.ts.
@@ -1459,7 +1378,18 @@ export class HaCodeEditor extends ReactiveElement {
private _getDevices = memoizeOne(
(devices: HomeAssistant["devices"]): Completion[] =>
buildDeviceCompletions(devices)
Object.values(devices)
.filter((device) => !device.disabled_by)
.map((device) => {
const name = computeDeviceName(device);
return {
type: "variable",
label: `${name} ${device.id}`,
displayLabel: name ?? device.id,
detail: device.id,
apply: device.id,
};
})
);
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
@@ -1478,7 +1408,17 @@ export class HaCodeEditor extends ReactiveElement {
}
private _getAreas = memoizeOne(
(areas: HomeAssistant["areas"]): Completion[] => buildAreaCompletions(areas)
(areas: HomeAssistant["areas"]): Completion[] =>
Object.values(areas).map((area) => {
const name = computeAreaName(area) ?? area.area_id;
return {
type: "variable",
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: area.area_id,
apply: area.area_id,
};
})
);
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
@@ -1498,7 +1438,16 @@ export class HaCodeEditor extends ReactiveElement {
private _getFloors = memoizeOne(
(floors: HomeAssistant["floors"]): Completion[] =>
buildFloorCompletions(floors)
Object.values(floors).map((floor) => {
const name = computeFloorName(floor) ?? floor.floor_id;
return {
type: "variable",
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: floor.floor_id,
apply: floor.floor_id,
};
})
);
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
@@ -1518,7 +1467,16 @@ export class HaCodeEditor extends ReactiveElement {
private _getLabels = memoizeOne(
(labels: LabelRegistryEntry[]): Completion[] =>
buildLabelCompletions(labels)
labels.map((label) => {
const name = label.name.trim() || label.label_id;
return {
type: "variable",
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
displayLabel: name,
detail: label.label_id,
apply: label.label_id,
};
})
);
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
+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();
});
};
+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>
+5 -1
View File
@@ -122,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>`;
+1 -4
View File
@@ -137,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,
})}
>
+14 -18
View File
@@ -1,6 +1,5 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiPlaylistPlus } from "@mdi/js";
import {
css,
@@ -14,10 +13,8 @@ import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../common/dom/fire_event";
import { configContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import type { FuseWeightedKey } from "../resources/fuseMultiTerm";
import { isIosApp } from "../util/is_ios";
import "./ha-bottom-sheet";
import "./ha-button";
import "./ha-combo-box-item";
@@ -113,9 +110,10 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state()
@consume({ context: configContext, subscribe: true })
private _hassConfig?: ContextType<typeof configContext>;
// disabled till iOS app fix the "focus_element" implementation
// @state()
// @consume({ context: authContext, subscribe: true })
// private auth?: ContextType<typeof authContext>;
@state() private _opened = false;
@@ -321,18 +319,16 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
this._comboBox?.setFieldValue(this._initialFieldValue);
this._initialFieldValue = undefined;
}
if (
this._hassConfig?.auth.external &&
isIosApp(this._hassConfig.auth.external)
) {
this._hassConfig.auth.external.fireMessage({
type: "focus_element",
payload: {
element_id: "combo-box",
},
});
return;
}
// disabled till iOS app fix the "focus_element" implementation
// if (this.auth?.external && isIosApp(this.auth.external)) {
// this.auth.external.fireMessage({
// type: "focus_element",
// payload: {
// element_id: "combo-box",
// },
// });
// return;
// }
this._comboBox?.focus();
});
+5
View File
@@ -166,6 +166,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${entity}
slot="graphic"
></ha-state-icon>
@@ -321,6 +322,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${group}
slot="graphic"
></ha-state-icon>
@@ -345,6 +347,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${scene}
slot="graphic"
></ha-state-icon>
@@ -397,6 +400,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${automation}
slot="graphic"
></ha-state-icon>
@@ -448,6 +452,7 @@ export class HaRelatedItems extends LitElement {
graphic="icon"
>
<ha-state-icon
.hass=${this.hass}
.stateObj=${script}
slot="graphic"
></ha-state-icon>
+1 -6
View File
@@ -63,12 +63,7 @@ export class HaSelectBox extends LitElement {
const selected = option.value === this.value;
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass
? computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
: false;
const isRTL = this.hass ? computeRTL(this.hass) : false;
const imageSrc =
typeof option.image === "object"
@@ -13,16 +13,13 @@ import "../ha-input-helper-text";
import type { SelectBoxOption } from "../ha-select-box";
import "../ha-select-box";
export const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"any",
"first",
"last",
];
export const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = [
"any",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
+6 -10
View File
@@ -36,15 +36,7 @@ export class HaIconSelector extends LitElement {
const placeholder =
this.selector.icon?.placeholder ||
stateObj?.attributes.icon ||
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
));
(stateObj && until(entityIcon(this.hass, stateObj)));
return html`
<ha-icon-picker
@@ -59,7 +51,11 @@ export class HaIconSelector extends LitElement {
>
${!placeholder && stateObj
? html`
<ha-state-icon slot="start" .stateObj=${stateObj}></ha-state-icon>
<ha-state-icon
slot="start"
.hass=${this.hass}
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing}
</ha-icon-picker>
+16 -11
View File
@@ -523,10 +523,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
private _renderUserItem(selectedPanel: string) {
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
const isSelected = selectedPanel === "profile";
return html`
@@ -564,9 +561,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
id="sidebar-external-config"
>
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
<span class="item-text" slot="headline">
${this.hass.localize("ui.sidebar.external_app_configuration")}
</span>
<span class="item-text" slot="headline"
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
>
</ha-list-item-button>
${!this.alwaysExpand
? this._renderToolTip(
@@ -743,7 +740,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
border-radius: var(--ha-border-radius-sm);
--ha-row-item-min-height: var(--ha-space-10);
--ha-row-item-padding-block: 0;
--ha-row-item-padding-inline: var(--ha-space-3);
width: var(--ha-space-12);
position: relative;
transition: width var(--ha-animation-duration-normal) ease;
@@ -844,12 +840,21 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
}
ha-user-badge {
width: 40px;
height: 40px;
width: var(--ha-space-10);
height: var(--ha-space-10);
}
ha-list-item-button.user {
--ha-row-item-padding-inline: var(--ha-space-1) 0;
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
}
ha-list-item-button.user.rtl {
--ha-row-item-padding-inline: var(--ha-space-4) var(--ha-space-3);
}
ha-user-badge {
flex-shrink: 0;
margin-right: calc(var(--ha-space-2) * -1);
}
.spacer {
+13 -32
View File
@@ -1,46 +1,31 @@
import { consume, type ContextType } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
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 { computeStateDomain } from "../common/entity/compute_state_domain";
import {
configContext,
connectionContext,
entitiesContext,
} from "../data/context";
import {
DEFAULT_DOMAIN_ICON,
entityIcon,
FALLBACK_DOMAIN_ICONS,
} from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-state-icon")
export class HaStateIcon extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: false }) public stateValue?: string;
@property() public icon?: string;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: entitiesContext, subscribe: true })
protected _entities?: ContextType<typeof entitiesContext>;
protected render() {
const overrideIcon =
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
(this.stateObj && this.hass?.entities[this.stateObj.entity_id]?.icon) ||
this.stateObj?.attributes.icon;
if (overrideIcon) {
return html`<ha-icon .icon=${overrideIcon}></ha-icon>`;
@@ -48,21 +33,17 @@ export class HaStateIcon extends LitElement {
if (!this.stateObj) {
return nothing;
}
if (!this._config || !this._connection || !this._entities) {
if (!this.hass) {
return this._renderFallback();
}
const icon = entityIcon(
this._entities,
this._config.config,
this._connection.connection,
this.stateObj,
this.stateValue
).then((icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
const icon = entityIcon(this.hass, this.stateObj, this.stateValue).then(
(icn) => {
if (icn) {
return html`<ha-icon .icon=${icn}></ha-icon>`;
}
return this._renderFallback();
}
return this._renderFallback();
});
);
return html`${until(icon)}`;
}
-4
View File
@@ -228,10 +228,6 @@ export class HaSwitch extends Switch {
outline: var(--wa-focus-ring);
outline-offset: var(--wa-focus-ring-offset);
}
:host(:empty) slot.label {
display: none;
}
`,
];
}
+1 -4
View File
@@ -1136,10 +1136,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let rtl = false;
let showEntityId = false;
if (type === "area" || type === "floor") {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
rtl = computeRTL(this.hass);
hasFloor =
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
}
+1 -11
View File
@@ -7,7 +7,6 @@ import { fireEvent } from "../common/dom/fire_event";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import type { YamlFieldSchemaMap } from "../resources/yaml_field_schema";
import { showToast } from "../util/toast";
import "./ha-button";
import "./ha-code-editor";
@@ -33,14 +32,6 @@ export class HaYamlEditor extends LitElement {
@property({ attribute: false }) public yamlSchema: Schema = DEFAULT_SCHEMA;
/**
* Optional field schema for YAML mode. When provided, the code editor will
* offer field-aware key/value completions, hover tooltips, and linting.
* This is forwarded directly to ha-code-editor.
*/
@property({ attribute: false })
public yamlFieldSchema?: YamlFieldSchemaMap;
@property({ attribute: false }) public defaultValue?: any;
@property({ attribute: "is-valid", type: Boolean }) public isValid = true;
@@ -128,9 +119,8 @@ export class HaYamlEditor extends LitElement {
.inDialog=${this.inDialog}
mode="yaml"
lint
.autocompleteEntities=${!this.yamlFieldSchema}
autocomplete-entities
autocomplete-icons
.yamlFieldSchema=${this.yamlFieldSchema}
.error=${this.isValid === false}
@value-changed=${this._onChange}
@editor-save=${this._onEditorSave}
-8
View File
@@ -1,8 +1,6 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../list/types";
import { HaRowItem } from "./ha-row-item";
/**
@@ -41,12 +39,6 @@ export class HaListItemBase extends HaRowItem {
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
fireEvent(this, "ha-list-item-register", { item: this });
}
public disconnectedCallback(): void {
super.disconnectedCallback();
fireEvent(this, "ha-list-item-unregister", { item: this });
}
/**
+15 -34
View File
@@ -1,7 +1,7 @@
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
/**
* @element ha-row-item
@@ -46,34 +46,13 @@ export class HaRowItem extends LitElement {
protected readonly _slotController = new HasSlotController(
this,
"start",
"end",
"headline",
"supporting-text",
"content"
);
@state() private _hasStart = false;
@state() private _hasEnd = false;
private _onSlotChange(name: "start" | "end") {
return (ev: Event) => {
const slot = ev.target as HTMLSlotElement;
const hasContent = slot
.assignedNodes({ flatten: true })
.some(
(node) =>
node.nodeType === Node.ELEMENT_NODE ||
(node.nodeType === Node.TEXT_NODE &&
(node as Text).textContent?.trim() !== "")
);
if (name === "start") {
this._hasStart = hasContent;
} else {
this._hasEnd = hasContent;
}
};
}
protected render(): TemplateResult {
return this._renderBase(this._renderInner());
}
@@ -83,20 +62,26 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
const hasStart = this._slotController.test("start");
const hasEnd = this._slotController.test("end");
const hasContent = this._slotController.test("content");
return html`
<div part="start" class="start" ?hidden=${!this._hasStart}>
<slot name="start" @slotchange=${this._onSlotChange("start")}></slot>
</div>
${hasStart
? html`<div part="start" class="start">
<slot name="start"></slot>
</div>`
: nothing}
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
<div part="end" class="end" ?hidden=${!this._hasEnd}>
<slot name="end" @slotchange=${this._onSlotChange("end")}></slot>
</div>
${hasEnd
? html`<div part="end" class="end">
<slot name="end"></slot>
</div>`
: nothing}
`;
}
@@ -163,10 +148,6 @@ export class HaRowItem extends LitElement {
align-items: center;
flex: 0 0 auto;
}
.start[hidden],
.end[hidden] {
display: none;
}
.headline {
overflow: hidden;
text-overflow: ellipsis;
+36 -58
View File
@@ -1,12 +1,10 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaListItemBase } from "../item/ha-list-item-base";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
/**
* @element ha-list-base
@@ -14,11 +12,9 @@ import type { HaListItemRegistrationDetail } from "./types";
*
* @summary
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
* `HaListItemBase` descendants via the `ha-list-item-register` /
* `ha-list-item-unregister` events they fire on connect/disconnect works
* across any nesting depth and shadow boundaries. Subclasses override
* `hostRole` and/or `render()` to specialize.
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
* `render()` to specialize.
*
* @slot - List items (`<ha-list-item-*>`).
*
@@ -72,14 +68,6 @@ export class HaListBase extends LitElement {
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public disconnectedCallback() {
@@ -87,14 +75,11 @@ export class HaListBase extends LitElement {
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this.updateListItems();
}
public focus(options?: FocusOptions) {
@@ -130,14 +115,18 @@ export class HaListBase extends LitElement {
this._applyActive(focusItem);
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
const next = this._discoverListItems();
const changed =
next.length !== this.items.length ||
next.some((it, i) => it !== this.items[i]);
if (!changed) {
return;
}
this.items = next;
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= this.items.length ||
this._activeItemIndex >= next.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
@@ -146,32 +135,6 @@ export class HaListBase extends LitElement {
this._applyActive(false);
}
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (this.items.includes(item)) {
return;
}
const next = [...this.items, item];
next.sort(compareNodeOrder);
this.items = next;
this.updateListItems();
};
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (!this.items.includes(item)) {
return;
}
this.items = this.items.filter((it) => it !== item);
this.updateListItems();
};
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
@@ -188,12 +151,27 @@ export class HaListBase extends LitElement {
this._hasFocusableItem = first !== -1;
}
public handleSlotChange = () => {
this.updateListItems();
};
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot></slot>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>`;
}
private _discoverListItems(): HaListItemBase[] {
const slot =
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
if (!slot) {
return [];
}
return slot
.assignedElements({ flatten: true })
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
}
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;
+1 -1
View File
@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot></slot>
<slot @slotchange=${this.handleSlotChange}></slot>
</div>
</nav>`;
}
-6
View File
@@ -11,15 +11,9 @@ export interface HaListActivatedDetail {
item: HaListItemBase;
}
export interface HaListItemRegistrationDetail {
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
}
}
+1
View File
@@ -37,6 +37,7 @@ class HaEntityMarker extends LitElement {
></div>`
: this.showIcon && this.entityId
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${this.hass?.states[this.entityId]}
></ha-state-icon>`
: !this.entityUnit
@@ -76,7 +76,12 @@ class DialogJoinMediaPlayers extends LitElement {
const entityId = this._entityId;
return html`
<ha-dialog .open=${this._open} flexcontent @closed=${this._dialogClosed}>
<ha-dialog
.hass=${this.hass}
.open=${this._open}
flexcontent
@closed=${this._dialogClosed}
>
<ha-dialog-header show-border slot="header">
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
@@ -100,6 +100,7 @@ class DialogMediaManage extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
?prevent-scrim-close=${this._uploading || this._deleting}
@closed=${this._dialogClosed}
@@ -77,6 +77,7 @@ class DialogMediaPlayerBrowse extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
width="large"
flexcontent
@@ -59,10 +59,7 @@ class HaMediaPlayerToggle extends LitElement {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const isRTL = computeRTL(this.hass);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
@@ -1,31 +1,15 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../common/array/ensure-array";
import { fireEvent } from "../../../common/dom/fire_event";
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
import { getDeviceIntegrationLookup } from "../../../data/device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import type { TargetSelector } from "../../../data/selector";
import {
filterSelectorDevices,
filterSelectorEntities,
} from "../../../data/selector";
import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-adaptive-dialog";
import "../../ha-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-md-list";
import "../../ha-md-list-item";
import "../../ha-svg-icon";
import "../../list/ha-list-base";
import "../ha-target-picker-item-row";
import type { TargetDetailsDialogParams } from "./show-dialog-target-details";
@@ -37,12 +21,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
@state() private _opened = false;
@state() private _entitySources?: EntitySources;
@state() private _entitySourcesLoaded = false;
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
public showDialog(params: TargetDetailsDialogParams): void {
this._params = params;
this._opened = true;
@@ -56,72 +34,6 @@ class DialogTargetDetails extends LitElement implements HassDialog {
private _dialogClosed() {
fireEvent(this, "dialog-closed", { dialog: this.localName });
this._params = undefined;
this._entitySources = undefined;
this._entitySourcesLoaded = false;
}
private _hasIntegration(selector: TargetSelector) {
return (
(selector.target?.entity &&
ensureArray(selector.target.entity).some((e) => e.integration)) ||
(selector.target?.device &&
ensureArray(selector.target.device).some((d) => d.integration))
);
}
protected updated(changedProperties: PropertyValues): void {
super.updated(changedProperties);
if (!changedProperties.has("_params")) {
return;
}
if (
this._params?.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded
) {
this._loadEntitySources();
}
}
private async _loadEntitySources(): Promise<void> {
try {
this._entitySources = await fetchEntitySourcesWithCache(this.hass);
} catch (err) {
// eslint-disable-next-line no-console
console.error("Failed to load entity sources for target details", err);
} finally {
this._entitySourcesLoaded = true;
}
}
private _filterEntities = (entity: HassEntity): boolean => {
const target = this._selectorTarget();
if (!target?.entity) {
return true;
}
return ensureArray(target.entity).some((e) =>
filterSelectorEntities(e, entity, this._entitySources)
);
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
const target = this._selectorTarget();
if (!target?.device) {
return true;
}
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
Object.values(this.hass.entities)
)
: undefined;
return ensureArray(target.device).some((d) =>
filterSelectorDevices(d, device, deviceIntegrations)
);
};
private _selectorTarget() {
return this._params?.selector?.target || null;
}
protected render() {
@@ -129,86 +41,33 @@ class DialogTargetDetails extends LitElement implements HassDialog {
return nothing;
}
let deviceFilter: HaDevicePickerDeviceFilterFunc | undefined;
let entityFilter: HaEntityPickerEntityFilterFunc | undefined;
let includeDomains: string[] | undefined;
let includeDeviceClasses: string[] | undefined;
let primaryEntitiesOnly: boolean | undefined;
if (this._params.selector) {
deviceFilter = this._filterDevices;
entityFilter = this._filterEntities;
primaryEntitiesOnly = this._params.selector.target?.primary_entities_only;
} else {
deviceFilter = this._params.deviceFilter;
entityFilter = this._params.entityFilter;
includeDomains = this._params.includeDomains;
includeDeviceClasses = this._params.includeDeviceClasses;
primaryEntitiesOnly = this._params.primaryEntitiesOnly;
}
const waitingForSources =
this._params.selector &&
this._hasIntegration(this._params.selector) &&
!this._entitySourcesLoaded;
return html`
<ha-adaptive-dialog
<ha-dialog
.hass=${this.hass}
.open=${this._opened}
header-title=${this.hass.localize(
"ui.components.target-picker.target_details"
)}
header-subtitle=${`${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}:
${this._params.title}`}
@closed=${this._dialogClosed}
>
<div class="type-wrapper">
<div class="type-label">
${this.hass.localize(
`ui.components.target-picker.type.${this._params.type}`
)}
</div>
<ha-list-base
.ariaLabel=${`${this.hass.localize(`ui.components.target-picker.type.${this._params.type}`)}: ${this._params.title}`}
wrap-focus
>
${waitingForSources
? nothing
: html`
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${deviceFilter}
.entityFilter=${entityFilter}
.includeDomains=${includeDomains}
.includeDeviceClasses=${includeDeviceClasses}
.primaryEntitiesOnly=${primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
`}
</ha-list-base>
</div>
</ha-adaptive-dialog>
<ha-target-picker-item-row
.hass=${this.hass}
.type=${this._params.type}
.itemId=${this._params.itemId}
.deviceFilter=${this._params.deviceFilter}
.entityFilter=${this._params.entityFilter}
.includeDomains=${this._params.includeDomains}
.includeDeviceClasses=${this._params.includeDeviceClasses}
.primaryEntitiesOnly=${this._params.primaryEntitiesOnly}
expand
></ha-target-picker-item-row>
</ha-dialog>
`;
}
static styles = css`
.type-wrapper {
display: flex;
flex-direction: column;
border-radius: var(--ha-border-radius-xl);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-normal);
overflow: hidden;
}
.type-label {
background-color: var(--ha-color-surface-low);
padding: var(--ha-space-1) var(--ha-space-3);
font-weight: var(--ha-font-weight-bold);
display: flex;
align-items: center;
height: 20px;
}
`;
}
declare global {
@@ -1,14 +1,14 @@
import { fireEvent } from "../../../common/dom/fire_event";
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
import type { TargetSelector } from "../../../data/selector";
import type { TargetType } from "../../../data/target";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
export type NewBackupType = "automatic" | "manual";
export interface TargetDetailsDialogParams {
title: string;
type: TargetType;
itemId: string;
selector?: TargetSelector;
deviceFilter?: HaDevicePickerDeviceFilterFunc;
entityFilter?: HaEntityPickerEntityFilterFunc;
includeDomains?: string[];
@@ -5,7 +5,7 @@ import type { TargetType, TargetTypeFloorless } from "../../data/target";
import type { HomeAssistant } from "../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import "../ha-expansion-panel";
import "../list/ha-list-base";
import "../ha-md-list";
import "./ha-target-picker-item-row";
@customElement("ha-target-picker-item-group")
@@ -66,25 +66,23 @@ export class HaTargetPickerItemGroup extends LitElement {
}
)}
</div>
<ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-list-base>
${Object.entries(this.items).map(([type, items]) =>
items
? items.map(
(item) =>
html`<ha-target-picker-item-row
.hass=${this.hass}
.type=${type as TargetTypeFloorless}
.itemId=${item}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDomains=${this.includeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.primaryEntitiesOnly=${this.primaryEntitiesOnly}
></ha-target-picker-item-row>`
)
: nothing
)}
</ha-expansion-panel>`;
}
@@ -98,7 +96,7 @@ export class HaTargetPickerItemGroup extends LitElement {
--expansion-panel-content-padding: 0;
}
ha-expansion-panel::part(summary) {
background-color: var(--ha-color-surface-low);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-2);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
@@ -106,6 +104,9 @@ export class HaTargetPickerItemGroup extends LitElement {
justify-content: space-between;
min-height: unset;
}
ha-md-list {
padding: 0;
}
`;
}
@@ -1,24 +1,14 @@
import { consume } from "@lit/context";
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiDevices,
mdiHome,
mdiLabel,
mdiMinusBox,
mdiTextureBox,
} from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import {
css,
html,
LitElement,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
@@ -48,17 +38,18 @@ import {
type ExtractFromTargetResultReferenced,
type TargetType,
} from "../../data/target";
import { showMoreInfoDialog } from "../../dialogs/more-info/show-ha-more-info-dialog";
import { buttonLinkStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-icon-button";
import "../ha-md-list";
import type { HaMdList } from "../ha-md-list";
import "../ha-md-list-item";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-state-icon";
import "../ha-svg-icon";
import "../item/ha-list-item-base";
import "../item/ha-list-item-button";
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
@customElement("ha-target-picker-item-row")
@@ -74,9 +65,6 @@ export class HaTargetPickerItemRow extends LitElement {
@property({ type: Boolean, attribute: "sub-entry", reflect: true })
public subEntry = false;
@property({ attribute: false })
public subLevel = 0;
@property({ type: Boolean, attribute: "hide-context" })
public hideContext = false;
@@ -118,6 +106,12 @@ export class HaTargetPickerItemRow extends LitElement {
@consume({ context: labelsContext, subscribe: true })
_labelRegistry!: LabelRegistryEntry[];
@query("ha-md-list-item") public item?: HaMdListItem;
@query("ha-md-list") public list?: HaMdList;
@query("ha-target-picker-item-row") public itemRow?: HaTargetPickerItemRow;
protected willUpdate(changedProps: PropertyValues<this>) {
if (!this.subEntry && changedProps.has("itemId")) {
this._updateItemData();
@@ -143,130 +137,101 @@ export class HaTargetPickerItemRow extends LitElement {
const replaceable = !this.subEntry && !this.expand;
const content = html`
<div class="icon" slot="start">
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
return html`
<ha-md-list-item
type=${replaceable ? "button" : "text"}
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable ? this._replaceItem : undefined}
>
<div class="icon" slot="start">
${this.subEntry
? html`
<div class="horizontal-line-wrapper">
<div class="horizontal-line"></div>
</div>
`
: nothing}
${iconPath
? html`<ha-icon .icon=${iconPath}></ha-icon>`
: this._iconImg
? html`<img
alt=${this._domainName || ""}
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${this._iconImg}
/>`
: fallbackIconPath
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: this.type === "entity"
? html`
<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject ||
({
entity_id: this.itemId,
attributes: {},
} as HassEntity)}
>
</ha-state-icon>
`
: nothing}
</div>
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${stateObject && this.subEntry
? html`<span slot="supporting-text" class="state"
>${this.hass.formatEntityState(stateObject)}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</button>`
: showEntities
? html`<span class="main">
<div slot="headline">${name}</div>
${notFound || (context && !this.hideContext)
? html`<span slot="supporting-text"
>${notFound
? this.hass.localize(
`ui.components.target-picker.${this.type}_not_found`
)
: context}</span
>`
: nothing}
${this._domainName && this.subEntry
? html`<span slot="supporting-text" class="domain"
>${this._domainName}</span
>`
: nothing}
${!this.subEntry && entries && showEntities
? html`
<div slot="end" class="summary">
${showEntities &&
!this.expand &&
entries?.referenced_entities.length
? html`<button class="main link" @click=${this._openDetails}>
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
</div>
`
: nothing}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: this.subEntry && this.type === "entity"
? html`
<ha-svg-icon
.path=${computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? mdiChevronLeft
: mdiChevronRight}
slot="end"
></ha-svg-icon>
</button>`
: showEntities
? html`<span class="main">
${this.hass.localize(
"ui.components.target-picker.entities_count",
{
count: entries?.referenced_entities.length,
}
)}
</span>`
: nothing}
</div>
`
: nothing}
`;
let item: TemplateResult;
if (replaceable || (this.subEntry && this.type === "entity")) {
item = html`
<ha-list-item-button
class=${classMap({
error: notFound,
replaceable,
})}
@click=${replaceable
? this._replaceItem
: this.subEntry && this.type === "entity"
? this._openMoreInfo
: undefined}
>
${content}
</ha-list-item-button>
`;
} else {
item = html`
<ha-list-item-base
class=${classMap({
error: notFound,
})}
>
${content}
</ha-list-item-base>
`;
}
return html`
${item}
${!this.expand && !this.subEntry
? html`
<ha-icon-button
.path=${mdiClose}
slot="end"
@click=${this._removeItem}
></ha-icon-button>
`
: nothing}
</ha-md-list-item>
${this.expand && entries && entries.referenced_entities
? this._renderEntries()
: nothing}
@@ -276,10 +241,6 @@ export class HaTargetPickerItemRow extends LitElement {
private _renderEntries() {
const entries = this.parentEntries || this._entries;
if (!entries || entries.referenced_entities.length === 0) {
return this._renderEmptyEntries();
}
let nextType: TargetType =
this.type === "floor"
? "area"
@@ -389,64 +350,54 @@ export class HaTargetPickerItemRow extends LitElement {
) || ([] as string[]),
}));
const nextSubLevel = this.subLevel + 1;
return html`
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.subLevel=${nextSubLevel}
style=${`--sub-entry-indent: calc(${nextSubLevel} * var(--ha-space-10));`}
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
<div class="entries-tree">
<div class="line-wrapper">
<div class="line"></div>
</div>
<ha-md-list class="entries">
${rows1.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
.type=${nextType}
.itemId=${itemId}
.parentEntries=${rows1Entries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${deviceRows.map(
(itemId, index) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="device"
.itemId=${itemId}
.parentEntries=${deviceRowsEntries?.[index]}
.hideContext=${this.hideContext || this.type !== "label"}
expand
></ha-target-picker-item-row>
`
)}
${entityRows.map(
(itemId) => html`
<ha-target-picker-item-row
sub-entry
.hass=${this.hass}
type="entity"
.itemId=${itemId}
.hideContext=${this.hideContext || this.type !== "label"}
></ha-target-picker-item-row>
`
)}
</ha-md-list>
</div>
`;
}
private _renderEmptyEntries() {
return html`<ha-list-item-base>
<ha-svg-icon .path=${mdiMinusBox} slot="start" class="icon"></ha-svg-icon>
<span slot="headline"
>${this.hass.localize("ui.components.target-picker.no_targets")}</span
>
</ha-list-item-base>`;
}
private async _updateItemData() {
if (this.type === "entity") {
this._entries = undefined;
@@ -615,14 +566,7 @@ export class HaTargetPickerItemRow extends LitElement {
const areaName = area ? computeAreaName(area) : undefined;
const context = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
.join(computeRTL(this.hass) ? " ◂ " : " ▸ ");
return {
name: entityName || deviceName || item,
context,
@@ -696,12 +640,6 @@ export class HaTargetPickerItemRow extends LitElement {
});
}
private _openMoreInfo = () => {
showMoreInfoDialog(this, {
entityId: this.itemId,
});
};
static styles = [
buttonLinkStyle,
css`
@@ -713,6 +651,12 @@ export class HaTargetPickerItemRow extends LitElement {
--md-list-item-two-line-container-height: 56px;
}
:host([expand]:not([sub-entry])) ha-md-list-item {
border: 2px solid var(--ha-color-border-neutral-loud);
background-color: var(--ha-color-fill-neutral-quiet-resting);
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
}
.error {
background: var(--ha-color-fill-warning-quiet-resting);
}
@@ -736,7 +680,6 @@ export class HaTargetPickerItemRow extends LitElement {
.icon {
width: 24px;
display: flex;
color: var(--ha-color-on-neutral-normal);
}
img {
@@ -754,21 +697,53 @@ export class HaTargetPickerItemRow extends LitElement {
line-height: var(--ha-line-height-condensed);
}
:host([sub-entry]) .summary {
margin-inline-start: var(--ha-space-12);
margin-right: var(--ha-space-12);
}
.summary .main {
font-weight: var(--ha-font-weight-medium);
}
:host([expand]) .summary .main {
color: var(--ha-color-text-secondary);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
}
.summary .secondary {
font-size: var(--ha-font-size-s);
color: var(--secondary-text-color);
}
.entries-tree {
display: flex;
position: relative;
}
.entries-tree .line-wrapper {
padding: var(--ha-space-5);
}
.entries-tree .line-wrapper .line {
border-left: 2px dashed var(--divider-color);
height: calc(100% - 28px);
position: absolute;
top: 0;
}
:host([sub-entry]) .entries-tree .line-wrapper .line {
height: calc(100% - 12px);
top: -18px;
}
.entries {
padding: 0;
--md-item-overflow: visible;
}
.horizontal-line-wrapper {
position: relative;
}
.horizontal-line-wrapper .horizontal-line {
position: absolute;
top: 11px;
margin-inline-start: -28px;
width: 29px;
border-top: 2px dashed var(--divider-color);
}
button.link {
text-decoration: none;
color: var(--primary-color);
@@ -779,19 +754,12 @@ export class HaTargetPickerItemRow extends LitElement {
text-decoration: underline;
}
.state {
.domain {
width: fit-content;
font-size: var(--ha-font-size-s);
color: var(--ha-color-text-secondary);
}
ha-list-item-button::part(end) {
gap: var(--ha-space-2);
}
:host([sub-entry]) ha-list-item-button::part(base),
:host([sub-entry]) ha-list-item-base::part(base) {
padding-inline-start: var(--sub-entry-indent);
border-radius: var(--ha-border-radius-md);
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1);
font-family: var(--ha-font-family-code);
}
`,
];
@@ -76,6 +76,7 @@ export class HaTargetPickerValueChip extends LitElement {
? html`<ha-svg-icon .path=${fallbackIconPath}></ha-svg-icon>`
: stateObject
? html`<ha-state-icon
.hass=${this.hass}
.stateObj=${stateObject}
></ha-state-icon>`
: nothing}
+1 -2
View File
@@ -99,8 +99,7 @@ export class HaTileContainer extends LitElement {
display: flex;
flex-direction: row;
align-items: center;
padding: 0 10px;
min-height: var(--row-height, 56px);
padding: 10px;
flex: 1;
min-width: 0;
box-sizing: border-box;
+13 -27
View File
@@ -3,6 +3,7 @@ import { dump } from "js-yaml";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import type { Trigger } from "../../data/automation";
import { migrateAutomationTrigger } from "../../data/automation";
@@ -22,10 +23,9 @@ import "../../panels/logbook/ha-logbook-renderer";
import type { HomeAssistant } from "../../types";
import "../ha-code-editor";
import "../ha-icon-button";
import "../ha-tab-group";
import "../ha-tab-group-tab";
import "./hat-logbook-note";
import type { NodeInfo } from "./hat-script-graph";
import { traceTabStyles } from "./trace-tab-styles";
const TRACE_PATH_TABS = [
"step_config",
@@ -66,21 +66,21 @@ export class HaTracePathDetails extends LitElement {
${this._renderSelectedTraceInfo()}
</div>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
<div class="tabs top">
${TRACE_PATH_TABS.map(
(view) => html`
<ha-tab-group-tab
slot="nav"
.active=${this._view === view}
.panel=${view}
<button
.view=${view}
class=${classMap({ active: this._view === view })}
@click=${this._showTab}
>
${this.hass!.localize(
`ui.panel.config.automation.trace.tabs.${view}`
)}
</ha-tab-group-tab>
</button>
`
)}
</ha-tab-group>
</div>
${this._view === "step_config"
? this._renderSelectedConfig()
: this._view === "changed_variables"
@@ -308,12 +308,7 @@ export class HaTracePathDetails extends LitElement {
? this.hass!.localize(
"ui.panel.config.automation.trace.path.no_variables_changed"
)
: html`<ha-code-editor
read-only
dir="ltr"
.hass=${this.hass}
.value=${dump(trace.changed_variables).trimEnd()}
></ha-code-editor>`}
: html`<pre>${dump(trace.changed_variables).trimEnd()}</pre>`}
`
)}
</div>
@@ -388,12 +383,13 @@ export class HaTracePathDetails extends LitElement {
</div>`;
}
private _handleTabChanged(ev: CustomEvent) {
this._view = ev.detail.name as typeof this._view;
private _showTab(ev) {
this._view = ev.target.view;
}
static get styles(): CSSResultGroup {
return [
traceTabStyles,
css`
.padded-box {
margin: 16px;
@@ -410,16 +406,6 @@ export class HaTracePathDetails extends LitElement {
.error {
color: var(--error-color);
}
ha-tab-group {
background-color: var(--primary-background-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
}
ha-tab-group-tab::part(base) {
padding: 2px 16px;
}
`,
];
}
+1 -5
View File
@@ -18,7 +18,6 @@ import { toggleAttribute } from "../../common/dom/toggle_attribute";
import { fullEntitiesContext } from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LogbookEntry } from "../../data/logbook";
import { localizeTriggerDescription } from "../../data/logbook";
import type {
ChooseAction,
IfAction,
@@ -333,10 +332,7 @@ class ActionRenderer {
: "other",
alias: triggerStep.changed_variables.trigger?.alias,
triggeredPath: triggerStep.path === "trigger" ? "manual" : "trigger",
trigger: localizeTriggerDescription(
this.hass.localize,
this.trace.trigger
),
trigger: this.trace.trigger,
time: formatDateTimeWithSeconds(
new Date(triggerStep.timestamp),
this.hass.locale,
+40
View File
@@ -0,0 +1,40 @@
import { css } from "lit";
export const traceTabStyles = css`
.tabs {
background-color: var(--primary-background-color);
border-top: 1px solid var(--divider-color);
border-bottom: 1px solid var(--divider-color);
display: flex;
padding-left: 4px;
padding-inline-start: 4px;
padding-inline-end: initial;
}
.tabs.top {
border-top: none;
}
.tabs > * {
padding: 2px 16px;
cursor: pointer;
position: relative;
bottom: -1px;
border: none;
border-bottom: 2px solid transparent;
user-select: none;
background: none;
color: var(--primary-text-color);
outline: none;
transition: background 15ms linear;
}
.tabs > *.active {
border-bottom-color: var(--primary-color);
}
.tabs > *:focus,
.tabs > *:hover {
background: var(--secondary-background-color);
}
`;
-14
View File
@@ -1,5 +1,4 @@
import type {
Connection,
HassEntityAttributeBase,
HassEntityBase,
HassServiceTarget,
@@ -585,19 +584,6 @@ export const testCondition = (
variables,
});
export const subscribeCondition = (
connection: Connection,
onChange: (result: {
result?: boolean;
error?: string | { code: string; message: string };
}) => void,
condition: Condition
) =>
connection.subscribeMessage(onChange, {
type: "subscribe_condition",
condition,
});
export interface AutomationClipboard {
trigger?: Trigger;
condition?: Condition;
-1
View File
@@ -164,7 +164,6 @@ export interface BatterySourceTypeEnergyPreference {
stat_energy_to: string;
stat_rate?: string; // always available if power_config is set
power_config?: PowerConfig;
stat_soc?: string;
}
export interface GasSourceTypeEnergyPreference {
type: "gas";
+1 -4
View File
@@ -96,10 +96,7 @@ export const getEntities = (
const domainName = domainToName(hass.localize, computeDomain(entityId));
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const isRTL = computeRTL(hass);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
+9 -26
View File
@@ -456,13 +456,11 @@ const getIconFromTranslations = (
};
export const entityIcon = async (
entities: HomeAssistant["entities"],
hassConfig: HomeAssistant["config"],
hassConnection: Connection,
hass: HomeAssistant,
stateObj: HassEntity,
state?: string
) => {
const entry = entities?.[stateObj.entity_id] as
const entry = hass.entities?.[stateObj.entity_id] as
| EntityRegistryDisplayEntry
| undefined;
if (entry?.icon) {
@@ -470,14 +468,7 @@ export const entityIcon = async (
}
const domain = computeStateDomain(stateObj);
return getEntityIcon(
hassConfig,
hassConnection,
domain,
stateObj,
state,
entry
);
return getEntityIcon(hass, domain, stateObj, state, entry);
};
export const entryIcon = async (
@@ -489,19 +480,11 @@ export const entryIcon = async (
}
const stateObj = hass.states[entry.entity_id] as HassEntity | undefined;
const domain = computeDomain(entry.entity_id);
return getEntityIcon(
hass.config,
hass.connection,
domain,
stateObj,
undefined,
entry
);
return getEntityIcon(hass, domain, stateObj, undefined, entry);
};
const getEntityIcon = async (
hassConfig: HomeAssistant["config"],
hassConnection: Connection,
hass: HomeAssistant,
domain: string,
stateObj?: HassEntity,
stateValue?: string,
@@ -515,8 +498,8 @@ const getEntityIcon = async (
let icon: string | undefined;
if (translation_key && platform) {
const platformIcons = await getPlatformIcons(
hassConfig,
hassConnection,
hass.config,
hass.connection,
platform
);
if (platformIcons) {
@@ -532,8 +515,8 @@ const getEntityIcon = async (
if (!icon) {
const entityComponentIcons = await getComponentIcons(
hassConnection,
hassConfig,
hass.connection,
hass.config,
domain
);
if (entityComponentIcons) {
-43
View File
@@ -195,49 +195,6 @@ export const localizeTriggerSource = (
return source;
};
// Mapping from a phrase key to the bare-phrase translation key (without the
// "triggered by" prefix), used by localizeTriggerDescription below.
const triggerDescriptionKeys: Record<
TriggerPhraseKeys,
| "numeric_state_of"
| "state_of"
| "event"
| "time"
| "time_pattern"
| "homeassistant_stopping"
| "homeassistant_starting"
> = {
triggered_by_numeric_state_of: "numeric_state_of",
triggered_by_state_of: "state_of",
triggered_by_event: "event",
triggered_by_time_pattern: "time_pattern",
triggered_by_time: "time",
triggered_by_homeassistant_stopping: "homeassistant_stopping",
triggered_by_homeassistant_starting: "homeassistant_starting",
};
// Like localizeTriggerSource, but returns just the bare localized trigger
// description (without the "triggered by" prefix). Used where the surrounding
// template already supplies its own "triggered by" wording.
export const localizeTriggerDescription = (
localize: LocalizeFunc,
source: string
) => {
for (const triggerPhraseKey of Object.keys(
triggerPhrases
) as TriggerPhraseKeys[]) {
const phrase = triggerPhrases[triggerPhraseKey];
if (source.startsWith(phrase)) {
const bareKey = triggerDescriptionKeys[triggerPhraseKey];
return source.replace(
phrase,
`${localize(`ui.components.logbook.${bareKey}`)}`
);
}
}
return source;
};
export const localizeStateMessage = (
hass: HomeAssistant,
localize: LocalizeFunc,
@@ -58,6 +58,7 @@ class DialogConfigEntrySystemOptions extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this.hass.localize(
"ui.dialogs.config_entry_system_options.title",
@@ -3,13 +3,10 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
@@ -43,33 +40,14 @@ interface FlowUpdateEvent {
stepPromise?: Promise<DataEntryFlowStep>;
}
interface FlowStepFooterStateChangedEvent {
loading?: boolean;
hasPendingUpdates?: boolean;
}
interface FormStepElement extends HTMLElement {
submit(): Promise<void>;
}
interface AbortStepElement extends HTMLElement {
close(): void;
}
interface CreateEntryStepElement extends HTMLElement {
finish(): Promise<void>;
}
declare global {
// for fire event
interface HASSDomEvents {
"flow-update": FlowUpdateEvent;
"flow-step-footer-state-changed": FlowStepFooterStateChangedEvent;
}
// for add event listener
interface HTMLElementEventMap {
"flow-update": HASSDomEvent<FlowUpdateEvent>;
"flow-step-footer-state-changed": HASSDomEvent<FlowStepFooterStateChangedEvent>;
}
}
@@ -95,16 +73,6 @@ class DataEntryFlowDialog extends LitElement {
@state() private _handler?: string;
@state() private _formStepLoading = false;
@state() private _createEntryHasPendingUpdates = false;
private _formStepRef = createRef<FormStepElement>();
private _abortStepRef = createRef<AbortStepElement>();
private _createEntryStepRef = createRef<CreateEntryStepElement>();
private _unsubDataEntryFlowProgress?: UnsubscribeFunc;
public async showDialog(params: DataEntryFlowDialogParams): Promise<void> {
@@ -333,6 +301,7 @@ class DataEntryFlowDialog extends LitElement {
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
prevent-scrim-close
@after-show=${this._focusFormStep}
@@ -397,14 +366,11 @@ class DataEntryFlowDialog extends LitElement {
${this._step.type === "form"
? html`
<step-flow-form
${ref(this._formStepRef)}
autofocus
narrow
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
></step-flow-form>
`
: this._step.type === "external"
@@ -418,7 +384,6 @@ class DataEntryFlowDialog extends LitElement {
: this._step.type === "abort"
? html`
<step-flow-abort
${ref(this._abortStepRef)}
.params=${this._params}
.step=${this._step}
.hass=${this.hass}
@@ -446,14 +411,11 @@ class DataEntryFlowDialog extends LitElement {
`
: html`
<step-flow-create-entry
${ref(this._createEntryStepRef)}
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult ?? false}
@flow-step-footer-state-changed=${this
._handleFooterStateChanged}
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
@@ -464,95 +426,10 @@ class DataEntryFlowDialog extends LitElement {
`}
`}
</div>
${this._renderFooter()}
</ha-dialog>
`;
}
private _renderFooter() {
if (!this._step || this._loading) {
return nothing;
}
switch (this._step.type) {
case "form":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
.loading=${this._formStepLoading}
@click=${this._submitFormStep}
>
${this._params!.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this._step
)}
</ha-button>
</ha-dialog-footer>
`;
case "abort":
return this._step.reason === "missing_credentials"
? nothing
: html`
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this._closeAbortStep}
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}
</ha-button>
</ha-dialog-footer>
`;
case "external":
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
href=${this._step.url}
target="_blank"
rel="noreferrer"
>
${this.hass.localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</ha-dialog-footer>
`;
case "create_entry": {
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
this._params!.carryOverDevices
);
return html`
<ha-dialog-footer slot="footer">
<ha-button
slot="primaryAction"
@click=${this._finishCreateEntryStep}
>
${this.hass.localize(
`ui.panel.config.integrations.config_flow.${
!devices.length ||
this._createEntryHasPendingUpdates ||
devices.some((device) => device.area_id)
? "finish"
: "finish_skip"
}`
)}
</ha-button>
</ha-dialog-footer>
`;
}
default:
return nothing;
}
}
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
this.addEventListener("flow-update", (ev) => {
@@ -602,8 +479,6 @@ class DataEntryFlowDialog extends LitElement {
}
this._step = undefined;
this._formStepLoading = false;
this._createEntryHasPendingUpdates = false;
await this.updateComplete;
this._step = _step;
if (
@@ -687,36 +562,20 @@ class DataEntryFlowDialog extends LitElement {
}
await this.updateComplete;
this._formStepRef.value?.focus();
};
private _handleFooterStateChanged = (
ev: HASSDomEvent<FlowStepFooterStateChangedEvent>
) => {
if (ev.detail.loading !== undefined) {
this._formStepLoading = ev.detail.loading;
}
if (ev.detail.hasPendingUpdates !== undefined) {
this._createEntryHasPendingUpdates = ev.detail.hasPendingUpdates;
}
};
private _submitFormStep = () => {
this._formStepRef.value?.submit();
};
private _closeAbortStep = () => {
this._abortStepRef.value?.close();
};
private _finishCreateEntryStep = () => {
this._createEntryStepRef.value?.finish();
(
this.renderRoot.querySelector(
"step-flow-form[autofocus]"
) as HTMLElement | null
)?.focus();
};
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--dialog-content-padding: 0;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;
@@ -18,7 +18,7 @@ import "../../../components/ha-slider";
import "../../../components/ha-time-input";
import "../../../components/input/ha-input";
import { isTiltOnly } from "../../../data/cover";
import { isUnavailableState, UNAVAILABLE } from "../../../data/entity/entity";
import { isUnavailableState } from "../../../data/entity/entity";
import type { ImageEntity } from "../../../data/image";
import { computeImageUrl } from "../../../data/image";
import "../../../panels/lovelace/components/hui-timestamp-display";
@@ -266,7 +266,7 @@ class EntityPreviewRow extends LitElement {
<div class="numberflex">
<ha-slider
labeled
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${isUnavailableState(stateObj.state)}
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
.max=${Number(stateObj.attributes.max)}
@@ -280,7 +280,7 @@ class EntityPreviewRow extends LitElement {
: html`<div class="numberflex numberstate">
<ha-input
auto-validate
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${isUnavailableState(stateObj.state)}
pattern="[0-9]+([\\.][0-9]+)?"
.step=${Number(stateObj.attributes.step)}
.min=${Number(stateObj.attributes.min)}
@@ -303,7 +303,7 @@ class EntityPreviewRow extends LitElement {
<ha-select
.label=${computeStateName(stateObj)}
.value=${stateObj.state}
.disabled=${stateObj.state === UNAVAILABLE}
.disabled=${isUnavailableState(stateObj.state)}
.options=${stateObj.attributes.options?.map((option) => ({
value: option,
label: this.hass!.formatEntityState(stateObj, option),
+8 -4
View File
@@ -8,6 +8,7 @@ import type { HomeAssistant } from "../../types";
import { showConfigFlowDialog } from "./show-dialog-config-flow";
import type { DataEntryFlowDialogParams } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-abort")
class StepFlowAbort extends LitElement {
@@ -36,6 +37,13 @@ class StepFlowAbort extends LitElement {
<div class="content">
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>
<div class="buttons">
<ha-button appearance="plain" @click=${this._flowDone}
>${this.hass.localize(
"ui.panel.config.integrations.config_flow.close"
)}</ha-button
>
</div>
`;
}
@@ -60,10 +68,6 @@ class StepFlowAbort extends LitElement {
fireEvent(this, "flow-update", { step: undefined });
}
public close(): void {
this._flowDone();
}
static get styles(): CSSResultGroup {
return configFlowContentStyles;
}
@@ -10,6 +10,7 @@ import {
import { computeDomain } from "../../common/entity/compute_domain";
import { navigate } from "../../common/navigate";
import "../../components/ha-area-picker";
import "../../components/ha-button";
import "../../components/input/ha-input";
import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
@@ -30,10 +31,6 @@ import { showVoiceAssistantSetupDialog } from "../voice-assistant-setup/show-voi
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
interface DeviceTarget {
device: string;
}
@customElement("step-flow-create-entry")
class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public flowConfig!: FlowConfig;
@@ -195,18 +192,20 @@ class StepFlowCreateEntry extends LitElement {
</div>
`}
</div>
<div class="buttons">
<ha-button @click=${this._flowDone}
>${localize(
`ui.panel.config.integrations.config_flow.${
!this.devices.length || Object.keys(this._deviceUpdate).length
? "finish"
: "finish_skip"
}`
)}</ha-button
>
</div>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_deviceUpdate")) {
fireEvent(this, "flow-step-footer-state-changed", {
hasPendingUpdates: Object.keys(this._deviceUpdate).length > 0,
});
}
}
private async _loadDomains() {
const entries = await getConfigEntries(this.hass);
this._domains = Object.fromEntries(
@@ -225,20 +224,18 @@ class StepFlowCreateEntry extends LitElement {
return updateDeviceRegistryEntry(this.hass, deviceId, {
name_by_user: update.name,
area_id: update.area,
}).catch((err: unknown) => {
const message =
err instanceof Error ? err.message : "Unknown error";
}).catch((err: any) => {
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_device",
{ error: message }
{ error: err.message }
),
});
});
}
);
await Promise.allSettled(deviceUpdates);
const entityUpdates: Promise<unknown>[] = [];
const entityUpdates: Promise<any>[] = [];
const entityIds: string[] = [];
renamedDevices.forEach((deviceId) => {
const entities = this._deviceEntities(
@@ -284,15 +281,8 @@ class StepFlowCreateEntry extends LitElement {
}
}
public finish(): Promise<void> {
return this._flowDone();
}
private async _areaPicked(ev: ValueChangedEvent<string>) {
const picker = ev.currentTarget as DeviceTarget | null;
if (!picker) {
return;
}
const picker = ev.currentTarget as any;
const device = picker.device;
const area = ev.detail.value;
@@ -304,11 +294,8 @@ class StepFlowCreateEntry extends LitElement {
}
private _deviceNameChanged(ev: InputEvent): void {
const picker = ev.currentTarget as (HaInput & DeviceTarget) | null;
if (!picker) {
return;
}
const device = picker.device;
const picker = ev.currentTarget as HaInput;
const device = (picker as any).device;
const name = picker.value;
if (!(device in this._deviceUpdate)) {
@@ -324,13 +311,22 @@ class StepFlowCreateEntry extends LitElement {
css`
.devices {
display: flex;
gap: var(--ha-space-2);
margin: -4px;
max-height: 600px;
overflow-y: auto;
flex-direction: column;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.devices {
/* header - margin content - footer */
max-height: calc(100vh - 52px - 20px - 52px);
}
}
.device {
border: 1px solid var(--divider-color);
padding: 6px;
border-radius: var(--ha-border-radius-sm);
margin: 4px;
display: inline-block;
}
.device-info {
@@ -356,6 +352,11 @@ class StepFlowCreateEntry extends LitElement {
ha-input {
margin: var(--ha-space-2) 0;
}
.buttons > *:last-child {
margin-left: auto;
margin-inline-start: auto;
margin-inline-end: initial;
}
.error {
color: var(--error-color);
}
+23 -2
View File
@@ -1,10 +1,11 @@
import type { CSSResultGroup, TemplateResult, PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { DataEntryFlowStepExternal } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import "../../components/ha-button";
@customElement("step-flow-external")
class StepFlowExternal extends LitElement {
@@ -15,9 +16,18 @@ class StepFlowExternal extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepExternal;
protected render(): TemplateResult {
const localize = this.hass.localize;
return html`
<div class="content">
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
<div class="open-button">
<ha-button href=${this.step.url} target="_blank" rel="noreferrer">
${localize(
"ui.panel.config.integrations.config_flow.external_step.open_site"
)}
</ha-button>
</div>
</div>
`;
}
@@ -28,7 +38,18 @@ class StepFlowExternal extends LitElement {
}
static get styles(): CSSResultGroup {
return [configFlowContentStyles];
return [
configFlowContentStyles,
css`
.open-button {
text-align: center;
padding: 24px 0;
}
.open-button a {
text-decoration: none;
}
`,
];
}
}
+37 -47
View File
@@ -1,11 +1,11 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-button";
import "../../components/ha-alert";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
import "../../components/ha-form/ha-form";
@@ -19,7 +19,7 @@ import { autocompleteLoginFields } from "../../data/auth";
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
import { previewModule } from "../../data/preview";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
@@ -47,8 +47,6 @@ class StepFlowForm extends LitElement {
private _errors?: Record<string, string>;
private _formRef = createRef<HTMLElementTagNameMap["ha-form"]>();
static shadowRootOptions: ShadowRootInit = {
...LitElement.shadowRootOptions,
delegatesFocus: true,
@@ -90,27 +88,24 @@ class StepFlowForm extends LitElement {
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
${this._errorMsg
? html`<ha-alert alert-type="error">${this._errorMsg}</ha-alert>`
: nothing}
${step.data_schema.length
? html`<ha-form
${ref(this._formRef)}
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>`
: nothing}
: ""}
<ha-form
?autofocus=${this.autoFocus}
.hass=${this.hass}
.narrow=${this.narrow}
.data=${stepData}
.disabled=${this._loading}
@value-changed=${this._stepDataChanged}
.schema=${autocompleteLoginFields(
this.handleReadOnlyFields(step.data_schema)
)}
.error=${this._errors}
.computeLabel=${this._labelCallback}
.computeHelper=${this._helperCallback}
.computeError=${this._errorCallback}
.localizeValue=${this._localizeValueCallback}
.context=${{ handler: step.handler }}
></ha-form>
</div>
${step.preview
? html`<div class="preview" @set-flow-errors=${this._setError}>
@@ -130,6 +125,14 @@ class StepFlowForm extends LitElement {
})}
</div>`
: nothing}
<div class="buttons">
<ha-button @click=${this._submitStep} .loading=${this._loading}>
${this.flowConfig.renderShowFormStepSubmitButton(
this.hass,
this.step
)}
</ha-button>
</div>
`;
}
@@ -142,17 +145,8 @@ class StepFlowForm extends LitElement {
this.addEventListener("keydown", this._handleKeyDown);
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (changedProps.has("_loading")) {
fireEvent(this, "flow-step-footer-state-changed", {
loading: this._loading,
});
}
}
public override focus(_options?: FocusOptions): void {
this._formRef.value?.focus();
this.renderRoot.querySelector("ha-form")?.focus();
}
protected willUpdate(changedProps: PropertyValues): void {
@@ -235,15 +229,13 @@ class StepFlowForm extends LitElement {
const flowId = this.step.flow_id;
const toSendData: Record<string, unknown> = {};
const toSendData = {};
Object.keys(stepData).forEach((key) => {
const value = stepData[key];
const isEmpty = [undefined, ""].includes(value);
const field = this.step.data_schema?.find((f) => f.name === key);
const selector = (field as HaFormSelector)?.selector ?? {};
const read_only = (
Object.values(selector)[0] as { read_only?: boolean } | null | undefined
)?.read_only;
const read_only = (Object.values(selector)[0] as any)?.read_only;
if (!isEmpty && !read_only) {
toSendData[key] = value;
}
@@ -285,13 +277,7 @@ class StepFlowForm extends LitElement {
}
}
public submit(): Promise<void> {
return this._submitStep();
}
private _stepDataChanged(
ev: ValueChangedEvent<Record<string, unknown>>
): void {
private _stepDataChanged(ev: CustomEvent): void {
this._stepData = ev.detail.value;
}
@@ -335,9 +321,13 @@ class StepFlowForm extends LitElement {
ha-alert,
ha-form {
margin-top: var(--ha-space-6);
margin-top: 24px;
display: block;
}
.buttons {
padding: 16px;
}
`,
];
}
+3 -3
View File
@@ -1,5 +1,5 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-spinner";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
@@ -28,7 +28,7 @@ class StepFlowLoading extends LitElement {
return html`
<div class="content">
<ha-spinner size="large"></ha-spinner>
${description ? html`<div>${description}</div>` : nothing}
${description ? html`<div>${description}</div>` : ""}
</div>
`;
}
@@ -40,7 +40,7 @@ class StepFlowLoading extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: var(--ha-space-4);
margin-bottom: 16px;
}
`;
}
+6 -6
View File
@@ -78,7 +78,7 @@ class StepFlowMenu extends LitElement {
);
return html`
${description ? html`<div class="content">${description}</div>` : nothing}
${description ? html`<div class="content">${description}</div>` : ""}
<div class="options">
${options.map(
(option) => html`
@@ -119,17 +119,17 @@ class StepFlowMenu extends LitElement {
configFlowContentStyles,
css`
.options {
margin-top: var(--ha-space-5);
margin-bottom: var(--ha-space-4);
margin-top: 20px;
margin-bottom: 16px;
}
.content {
padding-bottom: var(--ha-space-4);
padding-bottom: 16px;
}
.content + .options {
margin-top: var(--ha-space-2);
margin-top: 8px;
}
ha-list-item {
--mdc-list-side-padding: var(--ha-space-6);
--mdc-list-side-padding: 24px;
}
`,
];
@@ -53,7 +53,7 @@ class StepFlowProgress extends LitElement {
text-align: center;
}
ha-spinner {
margin-bottom: var(--ha-space-4);
margin-bottom: 16px;
}
`,
];
+17 -3
View File
@@ -2,9 +2,12 @@ import { css } from "lit";
export const configFlowContentStyles = css`
h2 {
margin: var(--ha-space-6) var(--ha-space-10) 0 0;
margin: 24px 38px 0 0;
margin-inline-start: 0px;
margin-inline-end: var(--ha-space-10);
margin-inline-end: 38px;
padding: 0 24px;
padding-inline-start: 24px;
padding-inline-end: 24px;
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-family: var(
@@ -25,8 +28,19 @@ export const configFlowContentStyles = css`
.content,
.preview {
margin-top: var(--ha-space-5);
margin-top: 20px;
padding: 0 24px;
}
.buttons {
position: relative;
padding: 16px;
margin: 8px 0 0;
color: var(--primary-color);
display: flex;
justify-content: flex-end;
}
ha-markdown {
overflow-wrap: break-word;
}
@@ -103,6 +103,7 @@ export class ListItemsDialog
return html`
<ha-dialog
.hass=${this.hass}
.open=${this._open}
header-title=${this._params.title ?? " "}
@closed=${this._dialogClosed}

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