Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d355d9e089 | |||
| ce5a135bd2 | |||
| 8905123249 | |||
| 92e6f69fc7 | |||
| 3fbc11458a | |||
| 8b419646d1 | |||
| 5ca28bdc85 | |||
| efe41b1e1a | |||
| d16aa48f65 | |||
| 99c7a427f1 | |||
| 0ccc582aff | |||
| e32dd78f51 | |||
| c3358e0825 | |||
| 764961b28e | |||
| 897a33963e | |||
| 6eadf2ff15 | |||
| d32f5b6a50 | |||
| a21cf5d995 | |||
| 9aa6cd4154 | |||
| 2024ce0aef | |||
| 908a518f18 |
@@ -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
|
||||
|
||||
@@ -41,14 +41,14 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -10,6 +10,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Apply labels
|
||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -13,11 +13,13 @@ jobs:
|
||||
lock:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@89ae32b08ed1a541efecbab17912962a5e38981c # v6.0.2
|
||||
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
process-only: "issues, prs"
|
||||
issue-inactive-days: "30"
|
||||
issue-lock-inactive-days: "30"
|
||||
issue-exclude-created-before: "2020-10-01T00:00:00Z"
|
||||
issue-lock-reason: ""
|
||||
pr-inactive-days: "1"
|
||||
pr-lock-inactive-days: "1"
|
||||
pr-exclude-created-before: "2020-11-01T00:00:00Z"
|
||||
pr-lock-reason: ""
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_modern }}
|
||||
token: ${{ github.token }}
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@fcf45416581928e8dd62eded78ce98c78e5149f8 # v3.2.3
|
||||
uses: relative-ci/agent-action@3c681926017930047fc03acaa35cd6a44efcbfc3 # v3.2.2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY_frontend_legacy }}
|
||||
token: ${{ github.token }}
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
pull-requests: read
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: release-drafter/release-drafter@c2e2804cc59f45f57076a99af580d0fedb697927 # v7.3.0
|
||||
- uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@868e6cb4607727d764341a158d98872cd63fa658 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@f6f29a7ee3fa0eccadf3620a7b9ee00ab54ec03b # master
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 90 days stale policy
|
||||
uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
days-before-stale: 90
|
||||
|
||||
@@ -54,7 +54,14 @@ src/cast/dev_const.ts
|
||||
# test coverage
|
||||
test/coverage/
|
||||
|
||||
# Playwright e2e output
|
||||
test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
compressionLevel: mixed
|
||||
|
||||
npmMinimalAgeGate: "3d"
|
||||
|
||||
defaultSemverRangePrefix: ""
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.15.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.14.1.cjs
|
||||
|
||||
@@ -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
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
);
|
||||
@@ -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
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
@@ -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(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
}));
|
||||
};
|
||||
@@ -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];
|
||||
},
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -228,6 +228,12 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "@material/mwc-drawer";
|
||||
import "@material/mwc-top-app-bar-fixed";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../../src/components/ha-icon-button";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import "@material/mwc-drawer";
|
||||
import "@material/mwc-top-app-bar-fixed";
|
||||
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html } from "lit";
|
||||
@@ -5,12 +7,9 @@ import { customElement, query, state } from "lit/decorators";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/ha-drawer";
|
||||
import type { HaDrawer } from "../../src/components/ha-drawer";
|
||||
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
|
||||
import "../../src/components/ha-icon-button";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/ha-top-app-bar-fixed";
|
||||
import "../../src/managers/notification-manager";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
import { PAGES, SIDEBAR } from "../build/import-pages";
|
||||
@@ -40,8 +39,8 @@ class HaGallery extends LitElement {
|
||||
@query("notification-manager")
|
||||
private _notifications!: HTMLElementTagNameMap["notification-manager"];
|
||||
|
||||
@query("ha-drawer")
|
||||
private _drawer!: HaDrawer;
|
||||
@query("mwc-drawer")
|
||||
private _drawer!: HTMLElementTagNameMap["mwc-drawer"];
|
||||
|
||||
private _narrow = window.matchMedia("(max-width: 600px)").matches;
|
||||
|
||||
@@ -76,15 +75,16 @@ class HaGallery extends LitElement {
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-drawer
|
||||
.direction=${this._rtl ? "rtl" : "ltr"}
|
||||
<mwc-drawer
|
||||
hasHeader
|
||||
.open=${!this._narrow}
|
||||
.type=${this._narrow ? "modal" : "dismissible"}
|
||||
>
|
||||
<div class="drawer-title">Home Assistant Design</div>
|
||||
<span slot="title">Home Assistant Design</span>
|
||||
<!-- <span slot="subtitle">subtitle</span> -->
|
||||
<div class="sidebar">${sidebar}</div>
|
||||
<div slot="appContent" class="app-content">
|
||||
<ha-top-app-bar-fixed>
|
||||
<div slot="appContent">
|
||||
<mwc-top-app-bar-fixed>
|
||||
<ha-icon-button
|
||||
slot="navigationIcon"
|
||||
@click=${this._menuTapped}
|
||||
@@ -94,7 +94,7 @@ class HaGallery extends LitElement {
|
||||
<div slot="title">
|
||||
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
|
||||
</div>
|
||||
</ha-top-app-bar-fixed>
|
||||
</mwc-top-app-bar-fixed>
|
||||
<div class="content">
|
||||
${PAGES[this._page].description
|
||||
? html`
|
||||
@@ -144,7 +144,7 @@ class HaGallery extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ha-drawer>
|
||||
</mwc-drawer>
|
||||
<notification-manager
|
||||
.hass=${FAKE_HASS}
|
||||
id="notifications"
|
||||
@@ -226,28 +226,12 @@ class HaGallery extends LitElement {
|
||||
-ms-user-select: initial;
|
||||
-webkit-user-select: initial;
|
||||
-moz-user-select: initial;
|
||||
--ha-sidebar-width: 256px;
|
||||
--header-height: 64px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
box-sizing: border-box;
|
||||
max-height: calc(100vh - var(--header-height));
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
color: var(--primary-text-color);
|
||||
display: flex;
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
min-height: var(--header-height);
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sidebar a {
|
||||
color: var(--primary-text-color);
|
||||
display: block;
|
||||
@@ -271,17 +255,13 @@ class HaGallery extends LitElement {
|
||||
opacity: 0.12;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
div[slot="appContent"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
background: var(--primary-background-color);
|
||||
}
|
||||
|
||||
ha-drawer[type="dismissible"][open] ha-top-app-bar-fixed {
|
||||
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { provide } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../../../../src/components/ha-tip";
|
||||
import { internationalizationContext } from "../../../../src/data/context";
|
||||
import type { HomeAssistantInternationalization } from "../../../../src/types";
|
||||
import "../../../../src/components/ha-card";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
|
||||
const tips: (string | TemplateResult)[] = [
|
||||
"Test tip",
|
||||
@@ -16,25 +14,16 @@ const tips: (string | TemplateResult)[] = [
|
||||
|
||||
@customElement("demo-components-ha-tip")
|
||||
export class DemoHaTip extends LitElement {
|
||||
@provide({ context: internationalizationContext })
|
||||
@state()
|
||||
protected _i18n: HomeAssistantInternationalization = {
|
||||
localize: ((key: string) => key) as any,
|
||||
language: "en",
|
||||
selectedLanguage: null,
|
||||
locale: {} as any,
|
||||
translationMetadata: {} as any,
|
||||
loadBackendTranslation: (async () => (key: string) => key) as any,
|
||||
loadFragmentTranslation: (async () => (key: string) => key) as any,
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html` ${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-tip ${mode} demo">
|
||||
<div class="card-content">
|
||||
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
|
||||
${tips.map(
|
||||
(tip) =>
|
||||
html`<ha-tip .hass=${provideHass(this)}>${tip}</ha-tip>`
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
[build.environment]
|
||||
YARN_VERSION = "1.22.11"
|
||||
NODE_OPTIONS = "--max_old_space_size=6144"
|
||||
|
||||
@@ -14,95 +14,109 @@
|
||||
"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",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@babel/runtime": "7.29.2",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.2",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.6",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.43.0",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@codemirror/view": "6.41.1",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.4.7",
|
||||
"@formatjs/intl-displaynames": "7.3.9",
|
||||
"@formatjs/intl-durationformat": "0.10.13",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.9",
|
||||
"@formatjs/intl-listformat": "8.3.9",
|
||||
"@formatjs/intl-locale": "5.3.8",
|
||||
"@formatjs/intl-numberformat": "9.3.10",
|
||||
"@formatjs/intl-pluralrules": "6.3.9",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.9",
|
||||
"@formatjs/intl-datetimeformat": "7.4.1",
|
||||
"@formatjs/intl-displaynames": "7.3.4",
|
||||
"@formatjs/intl-durationformat": "0.10.7",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.5",
|
||||
"@formatjs/intl-listformat": "8.3.4",
|
||||
"@formatjs/intl-locale": "5.3.4",
|
||||
"@formatjs/intl-numberformat": "9.3.4",
|
||||
"@formatjs/intl-pluralrules": "6.3.4",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.4",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/luxon3": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.0",
|
||||
"@home-assistant/webawesome": "3.3.1-ha.3",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
"@lit-labs/virtualizer": "2.1.1",
|
||||
"@lit/context": "1.1.6",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/mwc-base": "0.27.0",
|
||||
"@material/mwc-drawer": "0.27.0",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/mwc-top-app-bar": "0.27.0",
|
||||
"@material/mwc-top-app-bar-fixed": "0.27.0",
|
||||
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/web": "2.4.1",
|
||||
"@mdi/js": "7.4.47",
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@swc/helpers": "0.5.21",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.0.5",
|
||||
"@tsparticles/preset-links": "4.0.5",
|
||||
"@tsparticles/engine": "3.9.1",
|
||||
"@tsparticles/preset-links": "3.2.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.1.3",
|
||||
"barcode-detector": "3.1.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.49.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"culori": "4.0.2",
|
||||
"date-fns": "4.3.0",
|
||||
"date-fns": "4.1.0",
|
||||
"deep-clone-simple": "1.1.1",
|
||||
"deep-freeze": "0.0.1",
|
||||
"dialog-polyfill": "0.5.6",
|
||||
"echarts": "6.1.0",
|
||||
"echarts": "6.0.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.3.0",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.4",
|
||||
"intl-messageformat": "11.2.7",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.4",
|
||||
"marked": "18.0.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -114,31 +128,33 @@
|
||||
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "4.0.0",
|
||||
"tinykeys": "3.0.0",
|
||||
"weekstart": "2.0.0",
|
||||
"workbox-cacheable-response": "7.4.1",
|
||||
"workbox-core": "7.4.1",
|
||||
"workbox-expiration": "7.4.1",
|
||||
"workbox-precaching": "7.4.1",
|
||||
"workbox-routing": "7.4.1",
|
||||
"workbox-strategies": "7.4.1",
|
||||
"workbox-cacheable-response": "7.4.0",
|
||||
"workbox-core": "7.4.0",
|
||||
"workbox-expiration": "7.4.0",
|
||||
"workbox-precaching": "7.4.0",
|
||||
"workbox-routing": "7.4.0",
|
||||
"workbox-strategies": "7.4.0",
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.29.7",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.7",
|
||||
"@babel/preset-env": "7.29.7",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.2",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.61.0",
|
||||
"@lokalise/node-api": "16.0.0",
|
||||
"@html-eslint/eslint-plugin": "0.60.0",
|
||||
"@lokalise/node-api": "15.7.1",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.12",
|
||||
"@rspack/core": "2.0.5",
|
||||
"@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",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
@@ -150,25 +166,27 @@
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.1",
|
||||
"@types/mocha": "10.0.10",
|
||||
"@types/qrcode": "1.5.6",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@types/tar": "7.0.87",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"babel-loader": "10.1.1",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"browserstack-node-sdk": "1.53.2",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.4.0",
|
||||
"eslint": "10.3.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
"eslint-plugin-lit": "2.3.1",
|
||||
"eslint-plugin-lit": "2.2.1",
|
||||
"eslint-plugin-lit-a11y": "5.1.1",
|
||||
"eslint-plugin-unused-imports": "4.4.1",
|
||||
"eslint-plugin-wc": "3.1.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.5",
|
||||
"generate-license-file": "4.2.1",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.6.0",
|
||||
"gulp": "5.0.1",
|
||||
@@ -179,8 +197,7 @@
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lint-staged": "17.0.5",
|
||||
"lint-staged": "16.4.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -189,21 +206,21 @@
|
||||
"prettier": "3.8.3",
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "22.0.0",
|
||||
"tar": "7.5.15",
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"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.60.0",
|
||||
"typescript-eslint": "8.59.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.7",
|
||||
"vitest": "4.1.5",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
|
||||
},
|
||||
"resolutions": {
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
@@ -212,8 +229,8 @@
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.15.0",
|
||||
"packageManager": "yarn@4.14.1",
|
||||
"volta": {
|
||||
"node": "24.16.0"
|
||||
"node": "24.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260527.0"
|
||||
version = "20260429.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -18,46 +18,7 @@
|
||||
"enabled": true,
|
||||
"schedule": ["on the 19th day of the month before 4am"]
|
||||
},
|
||||
"customDatasources": {
|
||||
"ha-core-python": {
|
||||
"defaultRegistryUrlTemplate": "https://raw.githubusercontent.com/home-assistant/core/dev/.python-version",
|
||||
"format": "plain"
|
||||
}
|
||||
},
|
||||
"customManagers": [
|
||||
{
|
||||
"description": "Keep PYTHON_VERSION in sync with home-assistant/core (patch + minor)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": ["/^\\.github/workflows/[^/]+\\.ya?ml$/"],
|
||||
"matchStrings": ["PYTHON_VERSION: \"(?<currentValue>[^\"]+)\""],
|
||||
"depNameTemplate": "python",
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python"
|
||||
},
|
||||
{
|
||||
"description": "Keep devcontainer image and requires-python in sync with home-assistant/core (minor only)",
|
||||
"customType": "regex",
|
||||
"managerFilePatterns": [
|
||||
"/^\\.devcontainer/Dockerfile$/",
|
||||
"/^pyproject\\.toml$/"
|
||||
],
|
||||
"matchStrings": [
|
||||
"devcontainers/python:(?<currentValue>[\\d.]+)",
|
||||
"requires-python = \">=(?<currentValue>[^\"]+)\""
|
||||
],
|
||||
"depNameTemplate": "python",
|
||||
"datasourceTemplate": "custom.ha-core-python",
|
||||
"versioningTemplate": "python",
|
||||
"extractVersionTemplate": "^(?<version>\\d+\\.\\d+)"
|
||||
}
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "Group all Python version updates from home-assistant/core",
|
||||
"matchDepNames": ["python"],
|
||||
"matchDatasources": ["custom.ha-core-python"],
|
||||
"groupName": "Python version"
|
||||
},
|
||||
{
|
||||
"description": "MDC packages are pinned to the same version as MWC",
|
||||
"extends": ["monorepo:material-components-web"],
|
||||
|
||||
@@ -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,8 +54,6 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
@query("ha-auth-form") private _form?: HaAuthForm;
|
||||
|
||||
@query("ha-form") private _haForm?: HTMLElement;
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
}
|
||||
@@ -162,8 +160,9 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
// 100ms to give all the form elements time to initialize.
|
||||
setTimeout(() => {
|
||||
if (this._haForm) {
|
||||
(this._haForm as any).focus();
|
||||
const form = this.renderRoot.querySelector("ha-form");
|
||||
if (form) {
|
||||
(form as any).focus();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @param arr - The array to get combinations of
|
||||
* @returns A multidimensional array of all possible combinations
|
||||
*/
|
||||
export function getAllCombinations<T>(arr: readonly T[]): T[][] {
|
||||
export function getAllCombinations<T>(arr: T[]) {
|
||||
return arr.reduce<T[][]>(
|
||||
(combinations, element) =>
|
||||
combinations.concat(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { isComponentLoaded } from "./is_component_loaded";
|
||||
|
||||
export const canShowPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
(isCore(page) || isLoadedIntegration(hass, page)) &&
|
||||
!hideAdvancedPage(hass, page) &&
|
||||
isNotLoadedIntegration(hass, page);
|
||||
|
||||
export const isLoadedIntegration = (
|
||||
@@ -26,3 +27,8 @@ export const isNotLoadedIntegration = (
|
||||
);
|
||||
|
||||
export const isCore = (page: PageNavigation) => page.core;
|
||||
export const isAdvancedPage = (page: PageNavigation) => page.advancedOnly;
|
||||
export const userWantsAdvanced = (hass: HomeAssistant) =>
|
||||
hass.userData?.showAdvanced;
|
||||
export const hideAdvancedPage = (hass: HomeAssistant, page: PageNavigation) =>
|
||||
isAdvancedPage(page) && !userWantsAdvanced(hass);
|
||||
|
||||
@@ -114,15 +114,6 @@ export const DOMAINS_WITH_DYNAMIC_PICTURE = new Set([
|
||||
export const UNIT_C = "°C";
|
||||
export const UNIT_F = "°F";
|
||||
|
||||
/** Length units. */
|
||||
export const UNIT_IN = "in";
|
||||
export const UNIT_KM = "km";
|
||||
export const UNIT_MM = "mm";
|
||||
|
||||
/** Pressure units. */
|
||||
export const UNIT_HPA = "hPa";
|
||||
export const UNIT_INHG = "inHg";
|
||||
|
||||
/** Entity ID of the default view. */
|
||||
export const DEFAULT_VIEW_ENTITY_ID = "group.default_view";
|
||||
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import type {
|
||||
Condition,
|
||||
ConditionContext,
|
||||
} from "../../panels/lovelace/common/validate-condition";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { setupConditionListeners } from "../condition/listeners";
|
||||
|
||||
/**
|
||||
* Reactive controller that manages the media-query and time-based listeners
|
||||
* needed to keep a set of lovelace visibility conditions evaluated live.
|
||||
*
|
||||
* The host is responsible for the actual evaluation (e.g. computing visible /
|
||||
* hidden / invalid state); the controller only triggers it via the supplied
|
||||
* `onUpdate` callback when something the conditions depend on changes. Call
|
||||
* `setup()` whenever the conditions change; the controller clears previous
|
||||
* listeners and re-subscribes. Listeners are automatically released when the
|
||||
* host disconnects.
|
||||
*/
|
||||
export class ConditionListenersController implements ReactiveController {
|
||||
private _unsubs: (() => void)[] = [];
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostDisconnected(): void {
|
||||
this.clear();
|
||||
}
|
||||
|
||||
public setup(
|
||||
conditions: Condition[],
|
||||
hass: HomeAssistant,
|
||||
onUpdate: () => void,
|
||||
getContext?: () => ConditionContext
|
||||
): void {
|
||||
this.clear();
|
||||
if (!conditions.length) {
|
||||
return;
|
||||
}
|
||||
setupConditionListeners(
|
||||
conditions,
|
||||
hass,
|
||||
(unsub) => this._unsubs.push(unsub),
|
||||
() => onUpdate(),
|
||||
getContext
|
||||
);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const unsub of this._unsubs) {
|
||||
unsub();
|
||||
}
|
||||
this._unsubs = [];
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,6 @@
|
||||
import timezones from "google-timezones-json";
|
||||
import { TimeZone } from "../../data/translation";
|
||||
|
||||
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
|
||||
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
|
||||
// instead of an IANA zone name. Only accept values that are known IANA zones,
|
||||
// matching the list used by ha-timezone-picker.
|
||||
const RESOLVED_TIME_ZONE =
|
||||
RESOLVED_RAW &&
|
||||
(RESOLVED_RAW === "UTC" ||
|
||||
RESOLVED_RAW === "Etc/UTC" ||
|
||||
RESOLVED_RAW in timezones)
|
||||
? RESOLVED_RAW
|
||||
: undefined;
|
||||
|
||||
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
|
||||
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
|
||||
|
||||
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
|
||||
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../types";
|
||||
import {
|
||||
entitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { entitiesContext, statesContext } from "../../data/context";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import { transform } from "./transform";
|
||||
|
||||
interface ConsumeEntryConfig {
|
||||
@@ -27,28 +18,6 @@ const resolveAtPath = (host: unknown, path: readonly string[]) => {
|
||||
return cur;
|
||||
};
|
||||
|
||||
/** Reuse `previous` when every entry still references the same `HassEntity`. */
|
||||
export const preserveUnchangedEntityStatesRecord = <
|
||||
T extends Record<string, HassEntity | undefined>,
|
||||
>(
|
||||
previous: T | undefined,
|
||||
next: T
|
||||
): T => {
|
||||
if (!previous) {
|
||||
return next;
|
||||
}
|
||||
const nextKeys = Object.keys(next);
|
||||
if (Object.keys(previous).length !== nextKeys.length) {
|
||||
return next;
|
||||
}
|
||||
for (const key of nextKeys) {
|
||||
if (previous[key] !== next[key]) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return previous;
|
||||
};
|
||||
|
||||
const composeDecorator = <T, V>(
|
||||
context: Parameters<typeof consume>[0]["context"],
|
||||
watchKey: string | undefined,
|
||||
@@ -86,52 +55,27 @@ export const consumeEntityState = (config: ConsumeEntryConfig) =>
|
||||
);
|
||||
|
||||
/**
|
||||
* Like {@link consumeEntityState} but for one or more entity IDs at
|
||||
* `entityIdPath` (a string or string array; wrapped with {@link ensureArray}).
|
||||
* Resolves to a record keyed by entity ID containing the currently-available
|
||||
* entities (missing entities and non-string IDs are filtered out). Returns the
|
||||
* previous record when none of the selected entities changed.
|
||||
* Like {@link consumeEntityState} but for an array of entity IDs at
|
||||
* `entityIdPath`. Resolves to a `HassEntity[]` containing one entry per
|
||||
* currently-available entity (missing entities and non-string IDs are
|
||||
* filtered out; original order is preserved).
|
||||
*/
|
||||
export const consumeEntityStates = (config: ConsumeEntryConfig) => {
|
||||
const watchKey = config.entityIdPath[0];
|
||||
const buildRecord = function (this: unknown, states: HassEntities) {
|
||||
const ids = ensureArray(resolveAtPath(this, config.entityIdPath));
|
||||
if (!ids || !states) return undefined;
|
||||
const result: Record<string, HassEntity> = {};
|
||||
for (const id of ids) {
|
||||
if (typeof id !== "string") continue;
|
||||
const state = states[id];
|
||||
if (state !== undefined) result[id] = state;
|
||||
export const consumeEntityStates = (config: ConsumeEntryConfig) =>
|
||||
composeDecorator<HassEntities, HassEntity[]>(
|
||||
statesContext,
|
||||
config.entityIdPath[0],
|
||||
function (states) {
|
||||
const ids = resolveAtPath(this, config.entityIdPath);
|
||||
if (!Array.isArray(ids) || !states) return undefined;
|
||||
const result: HassEntity[] = [];
|
||||
for (const id of ids) {
|
||||
if (typeof id !== "string") continue;
|
||||
const state = states[id];
|
||||
if (state !== undefined) result.push(state);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
return (proto: unknown, propertyKey: string) => {
|
||||
const key = String(propertyKey);
|
||||
const transformDec = transform<
|
||||
HassEntities,
|
||||
Record<string, HassEntity> | undefined
|
||||
>({
|
||||
transformer: function (this: unknown, states: HassEntities) {
|
||||
const next = buildRecord.call(this, states);
|
||||
if (next === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const previous = (this as Record<string, unknown>)[
|
||||
`__transform_${key}`
|
||||
] as Record<string, HassEntity> | undefined;
|
||||
return preserveUnchangedEntityStatesRecord(previous, next);
|
||||
},
|
||||
watch: watchKey ? [watchKey] : [],
|
||||
});
|
||||
const consumeDec = consume<any>({
|
||||
context: statesContext,
|
||||
subscribe: true,
|
||||
});
|
||||
transformDec(proto as never, propertyKey);
|
||||
consumeDec(proto as never, propertyKey);
|
||||
};
|
||||
};
|
||||
);
|
||||
|
||||
/**
|
||||
* Consumes `entitiesContext` and narrows it to the
|
||||
@@ -147,15 +91,3 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
|
||||
return typeof id === "string" ? entities?.[id] : undefined;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Consumes `internationalizationContext` and narrows it to the `localize`
|
||||
* function. No host watching is needed — the decorated property updates
|
||||
* whenever the i18n context changes.
|
||||
*/
|
||||
export const consumeLocalize = () =>
|
||||
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
|
||||
internationalizationContext,
|
||||
undefined,
|
||||
({ localize }) => localize
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -1,17 +1,3 @@
|
||||
import {
|
||||
mdiBattery,
|
||||
mdiBattery10,
|
||||
mdiBattery20,
|
||||
mdiBattery30,
|
||||
mdiBattery40,
|
||||
mdiBattery50,
|
||||
mdiBattery60,
|
||||
mdiBattery70,
|
||||
mdiBattery80,
|
||||
mdiBattery90,
|
||||
mdiBatteryAlertVariantOutline,
|
||||
mdiBatteryUnknown,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
|
||||
const BATTERY_ICONS = {
|
||||
@@ -26,18 +12,6 @@ const BATTERY_ICONS = {
|
||||
90: "mdi:battery-90",
|
||||
100: "mdi:battery",
|
||||
};
|
||||
const BATTERY_ICON_PATHS = {
|
||||
10: mdiBattery10,
|
||||
20: mdiBattery20,
|
||||
30: mdiBattery30,
|
||||
40: mdiBattery40,
|
||||
50: mdiBattery50,
|
||||
60: mdiBattery60,
|
||||
70: mdiBattery70,
|
||||
80: mdiBattery80,
|
||||
90: mdiBattery90,
|
||||
100: mdiBattery,
|
||||
};
|
||||
const BATTERY_CHARGING_ICONS = {
|
||||
10: "mdi:battery-charging-10",
|
||||
20: "mdi:battery-charging-20",
|
||||
@@ -83,15 +57,3 @@ export const batteryLevelIcon = (
|
||||
}
|
||||
return BATTERY_ICONS[batteryRound];
|
||||
};
|
||||
|
||||
export const batteryLevelIconPath = (batteryLevel: number | string): string => {
|
||||
const batteryValue = Number(batteryLevel);
|
||||
if (isNaN(batteryValue)) {
|
||||
return mdiBatteryUnknown;
|
||||
}
|
||||
if (batteryValue <= 5) {
|
||||
return mdiBatteryAlertVariantOutline;
|
||||
}
|
||||
const batteryRound = Math.round(batteryValue / 10) * 10;
|
||||
return BATTERY_ICON_PATHS[batteryRound];
|
||||
};
|
||||
|
||||
@@ -137,10 +137,7 @@ export const computeEntityPickerDisplay = (
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
const isRTL = computeRTL(hass);
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { isUnavailableState } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface EntityUnitStubConfig {
|
||||
@@ -21,24 +21,32 @@ export const computeEntityUnitDisplay = (
|
||||
stateObj: HassEntity | undefined,
|
||||
config: EntityUnitStubConfig
|
||||
): string => {
|
||||
let unit;
|
||||
if (
|
||||
!stateObj ||
|
||||
stateObj.state === UNAVAILABLE ||
|
||||
stateObj.state === UNKNOWN ||
|
||||
(!config.attribute && stateObj.attributes.device_class === "duration")
|
||||
stateObj &&
|
||||
!isUnavailableState(stateObj.state) &&
|
||||
(config.attribute || stateObj.attributes.device_class !== "duration")
|
||||
) {
|
||||
return "";
|
||||
// check for an explicitly defined unit in config
|
||||
unit = config.unit;
|
||||
|
||||
if (!unit) {
|
||||
if (!config.attribute) {
|
||||
// use entity's unit_of_measurement
|
||||
const stateParts = hass.formatEntityStateToParts(stateObj);
|
||||
unit = stateParts.find((part) => part.type === "unit")?.value;
|
||||
} else {
|
||||
// use attribute's unit if available
|
||||
const attrParts = hass.formatEntityAttributeValueToParts(
|
||||
stateObj,
|
||||
config.attribute
|
||||
);
|
||||
unit = attrParts.find((part) => part.type === "unit")?.value;
|
||||
}
|
||||
}
|
||||
|
||||
return unit ?? "";
|
||||
}
|
||||
|
||||
// check for an explicitly defined unit in config
|
||||
if (config.unit) {
|
||||
return config.unit;
|
||||
}
|
||||
|
||||
// otherwise derive from the entity's state or attribute
|
||||
const parts = config.attribute
|
||||
? hass.formatEntityAttributeValueToParts(stateObj, config.attribute)
|
||||
: hass.formatEntityStateToParts(stateObj);
|
||||
|
||||
return parts.find((part) => part.type === "unit")?.value ?? "";
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { UNAVAILABLE_STATES } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { stringCompare } from "../string/compare";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
@@ -253,7 +253,7 @@ export const getStatesDomain = (
|
||||
|
||||
if (!attribute) {
|
||||
// All entities can have unavailable states
|
||||
result.push(UNAVAILABLE, UNKNOWN);
|
||||
result.push(...UNAVAILABLE_STATES);
|
||||
}
|
||||
|
||||
if (!attribute && domain in FIXED_DOMAIN_STATES) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
|
||||
@@ -8,18 +8,12 @@ export const computeGroupEntitiesState = (states: HassEntity[]): string => {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
const allUnavailable = states.every(
|
||||
(stateObj) => stateObj.state === UNAVAILABLE
|
||||
const validState = states.some(
|
||||
(stateObj) => !isUnavailableState(stateObj.state)
|
||||
);
|
||||
if (allUnavailable) {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
const hasValidState = states.some(
|
||||
(stateObj) => stateObj.state !== UNAVAILABLE && stateObj.state !== UNKNOWN
|
||||
);
|
||||
if (!hasValidState) {
|
||||
return UNKNOWN;
|
||||
if (!validState) {
|
||||
return UNAVAILABLE;
|
||||
}
|
||||
|
||||
// Use the first state to determine the domain
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { OFF, UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { isUnavailableState, OFF, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
|
||||
export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
@@ -19,7 +19,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
return compareState !== UNAVAILABLE;
|
||||
}
|
||||
|
||||
if (compareState === UNAVAILABLE || compareState === UNKNOWN) {
|
||||
if (isUnavailableState(compareState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* @summary Truncates a string to `maxLength`, appending `ellipsis` only when it actually shortens the result.
|
||||
* @param text The input string.
|
||||
* @param maxLength Maximum length of the prefix kept before the ellipsis.
|
||||
* @param ellipsis Suffix appended when truncation occurs.
|
||||
* @returns `text` unchanged when its length is `<= maxLength + ellipsis.length`, otherwise `text.substring(0, maxLength) + ellipsis`.
|
||||
*/
|
||||
export const truncateWithEllipsis = (
|
||||
text: string,
|
||||
maxLength: number,
|
||||
ellipsis = "..."
|
||||
): string => {
|
||||
if (text.length <= maxLength + ellipsis.length) {
|
||||
return text;
|
||||
}
|
||||
return `${text.substring(0, maxLength)}${ellipsis}`;
|
||||
};
|
||||
@@ -1,20 +1,16 @@
|
||||
import type { LitElement } from "lit";
|
||||
import type { HomeAssistant, Translation } from "../../types";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export function computeRTL(
|
||||
language = "en",
|
||||
translations: Record<string, Translation>
|
||||
) {
|
||||
if (translations[language]) {
|
||||
return translations[language].isRTL || false;
|
||||
export function computeRTL(hass: HomeAssistant) {
|
||||
const lang = hass.language || "en";
|
||||
if (hass.translationMetadata.translations[lang]) {
|
||||
return hass.translationMetadata.translations[lang].isRTL || false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function computeRTLDirection(hass: HomeAssistant) {
|
||||
return emitRTLDirection(
|
||||
computeRTL(hass.language, hass.translationMetadata.translations)
|
||||
);
|
||||
return emitRTLDirection(computeRTL(hass));
|
||||
}
|
||||
|
||||
export function emitRTLDirection(rtl: boolean) {
|
||||
|
||||
@@ -121,7 +121,6 @@ export class HaAutomationRowEventChip extends LitElement {
|
||||
align-items: center;
|
||||
--mdc-icon-size: 16px;
|
||||
line-height: 1;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../ha-tooltip";
|
||||
|
||||
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
|
||||
|
||||
/**
|
||||
* @element ha-automation-row-live-test
|
||||
*
|
||||
* @summary
|
||||
* Small status indicator dot used in automation/condition rows to surface the
|
||||
* live evaluation result.
|
||||
*
|
||||
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
|
||||
* @attr {string} label - Accessible label announced by assistive technology.
|
||||
* @attr {string} message - Optional tooltip body shown on hover/focus.
|
||||
*/
|
||||
@customElement("ha-automation-row-live-test")
|
||||
export class HaAutomationRowLiveTest extends LitElement {
|
||||
@property({ reflect: true }) public state: LiveTestState = "unknown";
|
||||
|
||||
@property() public label = "";
|
||||
|
||||
@property() public message?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
id="indicator"
|
||||
role="status"
|
||||
tabindex="0"
|
||||
aria-label=${this.label}
|
||||
></div>
|
||||
${this.message
|
||||
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: absolute;
|
||||
inset-inline-end: -6px;
|
||||
display: inline-block;
|
||||
}
|
||||
#indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
border: 3px solid;
|
||||
box-sizing: border-box;
|
||||
background-color: var(--card-background-color);
|
||||
transition: all var(--ha-animation-duration-normal) ease-in-out;
|
||||
}
|
||||
:host([state="pass"]) #indicator {
|
||||
background-color: var(--ha-color-fill-success-loud-resting);
|
||||
border-color: var(--ha-color-fill-success-loud-resting);
|
||||
}
|
||||
:host([state="fail"]) #indicator {
|
||||
border-color: var(--ha-color-fill-warning-loud-resting);
|
||||
}
|
||||
:host([state="invalid"]) #indicator {
|
||||
border-color: var(--ha-color-fill-danger-loud-resting);
|
||||
}
|
||||
:host([state="unknown"]) #indicator {
|
||||
border-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-row-live-test": HaAutomationRowLiveTest;
|
||||
}
|
||||
}
|
||||
@@ -124,13 +124,10 @@ export class HaAutomationRow extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
padding-left: var(--ha-space-3);
|
||||
padding-inline-start: var(--ha-space-3);
|
||||
padding-inline-end: initial;
|
||||
padding: 0 0 0 var(--ha-space-3);
|
||||
min-height: 48px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
@@ -146,8 +143,6 @@ export class HaAutomationRow extends LitElement {
|
||||
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--ha-color-on-neutral-quiet);
|
||||
margin-left: calc(var(--ha-space-2) * -1);
|
||||
margin-inline-start: calc(var(--ha-space-2) * -1);
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
:host([building-block]) .leading-icon-wrapper {
|
||||
background-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
@@ -191,6 +186,7 @@ export class HaAutomationRow extends LitElement {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0 var(--ha-space-3);
|
||||
}
|
||||
::slotted([slot="header"]) {
|
||||
overflow-wrap: anywhere;
|
||||
@@ -198,6 +194,7 @@ export class HaAutomationRow extends LitElement {
|
||||
}
|
||||
::slotted([slot="event"]) {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
.icons {
|
||||
|
||||
@@ -116,7 +116,7 @@ export class HaProgressButton extends LitElement {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
:host([appearance="brand"]) ha-svg-icon {
|
||||
ha-svg-icon {
|
||||
color: var(--white-color);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import type { TooltipPositionCallback } from "echarts/types/dist/shared";
|
||||
|
||||
export const TOOLTIP_GAP_PX = 12;
|
||||
export const TOOLTIP_TOP_OFFSET_PX = 10;
|
||||
|
||||
/**
|
||||
* Pins the tooltip near the top of the chart and offsets it horizontally
|
||||
* from the cursor so it never covers the data point being inspected.
|
||||
* For axis-trigger time-series tooltips where the cursor's Y is uncorrelated
|
||||
* with the displayed content.
|
||||
*/
|
||||
export const sideTooltipPosition: TooltipPositionCallback = (
|
||||
point,
|
||||
_params,
|
||||
dom,
|
||||
_rect,
|
||||
size
|
||||
) => {
|
||||
const [cursorX] = point;
|
||||
const [viewW, viewH] = size.viewSize;
|
||||
const [tipW, tipH] = size.contentSize;
|
||||
|
||||
const rtl =
|
||||
dom instanceof HTMLElement && getComputedStyle(dom).direction === "rtl";
|
||||
|
||||
const rightOfCursor = cursorX + TOOLTIP_GAP_PX;
|
||||
const leftOfCursor = cursorX - TOOLTIP_GAP_PX - tipW;
|
||||
|
||||
let x = rtl ? leftOfCursor : rightOfCursor;
|
||||
const overflowsRight = x + tipW > viewW;
|
||||
const overflowsLeft = x < 0;
|
||||
if (overflowsRight || overflowsLeft) {
|
||||
x = rtl ? rightOfCursor : leftOfCursor;
|
||||
}
|
||||
x = Math.max(0, Math.min(x, viewW - tipW));
|
||||
|
||||
const y = Math.max(0, Math.min(TOOLTIP_TOP_OFFSET_PX, viewH - tipH));
|
||||
|
||||
return [x, y];
|
||||
};
|
||||
@@ -14,13 +14,12 @@ import type {
|
||||
ECElementEvent,
|
||||
LegendComponentOption,
|
||||
LineSeriesOption,
|
||||
TooltipOption,
|
||||
XAXisOption,
|
||||
YAXisOption,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
@@ -30,59 +29,22 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type {
|
||||
ECOption,
|
||||
HaECOption,
|
||||
HaECSeries,
|
||||
HaECSeriesItem,
|
||||
HaTooltipOption,
|
||||
} from "../../resources/echarts/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant, HomeAssistantUI } from "../../types";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button";
|
||||
import { formatTimeLabel } from "./axis-label";
|
||||
import { downSampleLineData } from "./down-sample";
|
||||
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
|
||||
|
||||
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||
const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
readonly unknown[]
|
||||
>;
|
||||
|
||||
const toEChartsFormatter = (
|
||||
fn: ReturnType<typeof wrapLitTooltipFormatter>
|
||||
): NonNullable<TooltipOption["formatter"]> =>
|
||||
fn as NonNullable<TooltipOption["formatter"]>;
|
||||
|
||||
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
|
||||
const { formatter, ...rest } = tooltip;
|
||||
const next: TooltipOption = { ...rest };
|
||||
if (typeof formatter === "function") {
|
||||
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
|
||||
} else if (formatter !== undefined) {
|
||||
next.formatter = formatter;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
|
||||
if (s.tooltip && typeof s.tooltip.formatter === "function") {
|
||||
return {
|
||||
...s,
|
||||
tooltip: convertHaTooltipFormatter(s.tooltip),
|
||||
} as RawSeriesOption;
|
||||
}
|
||||
return s as RawSeriesOption;
|
||||
};
|
||||
|
||||
export type CustomLegendOption = ECOption["legend"] & {
|
||||
type: "custom";
|
||||
data?: {
|
||||
@@ -104,9 +66,9 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public data: HaECSeries = [];
|
||||
@property({ attribute: false }) public data: ECOption["series"] = [];
|
||||
|
||||
@property({ attribute: false }) public options?: HaECOption;
|
||||
@property({ attribute: false }) public options?: ECOption;
|
||||
|
||||
@property({ type: String }) public height?: string;
|
||||
|
||||
@@ -140,8 +102,6 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
@state() private _hiddenDatasets = new Set<string>();
|
||||
|
||||
@query(".chart") private _chartContainer?: HTMLDivElement;
|
||||
|
||||
private _modifierPressed = false;
|
||||
|
||||
private _isTouchDevice = "ontouchstart" in window;
|
||||
@@ -509,6 +469,7 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private async _setupChart() {
|
||||
if (this._loading) return;
|
||||
const container = this.renderRoot.querySelector(".chart") as HTMLDivElement;
|
||||
this._loading = true;
|
||||
try {
|
||||
if (this.chart) {
|
||||
@@ -523,7 +484,7 @@ export class HaChartBase extends LitElement {
|
||||
const style = getComputedStyle(this);
|
||||
echarts.registerTheme("custom", this._createTheme(style));
|
||||
|
||||
this.chart = echarts.init(this._chartContainer!, "custom");
|
||||
this.chart = echarts.init(container, "custom");
|
||||
this.chart.on("datazoom", (e: any) => {
|
||||
this._handleDataZoomEvent(e);
|
||||
});
|
||||
@@ -652,7 +613,7 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
// Return an array of all IDs associated with the legend item of the primaryId
|
||||
private _getAllIdsFromLegend(
|
||||
options: HaECOption | undefined,
|
||||
options: ECOption | undefined,
|
||||
primaryId: string
|
||||
): string[] {
|
||||
if (!options) return [primaryId];
|
||||
@@ -672,7 +633,7 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
|
||||
// No known need to remove items at this time.
|
||||
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
|
||||
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
|
||||
if (!options) return;
|
||||
const legend = ensureArray(this.options?.legend || [])[0] as
|
||||
| LegendComponentOption
|
||||
@@ -795,34 +756,22 @@ export class HaChartBase extends LitElement {
|
||||
xAxis,
|
||||
};
|
||||
|
||||
if (options.tooltip) {
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
|
||||
// back into the caller's options.tooltip reference (callers may cache the
|
||||
// options object via memoizeOne, in which case in-place mutation would
|
||||
// pollute that cache across chart instances).
|
||||
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
|
||||
const next = convertHaTooltipFormatter(tooltip);
|
||||
if (isMobile) {
|
||||
// mobile charts are full width so we need to confine the tooltip to the chart
|
||||
next.confine = true;
|
||||
next.appendTo = undefined;
|
||||
next.triggerOn = "click";
|
||||
}
|
||||
return next;
|
||||
};
|
||||
const haTooltip = options.tooltip;
|
||||
const processedTooltip = Array.isArray(haTooltip)
|
||||
? haTooltip.map(processTooltip)
|
||||
: processTooltip(haTooltip);
|
||||
return {
|
||||
...options,
|
||||
tooltip: processedTooltip,
|
||||
} as ECOption;
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
if (isMobile && options.tooltip) {
|
||||
// mobile charts are full width so we need to confine the tooltip to the chart
|
||||
const tooltips = Array.isArray(options.tooltip)
|
||||
? options.tooltip
|
||||
: [options.tooltip];
|
||||
tooltips.forEach((tooltip) => {
|
||||
tooltip.confine = true;
|
||||
tooltip.appendTo = undefined;
|
||||
tooltip.triggerOn = "click";
|
||||
});
|
||||
options.tooltip = tooltips;
|
||||
}
|
||||
return options as ECOption;
|
||||
return options;
|
||||
}
|
||||
|
||||
private _createTheme(style: CSSStyleDeclaration) {
|
||||
@@ -1006,16 +955,30 @@ export class HaChartBase extends LitElement {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
| XAXisOption
|
||||
| undefined;
|
||||
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
|
||||
| YAXisOption
|
||||
| undefined;
|
||||
const series = ensureArray(this.data).map((s) => {
|
||||
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
|
||||
? undefined
|
||||
: s.data;
|
||||
let result = {
|
||||
...s,
|
||||
data,
|
||||
} as HaECSeriesItem;
|
||||
if (data && s.type === "line") {
|
||||
if ((s as LineSeriesOption).sampling === "minmax") {
|
||||
if (yAxis?.type === "log") {
|
||||
// set <=0 values to null so they render as gaps on a log graph
|
||||
return {
|
||||
...s,
|
||||
data: (data as LineSeriesOption["data"])!.map((v) =>
|
||||
Array.isArray(v)
|
||||
? [
|
||||
v[0],
|
||||
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
|
||||
...v.slice(2),
|
||||
]
|
||||
: v
|
||||
),
|
||||
};
|
||||
}
|
||||
if (s.sampling === "minmax") {
|
||||
const minX = xAxis?.min
|
||||
? xAxis.min instanceof Date
|
||||
? xAxis.min.getTime()
|
||||
@@ -1030,8 +993,8 @@ export class HaChartBase extends LitElement {
|
||||
? xAxis.max
|
||||
: undefined
|
||||
: undefined;
|
||||
result = {
|
||||
...result,
|
||||
return {
|
||||
...s,
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
@@ -1039,10 +1002,11 @@ export class HaChartBase extends LitElement {
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
} as HaECSeriesItem;
|
||||
};
|
||||
}
|
||||
}
|
||||
return processSeriesTooltipFormatter(result);
|
||||
const name = filterXSS(String(s.name ?? s.id ?? ""));
|
||||
return { ...s, name, data };
|
||||
});
|
||||
return series as ECOption["series"];
|
||||
}
|
||||
@@ -1146,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({
|
||||
@@ -1379,8 +1343,8 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
private _compareCustomLegendOptions(
|
||||
oldOptions: HaECOption | undefined,
|
||||
newOptions: HaECOption | undefined
|
||||
oldOptions: ECOption | undefined,
|
||||
newOptions: ECOption | undefined
|
||||
): boolean {
|
||||
const oldLegends = ensureArray(
|
||||
oldOptions?.legend || []
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@customElement("ha-chart-tooltip-marker")
|
||||
class HaChartTooltipMarker extends LitElement {
|
||||
@property() public color = "";
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public rtl = false;
|
||||
|
||||
protected willUpdate(changed: PropertyValues) {
|
||||
if (changed.has("color")) {
|
||||
this.style.backgroundColor = this.color;
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
margin-inline-end: 4px;
|
||||
margin-inline-start: initial;
|
||||
border-radius: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
:host([rtl]) {
|
||||
direction: rtl;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-chart-tooltip-marker": HaChartTooltipMarker;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { GraphSeriesOption } from "echarts/charts";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import "./ha-chart-base";
|
||||
import type { HaChartBase } from "./ha-chart-base";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ attribute: false }) public tooltipFormatter?: (
|
||||
params: TopLevelFormatterParams
|
||||
) => TemplateResult | typeof nothing | null;
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* Optional callback that returns additional searchable strings for a node.
|
||||
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
(categories?: NetworkData["categories"]): HaECOption => ({
|
||||
(categories?: NetworkData["categories"]): ECOption => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
confine: true,
|
||||
|
||||
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import SankeyChart from "../../resources/echarts/components/sankey/install";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { NODE_SIZE } from "../trace/hat-graph-const";
|
||||
import "../ha-alert";
|
||||
|
||||
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
|
||||
});
|
||||
|
||||
render() {
|
||||
const options: HaECOption = {
|
||||
const options = {
|
||||
grid: {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
|
||||
formatter: this._renderTooltip,
|
||||
appendTo: document.body,
|
||||
},
|
||||
};
|
||||
} as ECOption;
|
||||
|
||||
return html`<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
@@ -103,16 +103,12 @@ export class HaSankeyChart extends LitElement {
|
||||
: data.value;
|
||||
if (data.id) {
|
||||
const node = this.data.nodes.find((n) => n.id === data.id);
|
||||
return html`<ha-chart-tooltip-marker
|
||||
.color=${String(params.color ?? "")}
|
||||
></ha-chart-tooltip-marker>
|
||||
${node?.label ?? data.id}<br />${value}`;
|
||||
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
|
||||
}
|
||||
if (data.source && data.target) {
|
||||
const source = this.data.nodes.find((n) => n.id === data.source);
|
||||
const target = this.data.nodes.find((n) => n.id === data.target);
|
||||
return html`${source?.label ?? data.source} →
|
||||
${target?.label ?? data.target}<br />${value}`;
|
||||
return `${filterXSS(source?.label ?? data.source)} → ${filterXSS(target?.label ?? data.target)}<br>${value}`;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -5,9 +5,10 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
|
||||
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
|
||||
@@ -24,6 +25,8 @@ export interface SunburstNode {
|
||||
|
||||
@customElement("ha-sunburst-chart")
|
||||
export class HaSunburstChart extends LitElement {
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public data?: SunburstNode;
|
||||
|
||||
@property({ attribute: false }) public valueFormatter?: (
|
||||
@@ -47,13 +50,13 @@ export class HaSunburstChart extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const options: HaECOption = {
|
||||
const options = {
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
formatter: this._renderTooltip,
|
||||
appendTo: document.body,
|
||||
},
|
||||
};
|
||||
} as ECOption;
|
||||
|
||||
return html`<ha-chart-base
|
||||
.data=${this._createData(this.data)}
|
||||
@@ -68,10 +71,7 @@ export class HaSunburstChart extends LitElement {
|
||||
const value = this.valueFormatter
|
||||
? this.valueFormatter(data.value)
|
||||
: data.value;
|
||||
return html`<ha-chart-tooltip-marker
|
||||
.color=${String(params.color ?? "")}
|
||||
></ha-chart-tooltip-marker>
|
||||
${data.name}<br />${value}`;
|
||||
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
|
||||
};
|
||||
|
||||
private _createData = memoizeOne(
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import { nothing, render } from "lit";
|
||||
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
|
||||
|
||||
type WrappedTooltipFormatter = (
|
||||
params: unknown,
|
||||
ticket?: string
|
||||
) => HTMLElement | null;
|
||||
|
||||
export type { WrappedTooltipFormatter };
|
||||
|
||||
const litTooltipFormatterCache = new WeakMap<
|
||||
LitTooltipFormatter | WrappedTooltipFormatter,
|
||||
WrappedTooltipFormatter
|
||||
>();
|
||||
|
||||
export const wrapLitTooltipFormatter = (
|
||||
fn: LitTooltipFormatter | WrappedTooltipFormatter
|
||||
): WrappedTooltipFormatter => {
|
||||
const cached = litTooltipFormatterCache.get(fn);
|
||||
if (cached) return cached;
|
||||
const container = document.createElement("div");
|
||||
// display:contents keeps the wrapper layout-invisible so its children act as
|
||||
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
|
||||
container.style.display = "contents";
|
||||
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
|
||||
const result = (fn as LitTooltipFormatter)(params, ticket);
|
||||
// `nothing` and null/undefined must all suppress the tooltip. Returning
|
||||
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
|
||||
// comment marker behind so echarts would show an empty box; convert it to
|
||||
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
|
||||
if (result === null || result === undefined || result === nothing) {
|
||||
return null;
|
||||
}
|
||||
render(result, container);
|
||||
return container;
|
||||
};
|
||||
litTooltipFormatterCache.set(fn, wrapped);
|
||||
// Idempotent re-wrap: looking up the wrapped fn returns itself.
|
||||
litTooltipFormatterCache.set(wrapped, wrapped);
|
||||
return wrapped;
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type { VisualMapComponentOption } from "echarts/components";
|
||||
import type { LineSeriesOption } from "echarts/charts";
|
||||
@@ -11,10 +11,7 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { LineChartEntity, LineChartState } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
getNumberFormatOptions,
|
||||
@@ -25,6 +22,7 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
|
||||
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
|
||||
|
||||
const safeParseFloat = (value) => {
|
||||
@@ -108,7 +106,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _datasetToDataIndex: number[] = [];
|
||||
|
||||
@state() private _chartOptions?: HaECOption;
|
||||
@state() private _chartOptions?: ECOption;
|
||||
|
||||
private _hiddenStats = new Set<string>();
|
||||
|
||||
@@ -118,7 +116,9 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
private _yAxisFractionDigits = 1;
|
||||
private _previousYAxisLabelValue = 0;
|
||||
|
||||
private _yAxisMaximumFractionDigits = 0;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
@@ -141,11 +141,12 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _renderTooltip = (params: any) => {
|
||||
const time = params[0].axisValue;
|
||||
const title = formatDateTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
const title =
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(time),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) + "<br>";
|
||||
const datapoints: Record<string, any>[] = [];
|
||||
this._chartData.forEach((dataset, index) => {
|
||||
if (
|
||||
@@ -176,44 +177,52 @@ export class StateHistoryChartLine extends LitElement {
|
||||
seriesName: dataset.name,
|
||||
seriesIndex: index,
|
||||
value: lastData,
|
||||
color: dataset.color,
|
||||
// HTML copied from echarts. May change based on options
|
||||
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
|
||||
});
|
||||
});
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
|
||||
return html`${title}${datapoints.map((param) => {
|
||||
const entityId = this._entityIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const entry = this.hass.entities[entityId];
|
||||
const stateValue = String(param.value[1]);
|
||||
const value = stateObj
|
||||
? this.hass.formatEntityState(stateObj, stateValue)
|
||||
: `${formatNumber(
|
||||
stateValue,
|
||||
this.hass.locale,
|
||||
getNumberFormatOptions(undefined, entry)
|
||||
)}${unit}`;
|
||||
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
|
||||
const data = this.data[dataIndex];
|
||||
let statSuffix: TemplateResult | typeof nothing = nothing;
|
||||
if (data.statistics && data.statistics.length > 0) {
|
||||
const source =
|
||||
data.states.length === 0 ||
|
||||
param.value[0] < data.states[0].last_changed
|
||||
? this.hass.localize("ui.components.history_charts.source_stats")
|
||||
: this.hass.localize("ui.components.history_charts.source_history");
|
||||
// Five non-breaking spaces indent the source label.
|
||||
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
|
||||
}
|
||||
return html`<br /><ha-chart-tooltip-marker
|
||||
.color=${String(param.color ?? "")}
|
||||
></ha-chart-tooltip-marker>
|
||||
${param.seriesName
|
||||
? html`${param.seriesName}: `
|
||||
: nothing}${value}${statSuffix}`;
|
||||
})}`;
|
||||
return (
|
||||
title +
|
||||
datapoints
|
||||
.map((param) => {
|
||||
const entityId = this._entityIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const entry = this.hass.entities[entityId];
|
||||
const stateValue = String(param.value[1]);
|
||||
let value = stateObj
|
||||
? this.hass.formatEntityState(stateObj, stateValue)
|
||||
: `${formatNumber(
|
||||
stateValue,
|
||||
this.hass.locale,
|
||||
getNumberFormatOptions(undefined, entry)
|
||||
)}${unit}`;
|
||||
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
|
||||
const data = this.data[dataIndex];
|
||||
if (data.statistics && data.statistics.length > 0) {
|
||||
value += "<br> ";
|
||||
const source =
|
||||
data.states.length === 0 ||
|
||||
param.value[0] < data.states[0].last_changed
|
||||
? `${this.hass.localize(
|
||||
"ui.components.history_charts.source_stats"
|
||||
)}`
|
||||
: `${this.hass.localize(
|
||||
"ui.components.history_charts.source_history"
|
||||
)}`;
|
||||
value += source;
|
||||
}
|
||||
|
||||
if (param.seriesName) {
|
||||
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
|
||||
}
|
||||
return `${param.marker} ${value}`;
|
||||
})
|
||||
.join("<br>")
|
||||
);
|
||||
};
|
||||
|
||||
private _datasetHidden(ev: CustomEvent) {
|
||||
@@ -284,10 +293,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
(changedProps.has("hass") &&
|
||||
this._hasEntityStatesChanged(changedProps.get("hass")))
|
||||
) {
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
const rtl = computeRTL(this.hass);
|
||||
let minYAxis: number | ((values: { min: number }) => number) | undefined =
|
||||
this.minYAxis;
|
||||
let maxYAxis: number | ((values: { max: number }) => number) | undefined =
|
||||
@@ -404,7 +410,8 @@ export class StateHistoryChartLine extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -426,14 +433,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const datasets: LineSeriesOption[] = [];
|
||||
const entityIds: string[] = [];
|
||||
const datasetToDataIndex: number[] = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
if (entityStates.length === 0) {
|
||||
return;
|
||||
}
|
||||
@@ -469,7 +468,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
d.data!.push([timestamp, prevValues[i]]);
|
||||
}
|
||||
d.data!.push([timestamp, datavalues[i]]);
|
||||
trackY(datavalues[i]);
|
||||
});
|
||||
prevValues = datavalues;
|
||||
};
|
||||
@@ -820,7 +818,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const currentValue = stateObj ? safeParseFloat(stateObj.state) : null;
|
||||
if (currentValue !== null) {
|
||||
data[0].data!.push([now, currentValue]);
|
||||
trackY(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -828,7 +825,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
Array.prototype.push.apply(datasets, data);
|
||||
});
|
||||
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = datasets;
|
||||
this._entityIds = entityIds;
|
||||
this._datasetToDataIndex = datasetToDataIndex;
|
||||
@@ -862,8 +858,20 @@ export class StateHistoryChartLine extends LitElement {
|
||||
}
|
||||
|
||||
private _formatYAxisLabel = (value: number) => {
|
||||
// show the first significant digit for tiny values
|
||||
const maximumFractionDigits = Math.max(
|
||||
1,
|
||||
// use the difference to the previous value to determine the number of significant digits #25526
|
||||
-Math.floor(
|
||||
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
||||
)
|
||||
);
|
||||
this._yAxisMaximumFractionDigits = Math.max(
|
||||
this._yAxisMaximumFractionDigits,
|
||||
maximumFractionDigits
|
||||
);
|
||||
const label = formatNumber(value, this.hass.locale, {
|
||||
maximumFractionDigits: this._yAxisFractionDigits,
|
||||
maximumFractionDigits: this._yAxisMaximumFractionDigits,
|
||||
});
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
@@ -873,6 +881,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
this._previousYAxisLabelValue = value;
|
||||
return label;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
ECElementEvent,
|
||||
TooltipFormatterCallback,
|
||||
TooltipPositionCallbackParams,
|
||||
} from "echarts/types/dist/shared";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
@@ -13,10 +14,8 @@ import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
@@ -57,7 +56,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _chartData: CustomSeriesOption[] = [];
|
||||
|
||||
@state() private _chartOptions?: HaECOption;
|
||||
@state() private _chartOptions?: ECOption;
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
@@ -69,7 +68,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * 30 + 30}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
.data=${this._chartData as ECOption["series"]}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@chart-zoom=${this._handleDataZoom}
|
||||
@@ -132,44 +131,47 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
return rect;
|
||||
};
|
||||
|
||||
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
|
||||
const { value, name, seriesName, color } = Array.isArray(params)
|
||||
? params[0]
|
||||
: params;
|
||||
const durationInMs = value![2] - value![1];
|
||||
const formattedDuration = `${this.hass.localize(
|
||||
"ui.components.history_charts.duration"
|
||||
)}: ${millisecondsToDuration(durationInMs)}`;
|
||||
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
|
||||
(params: TooltipPositionCallbackParams) => {
|
||||
const { value, name, marker, seriesName, color } = Array.isArray(params)
|
||||
? params[0]
|
||||
: params;
|
||||
const title = seriesName
|
||||
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
|
||||
: "";
|
||||
const durationInMs = value![2] - value![1];
|
||||
const formattedDuration = `${this.hass.localize(
|
||||
"ui.components.history_charts.duration"
|
||||
)}: ${millisecondsToDuration(durationInMs)}`;
|
||||
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
return html`${seriesName
|
||||
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
|
||||
: nothing}<ha-chart-tooltip-marker
|
||||
.color=${String(color ?? "")}
|
||||
.rtl=${rtl}
|
||||
></ha-chart-tooltip-marker
|
||||
>${name}<br />${formatDateTimeWithSeconds(
|
||||
new Date(value![1]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}<br />${formatDateTimeWithSeconds(
|
||||
new Date(value![2]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}<br />${formattedDuration}`;
|
||||
};
|
||||
const markerLocalized = !computeRTL(this.hass)
|
||||
? marker
|
||||
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
|
||||
|
||||
const lines = [
|
||||
markerLocalized + name,
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(value![1]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formatDateTimeWithSeconds(
|
||||
new Date(value![2]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
formattedDuration,
|
||||
].join("<br>");
|
||||
return [title, lines].join("");
|
||||
};
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
this.isConnected &&
|
||||
(changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("data") ||
|
||||
this._chartTime <
|
||||
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES))
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("data") ||
|
||||
this._chartTime <
|
||||
new Date(this.endTime.getTime() - MIN_TIME_BETWEEN_UPDATES)
|
||||
) {
|
||||
// If the line is more than 5 minutes old, re-gen it
|
||||
// so the X axis grows even if there is no new data
|
||||
@@ -196,10 +198,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
const rtl = computeRTL(this.hass);
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -257,7 +256,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
|
||||
@@ -2,13 +2,7 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiRestart } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
queryAll,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { customElement, eventOptions, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { restoreScroll } from "../../common/decorators/restore-scroll";
|
||||
import type {
|
||||
@@ -110,11 +104,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@state() private _hasZoomedCharts = false;
|
||||
|
||||
@queryAll("state-history-chart-line, state-history-chart-timeline")
|
||||
private _chartComponents!: NodeListOf<
|
||||
StateHistoryChartLine | StateHistoryChartTimeline
|
||||
>;
|
||||
|
||||
private _isSyncing = false;
|
||||
|
||||
// @ts-ignore
|
||||
@@ -338,7 +327,11 @@ export class StateHistoryCharts extends LitElement {
|
||||
this._isSyncing = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this._chartComponents.forEach((chartComponent, index) => {
|
||||
const chartComponents = this.renderRoot.querySelectorAll(
|
||||
"state-history-chart-line, state-history-chart-timeline"
|
||||
) as unknown as (StateHistoryChartLine | StateHistoryChartTimeline)[];
|
||||
|
||||
chartComponents.forEach((chartComponent, index) => {
|
||||
if (index === sourceChartIndex) {
|
||||
return;
|
||||
}
|
||||
@@ -357,7 +350,11 @@ export class StateHistoryCharts extends LitElement {
|
||||
this._isSyncing = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this._chartComponents.forEach((chartComponent: any) => {
|
||||
const chartComponents = this.renderRoot.querySelectorAll(
|
||||
"state-history-chart-line, state-history-chart-timeline"
|
||||
);
|
||||
|
||||
chartComponents.forEach((chartComponent: any) => {
|
||||
const chartBase =
|
||||
chartComponent.renderRoot?.querySelector("ha-chart-base");
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import type {
|
||||
ZRColor,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -13,9 +13,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
import { formatDate } from "../../common/datetime/format_date";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import { formatTimeWithSeconds } from "../../common/datetime/format_time";
|
||||
import {
|
||||
formatNumber,
|
||||
getNumberFormatOptions,
|
||||
@@ -34,15 +32,12 @@ import {
|
||||
isExternalStatistic,
|
||||
statisticsHaveType,
|
||||
} from "../../data/recorder";
|
||||
import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import type { ECOption } from "../../resources/echarts/echarts";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { sideTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -127,13 +122,13 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
@state() private _statisticIds: string[] = [];
|
||||
|
||||
@state() private _chartOptions?: HaECOption;
|
||||
@state() private _chartOptions?: ECOption;
|
||||
|
||||
@state() private _hiddenStats = new Set<string>();
|
||||
|
||||
private _computedStyle?: CSSStyleDeclaration;
|
||||
|
||||
private _yAxisFractionDigits = 1;
|
||||
private _previousYAxisLabelValue = 0;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
return changedProps.size > 1 || !changedProps.has("hass");
|
||||
@@ -145,8 +140,7 @@ export class StatisticsChart extends LitElement {
|
||||
changedProps.has("statTypes") ||
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats") ||
|
||||
changedProps.has("names")
|
||||
changedProps.has("_hiddenStats")
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
@@ -247,106 +241,42 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
private _renderTooltip = (params: any) => {
|
||||
const rendered: Record<string, boolean> = {};
|
||||
const chartIsBar = this.chartType.startsWith("bar");
|
||||
const period = this.period;
|
||||
const unit = this.unit
|
||||
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
|
||||
: "";
|
||||
const rows: {
|
||||
time?: string;
|
||||
color: string;
|
||||
seriesName?: string;
|
||||
value: string;
|
||||
}[] = [];
|
||||
for (const param of params) {
|
||||
if (rendered[param.seriesIndex]) continue;
|
||||
rendered[param.seriesIndex] = true;
|
||||
return params
|
||||
.map((param, index: number) => {
|
||||
if (rendered[param.seriesIndex]) return "";
|
||||
rendered[param.seriesIndex] = true;
|
||||
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const entry = this.hass.entities[statisticId];
|
||||
let rawValue: string;
|
||||
let rawTime: string;
|
||||
if (chartIsBar) {
|
||||
// For bar charts value is always second value.
|
||||
rawValue = String(param.value[1]);
|
||||
// Time value is third value (un-shifted date) if given, otherwise first value
|
||||
let startTime: Date;
|
||||
let endTime: Date | undefined;
|
||||
if (param.value[2]) {
|
||||
startTime = new Date(param.value[2]);
|
||||
if (param.value[3]) {
|
||||
endTime = new Date(param.value[3]);
|
||||
}
|
||||
} else {
|
||||
startTime = new Date(param.value[0]);
|
||||
}
|
||||
if (
|
||||
period === "year" ||
|
||||
period === "month" ||
|
||||
period === "week" ||
|
||||
period === "day"
|
||||
) {
|
||||
// For year/month/day periods, show only the date
|
||||
rawTime =
|
||||
formatDate(startTime, this.hass.locale, this.hass.config) +
|
||||
(endTime && period !== "day"
|
||||
? ` – ${formatDate(endTime, this.hass.locale, this.hass.config)}`
|
||||
: "");
|
||||
} else {
|
||||
// For other time periods, include time in render, and optionally show range
|
||||
// if we have an end time.
|
||||
rawTime =
|
||||
formatDateTimeWithSeconds(
|
||||
startTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) +
|
||||
(endTime
|
||||
? ` – ${formatTimeWithSeconds(
|
||||
endTime,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)}`
|
||||
: "");
|
||||
}
|
||||
} else {
|
||||
// For lines max series can have 3 values, as the second value is the max-min to form a band
|
||||
rawValue = String(param.value[2] ?? param.value[1]);
|
||||
// Time value is always first value
|
||||
rawTime = formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
const entry = this.hass.entities[statisticId];
|
||||
// max series can have 3 values, as the second value is the max-min to form a band
|
||||
const rawValue = String(param.value[2] ?? param.value[1]);
|
||||
|
||||
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
const value = `${formatNumber(
|
||||
rawValue,
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
}
|
||||
options
|
||||
)}${unit}`;
|
||||
|
||||
const options = getNumberFormatOptions(stateObj, entry) ?? {
|
||||
maximumFractionDigits: 2,
|
||||
};
|
||||
|
||||
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
|
||||
|
||||
rows.push({
|
||||
time: rows.length === 0 ? rawTime : undefined,
|
||||
color: String(param.color ?? ""),
|
||||
seriesName: param.seriesName,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
if (rows.length === 0) return nothing;
|
||||
|
||||
return html`${rows.map(
|
||||
(row, i) =>
|
||||
html`${row.time
|
||||
? html`${row.time}<br />`
|
||||
: nothing}<ha-chart-tooltip-marker
|
||||
.color=${row.color}
|
||||
></ha-chart-tooltip-marker>
|
||||
${row.seriesName}:
|
||||
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
|
||||
)}`;
|
||||
const time =
|
||||
index === 0
|
||||
? formatDateTimeWithSeconds(
|
||||
new Date(param.value[0]),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
) + "<br>"
|
||||
: "";
|
||||
return `${time}${param.marker} ${param.seriesName}: ${value}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("<br>");
|
||||
};
|
||||
|
||||
private _createOptions() {
|
||||
@@ -438,12 +368,7 @@ export class StatisticsChart extends LitElement {
|
||||
nameTextStyle: {
|
||||
align: "left",
|
||||
},
|
||||
position: computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? "right"
|
||||
: "left",
|
||||
position: computeRTL(this.hass) ? "right" : "left",
|
||||
scale:
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
@@ -473,7 +398,8 @@ export class StatisticsChart extends LitElement {
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
renderMode: "html",
|
||||
position: sideTooltipPosition,
|
||||
position: "bottom",
|
||||
align: "center",
|
||||
confine: true,
|
||||
formatter: this._renderTooltip,
|
||||
},
|
||||
@@ -508,14 +434,6 @@ export class StatisticsChart extends LitElement {
|
||||
const chartStacked = this.chartType.endsWith("stack");
|
||||
const statisticsData = Object.entries(this.statisticsData);
|
||||
const totalDataSets: typeof this._chartData = [];
|
||||
let yMin = Infinity;
|
||||
let yMax = -Infinity;
|
||||
const trackY = (v: number | null | undefined) => {
|
||||
if (typeof v === "number" && Number.isFinite(v)) {
|
||||
if (v < yMin) yMin = v;
|
||||
if (v > yMax) yMax = v;
|
||||
}
|
||||
};
|
||||
const legendData: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -588,57 +506,33 @@ export class StatisticsChart extends LitElement {
|
||||
const statDataSets: (LineSeriesOption | BarSeriesOption)[] = [];
|
||||
const statLegendData: typeof legendData = [];
|
||||
|
||||
// Place bars at centre of their specified time range if this is a bar chart
|
||||
// and the period is 5minute or hour.
|
||||
const centerBars =
|
||||
chartType === "bar" &&
|
||||
(this.period === "5minute" || this.period === "hour");
|
||||
|
||||
const pushData = (
|
||||
start: Date, // Data point start time
|
||||
end: Date, // Data point end time
|
||||
limit: Date, // Limit for end time (e.g. now)
|
||||
start: Date,
|
||||
end: Date,
|
||||
dataValues: (number | null)[][]
|
||||
) => {
|
||||
if (!dataValues.length) return;
|
||||
// Limit for time range is lesser of overall limit and data point end
|
||||
limit = end.getTime() < limit.getTime() ? end : limit;
|
||||
if (start.getTime() > limit.getTime()) {
|
||||
if (start > end) {
|
||||
// Drop data points that are after the requested endTime. This could happen if
|
||||
// endTime is "now" and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
statDataSets.forEach((d, i) => {
|
||||
if (chartType === "line") {
|
||||
if (
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
|
||||
// the last element. For regular rows it's [value]. Same call works.
|
||||
trackY(dataValues[i][dataValues[i].length - 1]);
|
||||
} else {
|
||||
let time = start;
|
||||
if (centerBars) {
|
||||
// If centering bars, set the time to the midpoint between start and end instead
|
||||
// of the start time.
|
||||
time = new Date((start.getTime() + end.getTime()) / 2);
|
||||
}
|
||||
// Data value should always be a scalar for bar charts. Pass in
|
||||
// real start time as extra value to allow formatting tooltip.
|
||||
d.data!.push([time, dataValues[i][0]!, start, end]);
|
||||
trackY(dataValues[i][0]);
|
||||
if (
|
||||
chartType === "line" &&
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
});
|
||||
prevValues = dataValues;
|
||||
prevEndTime = limit;
|
||||
prevEndTime = end;
|
||||
};
|
||||
|
||||
let color = colors[statistic_id];
|
||||
@@ -798,7 +692,11 @@ export class StatisticsChart extends LitElement {
|
||||
dataValues.push(val);
|
||||
});
|
||||
if (!this._hiddenStats.has(statistic_id)) {
|
||||
pushData(startDate, endDate, endTime, dataValues);
|
||||
pushData(
|
||||
startDate,
|
||||
endDate.getTime() < endTime.getTime() ? endDate : endTime,
|
||||
dataValues
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -847,7 +745,6 @@ export class StatisticsChart extends LitElement {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
trackY(val[val.length - 1]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -881,7 +778,6 @@ export class StatisticsChart extends LitElement {
|
||||
});
|
||||
});
|
||||
|
||||
this._yAxisFractionDigits = computeYAxisFractionDigits(yMin, yMax);
|
||||
this._chartData = totalDataSets;
|
||||
if (legendData.length !== this._legendData?.length) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
@@ -915,10 +811,21 @@ export class StatisticsChart extends LitElement {
|
||||
return Math.abs(value) < 1 ? value : roundingFn(value);
|
||||
}
|
||||
|
||||
private _formatYAxisLabel = (value: number) =>
|
||||
formatNumber(value, this.hass.locale, {
|
||||
maximumFractionDigits: this._yAxisFractionDigits,
|
||||
private _formatYAxisLabel = (value: number) => {
|
||||
// show the first significant digit for tiny values
|
||||
const maximumFractionDigits = Math.max(
|
||||
1,
|
||||
// use the difference to the previous value to determine the number of significant digits #25526
|
||||
-Math.floor(
|
||||
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
||||
)
|
||||
);
|
||||
const label = formatNumber(value, this.hass.locale, {
|
||||
maximumFractionDigits,
|
||||
});
|
||||
this._previousYAxisLabelValue = value;
|
||||
return label;
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
// Derive the number of decimal digits to use for Y-axis labels from the
|
||||
// observed data range. We estimate the tick interval as `range / 10` (twice
|
||||
// ECharts' default splitNumber of 5, as a safety margin against finer "nice"
|
||||
// intervals), then derive `ceil(-log10(interval))`.
|
||||
export function computeYAxisFractionDigits(min: number, max: number): number {
|
||||
const range = max - min;
|
||||
if (!Number.isFinite(range) || range <= 0) return 1;
|
||||
return Math.max(0, Math.ceil(-Math.log10(range / 10)));
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button";
|
||||
import "../ha-dialog-footer";
|
||||
import "../ha-icon-button";
|
||||
@@ -25,9 +24,7 @@ import type { DataTableSettingsDialogParams } from "./show-dialog-data-table-set
|
||||
|
||||
@customElement("dialog-data-table-settings")
|
||||
export class DialogDataTableSettings extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _params?: DataTableSettingsDialogParams;
|
||||
|
||||
@@ -120,7 +117,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const localize = this._params.localizeFunc || this._localize;
|
||||
const localize = this._params.localizeFunc || this.hass.localize;
|
||||
|
||||
const columns = this._sortedColumns(
|
||||
this._params.columns,
|
||||
@@ -130,6 +127,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
|
||||
return html`
|
||||
<ha-dialog
|
||||
.hass=${this.hass}
|
||||
.open=${this._open}
|
||||
header-title=${localize("ui.components.data-table.settings.header")}
|
||||
@closed=${this._dialogClosed}
|
||||
@@ -175,7 +173,7 @@ export class DialogDataTableSettings extends LitElement {
|
||||
.hidden=${!isVisible}
|
||||
.path=${isVisible ? mdiEye : mdiEyeOff}
|
||||
slot="meta"
|
||||
.label=${localize(
|
||||
.label=${this.hass!.localize(
|
||||
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
|
||||
{ title: typeof col.title === "string" ? col.title : "" }
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
@@ -18,163 +17,40 @@ import "../ha-label";
|
||||
class HaDataTableLabels extends LitElement {
|
||||
@property({ attribute: false }) public labels!: LabelRegistryEntry[];
|
||||
|
||||
@state() private _visibleCount = 0;
|
||||
|
||||
@query(".viewport") private _viewport?: HTMLDivElement;
|
||||
@query(".measure") private _measure?: HTMLDivElement;
|
||||
|
||||
private _sortedLabels: LabelRegistryEntry[] = [];
|
||||
|
||||
private _chipWidths: number[] = [];
|
||||
private _plusWidth = 0;
|
||||
private _gap = 8;
|
||||
|
||||
private _resizeController = new ResizeController(this, {
|
||||
target: null,
|
||||
skipInitial: true,
|
||||
callback: (entries) => {
|
||||
const entry = entries[0];
|
||||
const width = entry?.contentRect.width ?? 0;
|
||||
this._recomputeVisibleCount(width);
|
||||
return width;
|
||||
},
|
||||
});
|
||||
|
||||
protected willUpdate(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has("labels")) {
|
||||
this._sortedLabels = [...this.labels].sort((a, b) =>
|
||||
stringCompare(a.name, b.name)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const labels = this._sortedLabels;
|
||||
const visible = labels.slice(0, this._visibleCount);
|
||||
const hidden = labels.length - this._visibleCount;
|
||||
|
||||
const labels = this.labels.sort((a, b) => stringCompare(a.name, b.name));
|
||||
return html`
|
||||
<div class="viewport">
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
visible,
|
||||
(label) => label.label_id,
|
||||
(label) => this._renderLabel(label, true)
|
||||
)}
|
||||
${hidden > 0
|
||||
? html`
|
||||
<ha-dropdown
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${stopPropagation}
|
||||
@wa-select=${this._handleDropdownSelect}
|
||||
>
|
||||
<ha-label slot="trigger" class="plus" dense>
|
||||
+${hidden}
|
||||
</ha-label>
|
||||
${repeat(
|
||||
labels.slice(this._visibleCount),
|
||||
(label) => label.label_id,
|
||||
(label) => html`
|
||||
<ha-dropdown-item .value=${label.label_id} .item=${label}>
|
||||
${this._renderLabel(label, false)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
)}
|
||||
</ha-dropdown>
|
||||
`
|
||||
: nothing}
|
||||
</ha-chip-set>
|
||||
</div>
|
||||
|
||||
<div class="measure" aria-hidden="true">
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
labels,
|
||||
(label) => label.label_id,
|
||||
(label) => html`
|
||||
<div class="measure-chip" data-chip>
|
||||
${this._renderLabel(label, false)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div class="measure-chip" data-plus>
|
||||
<ha-label class="plus" dense>+99</ha-label>
|
||||
</div>
|
||||
</ha-chip-set>
|
||||
</div>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
labels.slice(0, 2),
|
||||
(label) => label.label_id,
|
||||
(label) => this._renderLabel(label, true)
|
||||
)}
|
||||
${labels.length > 2
|
||||
? html`<ha-dropdown
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${stopPropagation}
|
||||
@wa-select=${this._handleDropdownSelect}
|
||||
>
|
||||
<ha-label slot="trigger" class="plus" dense>
|
||||
+${labels.length - 2}
|
||||
</ha-label>
|
||||
${repeat(
|
||||
labels.slice(2),
|
||||
(label) => label.label_id,
|
||||
(label) => html`
|
||||
<ha-dropdown-item .value=${label.label_id} .item=${label}>
|
||||
${this._renderLabel(label, false)}
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
)}
|
||||
</ha-dropdown>`
|
||||
: nothing}
|
||||
</ha-chip-set>
|
||||
`;
|
||||
}
|
||||
|
||||
protected async firstUpdated() {
|
||||
await this.updateComplete;
|
||||
if (this._viewport) {
|
||||
this._resizeController.observe(this._viewport);
|
||||
}
|
||||
await this._measureWidths();
|
||||
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
|
||||
}
|
||||
|
||||
protected async updated(changedProps: Map<string, unknown>) {
|
||||
if (changedProps.has("labels")) {
|
||||
await this.updateComplete;
|
||||
await this._measureWidths();
|
||||
this._recomputeVisibleCount(this._viewport?.clientWidth ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
private async _measureWidths() {
|
||||
await this.updateComplete;
|
||||
|
||||
const measureRoot = this._measure;
|
||||
if (!measureRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const measureChipSet = measureRoot.querySelector("ha-chip-set");
|
||||
if (measureChipSet) {
|
||||
const styles = getComputedStyle(measureChipSet);
|
||||
const raw = styles.columnGap || styles.gap;
|
||||
this._gap = raw ? parseFloat(raw) : 0;
|
||||
}
|
||||
|
||||
const chipEls = Array.from(
|
||||
measureRoot.querySelectorAll<HTMLElement>("[data-chip]")
|
||||
);
|
||||
const plusEl = measureRoot.querySelector<HTMLElement>("[data-plus]");
|
||||
|
||||
this._chipWidths = chipEls.map((el) => el.offsetWidth);
|
||||
this._plusWidth = plusEl?.offsetWidth ?? 0;
|
||||
}
|
||||
|
||||
private _recomputeVisibleCount(containerWidth: number) {
|
||||
if (!containerWidth || !this.labels?.length) {
|
||||
this._visibleCount = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const total = this._sortedLabels.length;
|
||||
|
||||
let used = 0;
|
||||
let visibleCount = 0;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const chipWidth = this._chipWidths[i] ?? 0;
|
||||
const nextUsed =
|
||||
visibleCount === 0 ? chipWidth : used + this._gap + chipWidth;
|
||||
const remaining = total - (i + 1);
|
||||
const reserve = remaining > 0 ? this._gap + this._plusWidth : 0;
|
||||
|
||||
if (nextUsed + reserve <= containerWidth) {
|
||||
used = nextUsed;
|
||||
visibleCount++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
this._visibleCount = visibleCount;
|
||||
}
|
||||
|
||||
private _renderLabel(label: LabelRegistryEntry, clickAction: boolean) {
|
||||
return html`
|
||||
<ha-label
|
||||
@@ -217,43 +93,13 @@ class HaDataTableLabels extends LitElement {
|
||||
:host {
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
height: 22px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
display: flex;
|
||||
position: fixed;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.measure {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.measure ha-chip-set {
|
||||
width: max-content;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.measure-chip {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.plus {
|
||||
--ha-label-background-color: transparent;
|
||||
border: 1px solid var(--divider-color);
|
||||
|
||||
@@ -24,7 +24,6 @@ import "../ha-icon-button";
|
||||
import "../ha-icon-button-next";
|
||||
import "../ha-icon-button-prev";
|
||||
import "../ha-textarea";
|
||||
import type { HaTextArea } from "../ha-textarea";
|
||||
import "./date-range-picker";
|
||||
|
||||
export type DateRangePickerRanges = Record<string, [Date, Date]>;
|
||||
@@ -99,8 +98,6 @@ export class HaDateRangePicker extends LitElement {
|
||||
|
||||
@query(".container") private _containerElement?: HTMLDivElement;
|
||||
|
||||
@query("ha-textarea") private _textareaElement?: HaTextArea;
|
||||
|
||||
private _narrow = false;
|
||||
|
||||
private _unsubscribeTinyKeys?: () => void;
|
||||
@@ -338,8 +335,9 @@ export class HaDateRangePicker extends LitElement {
|
||||
};
|
||||
|
||||
private _setTextareaFocusStyle(focused: boolean) {
|
||||
if (this._textareaElement) {
|
||||
this._textareaElement.setFocused(focused);
|
||||
const textarea = this.renderRoot.querySelector("ha-textarea");
|
||||
if (textarea) {
|
||||
textarea.setFocused(focused);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { fullEntitiesContext } from "../../data/context";
|
||||
import type { DeviceAutomation } from "../../data/device/device_automation";
|
||||
import {
|
||||
@@ -14,7 +12,7 @@ import {
|
||||
sortDeviceAutomations,
|
||||
} from "../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { CallWS, HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-generic-picker";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
|
||||
@@ -48,14 +46,13 @@ export abstract class HaDeviceAutomationPicker<
|
||||
}
|
||||
|
||||
private _localizeDeviceAutomation: (
|
||||
localize: LocalizeFunc,
|
||||
states: HassEntities,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
automation: T
|
||||
) => string;
|
||||
|
||||
private _fetchDeviceAutomations: (
|
||||
callWS: CallWS,
|
||||
hass: HomeAssistant,
|
||||
deviceId: string
|
||||
) => Promise<T[]>;
|
||||
|
||||
@@ -130,8 +127,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
|
||||
const automationListItems = automations.map((automation, idx) => {
|
||||
const primary = this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this.hass,
|
||||
this._entityReg,
|
||||
automation
|
||||
);
|
||||
@@ -166,12 +162,7 @@ export abstract class HaDeviceAutomationPicker<
|
||||
);
|
||||
|
||||
const text = automation
|
||||
? this._localizeDeviceAutomation(
|
||||
this.hass.localize,
|
||||
this.hass.states,
|
||||
this._entityReg,
|
||||
automation
|
||||
)
|
||||
? this._localizeDeviceAutomation(this.hass, this._entityReg, automation)
|
||||
: value === NO_AUTOMATION_KEY
|
||||
? this.NO_AUTOMATION_TEXT
|
||||
: value;
|
||||
@@ -181,9 +172,9 @@ export abstract class HaDeviceAutomationPicker<
|
||||
|
||||
private async _updateDeviceInfo() {
|
||||
this._automations = this.deviceId
|
||||
? (
|
||||
await this._fetchDeviceAutomations(this.hass.callWS, this.deviceId)
|
||||
).sort(sortDeviceAutomations)
|
||||
? (await this._fetchDeviceAutomations(this.hass, this.deviceId)).sort(
|
||||
sortDeviceAutomations
|
||||
)
|
||||
: // No device, clear the list of automations
|
||||
[];
|
||||
|
||||
|
||||
@@ -6,7 +6,11 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { STATES_OFF } from "../../common/const";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import {
|
||||
UNAVAILABLE,
|
||||
UNKNOWN,
|
||||
isUnavailableState,
|
||||
} from "../../data/entity/entity";
|
||||
import { forwardHaptic } from "../../data/haptics";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-formfield";
|
||||
@@ -16,16 +20,7 @@ import "../ha-switch";
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
!STATES_OFF.includes(stateObj.state) &&
|
||||
stateObj.state !== UNAVAILABLE &&
|
||||
stateObj.state !== UNKNOWN;
|
||||
|
||||
/**
|
||||
* @element ha-entity-toggle
|
||||
*
|
||||
* @cssprop --ha-entity-toggle-switch-width - Width of the switch track. Defaults to `38px`.
|
||||
* @cssprop --ha-entity-toggle-switch-size - Height of the switch track. Defaults to `20px`.
|
||||
* @cssprop --ha-entity-toggle-switch-thumb-size - Size of the switch thumb. Defaults to `14px`.
|
||||
*/
|
||||
!isUnavailableState(stateObj.state);
|
||||
|
||||
@customElement("ha-entity-toggle")
|
||||
export class HaEntityToggle extends LitElement {
|
||||
@@ -170,9 +165,9 @@ export class HaEntityToggle extends LitElement {
|
||||
white-space: nowrap;
|
||||
}
|
||||
ha-switch {
|
||||
--ha-switch-width: var(--ha-entity-toggle-switch-width, 38px);
|
||||
--ha-switch-size: var(--ha-entity-toggle-switch-size, 20px);
|
||||
--ha-switch-thumb-size: var(--ha-entity-toggle-switch-thumb-size, 14px);
|
||||
--ha-switch-width: 38px;
|
||||
--ha-switch-size: 20px;
|
||||
--ha-switch-thumb-size: 14px;
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
|
||||
@@ -9,7 +9,7 @@ import secondsToDuration from "../../common/datetime/seconds_to_duration";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { FIXED_DOMAIN_STATES } from "../../common/entity/get_states";
|
||||
import { UNAVAILABLE, UNKNOWN } from "../../data/entity/entity";
|
||||
import { isUnavailableState, UNAVAILABLE } from "../../data/entity/entity";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
|
||||
import { timerTimeRemaining } from "../../data/timer";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
@@ -130,6 +130,7 @@ export class HaStateLabelBadge extends LitElement {
|
||||
? html`<ha-state-icon
|
||||
.icon=${this.icon}
|
||||
.stateObj=${entityState}
|
||||
.hass=${this.hass}
|
||||
></ha-state-icon>`
|
||||
: ""}
|
||||
${value && !image && !showIcon
|
||||
@@ -170,8 +171,7 @@ export class HaStateLabelBadge extends LitElement {
|
||||
}
|
||||
// eslint-disable-next-line: disable=no-fallthrough
|
||||
default:
|
||||
return entityState.state === UNAVAILABLE ||
|
||||
entityState.state === UNKNOWN
|
||||
return isUnavailableState(entityState.state)
|
||||
? "—"
|
||||
: this.hass!.formatEntityStateToParts(entityState).find(
|
||||
(part) => part.type === "value"
|
||||
@@ -210,7 +210,7 @@ export class HaStateLabelBadge extends LitElement {
|
||||
_timerTimeRemaining = 0
|
||||
) {
|
||||
// For unavailable states or certain domains, use a special translation that is truncated to fit within the badge label
|
||||
if (entityState.state === UNAVAILABLE || entityState.state === UNKNOWN) {
|
||||
if (isUnavailableState(entityState.state)) {
|
||||
return this.hass!.localize(`state_badge.default.${entityState.state}`);
|
||||
}
|
||||
const domainStateKey = getTruncatedKey(domain, entityState.state);
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import {
|
||||
mdiChartLine,
|
||||
mdiHelpCircleOutline,
|
||||
mdiPencil,
|
||||
mdiShape,
|
||||
} from "@mdi/js";
|
||||
import { mdiChartLine, mdiHelpCircleOutline, mdiShape } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
@@ -58,16 +53,6 @@ const SEARCH_KEYS = [
|
||||
{ name: "id", weight: 2 },
|
||||
];
|
||||
|
||||
export interface StatisticElementChangedEvent {
|
||||
statisticId: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"edit-statistics-element": StatisticElementChangedEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement("ha-statistic-picker")
|
||||
export class HaStatisticPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -145,8 +130,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@property({ attribute: "can-edit", type: Boolean }) public canEdit?: boolean;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
if (
|
||||
(!this.hasUpdated && !this.statisticIds) ||
|
||||
@@ -159,7 +142,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
private async _getStatisticIds() {
|
||||
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
|
||||
this._picker?.requestUpdate();
|
||||
this._valueRenderer = this._makeValueRenderer();
|
||||
}
|
||||
|
||||
private _getItems = () =>
|
||||
@@ -228,10 +210,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
const isRTL = computeRTL(hass);
|
||||
|
||||
const output: StatisticComboBoxItem[] = [];
|
||||
|
||||
@@ -335,7 +314,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _renderValue(value: string) {
|
||||
private _valueRenderer: PickerValueRenderer = (value) => {
|
||||
const statisticId = value;
|
||||
|
||||
const item = this._computeItem(statisticId);
|
||||
@@ -358,29 +337,8 @@ export class HaStatisticPicker extends LitElement {
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
${this.canEdit
|
||||
? html`<ha-icon-button
|
||||
slot="end"
|
||||
.value=${statisticId}
|
||||
.label=${this.hass.localize("ui.common.edit")}
|
||||
.path=${mdiPencil}
|
||||
@click=${this._editItem}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _makeValueRenderer(): PickerValueRenderer {
|
||||
return (value) => this._renderValue(value);
|
||||
}
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = this._makeValueRenderer();
|
||||
|
||||
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
|
||||
ev.stopPropagation();
|
||||
const statisticId = (ev.currentTarget as any).value;
|
||||
fireEvent(this, "edit-statistics-element", { statisticId });
|
||||
}
|
||||
};
|
||||
|
||||
private _computeItem(statisticId: string): StatisticComboBoxItem {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
@@ -395,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,10 +1,9 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ValueChangedEvent, HomeAssistant } from "../../types";
|
||||
import "./ha-statistic-picker";
|
||||
import type { StatisticElementChangedEvent } from "./ha-statistic-picker";
|
||||
|
||||
@customElement("ha-statistics-picker")
|
||||
class HaStatisticsPicker extends LitElement {
|
||||
@@ -60,8 +59,6 @@ class HaStatisticsPicker extends LitElement {
|
||||
})
|
||||
public ignoreRestrictionsOnFirstStatistic = false;
|
||||
|
||||
@property({ attribute: "can-edit", type: Boolean }) public canEdit?;
|
||||
|
||||
protected render() {
|
||||
if (!this.hass) {
|
||||
return nothing;
|
||||
@@ -102,9 +99,7 @@ class HaStatisticsPicker extends LitElement {
|
||||
.statisticIds=${this.statisticIds}
|
||||
.excludeStatistics=${this.value}
|
||||
.allowCustomEntity=${this.allowCustomEntity}
|
||||
.canEdit=${this.canEdit}
|
||||
@value-changed=${this._statisticChanged}
|
||||
@edit-statistics-element=${this._editItem}
|
||||
></ha-statistic-picker>
|
||||
</div>
|
||||
`
|
||||
@@ -127,17 +122,6 @@ class HaStatisticsPicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _editItem(ev: HASSDomEvent<StatisticElementChangedEvent>) {
|
||||
const statisticId = ev.detail.statisticId;
|
||||
const index = this._currentStatistics!.findIndex((e) => e === statisticId);
|
||||
fireEvent(this, "edit-detail-element", {
|
||||
subElementConfig: {
|
||||
index,
|
||||
type: "row",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private get _currentStatistics() {
|
||||
return this.value || [];
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -43,6 +43,7 @@ class StateInfo extends LitElement {
|
||||
)}:
|
||||
</span>
|
||||
<ha-relative-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.stateObj.last_changed}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
@@ -54,6 +55,7 @@ class StateInfo extends LitElement {
|
||||
)}:
|
||||
</span>
|
||||
<ha-relative-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.stateObj.last_updated}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
@@ -61,6 +63,7 @@ class StateInfo extends LitElement {
|
||||
</ha-tooltip>
|
||||
<ha-relative-time
|
||||
id="relative-time"
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.stateObj.last_changed}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
|
||||
@@ -1,34 +1,18 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { addDays, differenceInMilliseconds, startOfDay } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { transform } from "../common/decorators/transform";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { absoluteTime } from "../common/datetime/absolute_time";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../types";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
const SAFE_MARGIN = 5 * 1000;
|
||||
|
||||
@customElement("ha-absolute-time")
|
||||
class HaAbsoluteTime extends ReactiveElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public datetime?: string | Date;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _config?: HassConfig;
|
||||
|
||||
private _timeout?: number;
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
@@ -78,17 +62,13 @@ class HaAbsoluteTime extends ReactiveElement {
|
||||
}
|
||||
|
||||
private _updateAbsolute(): void {
|
||||
if (!this._i18n || !this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.datetime) {
|
||||
this.innerHTML = this._i18n.localize("ui.components.absolute_time.never");
|
||||
this.innerHTML = this.hass.localize("ui.components.absolute_time.never");
|
||||
} else {
|
||||
this.innerHTML = absoluteTime(
|
||||
new Date(this.datetime),
|
||||
this._i18n.locale,
|
||||
this._config
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { css, html, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { ScrollLockMixin } from "../mixins/scroll-lock-mixin";
|
||||
@@ -25,8 +25,6 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
|
||||
@state() private _shouldRenderPopover = false;
|
||||
|
||||
@query("wa-popover") private _popoverElement?: HTMLElement;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
changedProperties.has("dialogAnchor") ||
|
||||
@@ -190,7 +188,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
}
|
||||
|
||||
private _handlePopoverPointerDown(ev: PointerEvent) {
|
||||
const popover = this._popoverElement;
|
||||
const popover = this.renderRoot.querySelector("wa-popover");
|
||||
const dialog = popover?.shadowRoot?.querySelector(
|
||||
"dialog"
|
||||
) as HTMLDialogElement | null;
|
||||
@@ -217,7 +215,7 @@ export class HaAdaptivePopover extends ScrollLockMixin(HaAdaptiveDialog) {
|
||||
}
|
||||
|
||||
private _pulsePopover() {
|
||||
const popover = this._popoverElement;
|
||||
const popover = this.renderRoot.querySelector("wa-popover");
|
||||
const popup = popover?.shadowRoot?.querySelector("wa-popup") as {
|
||||
popup?: HTMLElement;
|
||||
} | null;
|
||||
|
||||
@@ -6,9 +6,8 @@ import {
|
||||
mdiInformationOutline,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-icon-button";
|
||||
@@ -40,9 +39,7 @@ class HaAlert extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public dismissable = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize?: LocalizeFunc;
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@@ -71,7 +68,7 @@ class HaAlert extends LitElement {
|
||||
${this.dismissable
|
||||
? html`<ha-icon-button
|
||||
@click=${this._dismissClicked}
|
||||
.label=${this._localize?.("ui.common.dismiss_alert")}
|
||||
.label=${this.localize!("ui.common.dismiss_alert")}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
|
||||
@@ -5,10 +5,10 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import "./ha-md-list-item";
|
||||
import "./ha-switch";
|
||||
import type { HaSwitch } from "./ha-switch";
|
||||
import "./ha-tooltip";
|
||||
import "./item/ha-row-item";
|
||||
import type { HaSwitch } from "./ha-switch";
|
||||
|
||||
const ADDITIONAL_PREFERENCES = ["usage", "statistics"] as const;
|
||||
|
||||
@@ -33,7 +33,7 @@ export class HaAnalytics extends LitElement {
|
||||
const baseEnabled = !loading && this.analytics!.preferences.base;
|
||||
|
||||
return html`
|
||||
<ha-row-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
|
||||
@@ -52,10 +52,10 @@ export class HaAnalytics extends LitElement {
|
||||
.disabled=${loading}
|
||||
name="base"
|
||||
></ha-switch>
|
||||
</ha-row-item>
|
||||
</ha-md-list-item>
|
||||
${ADDITIONAL_PREFERENCES.map(
|
||||
(preference) => html`
|
||||
<ha-row-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
|
||||
@@ -81,10 +81,10 @@ export class HaAnalytics extends LitElement {
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
|
||||
)}
|
||||
</ha-tooltip>`}
|
||||
</ha-row-item>
|
||||
</ha-md-list-item>
|
||||
`
|
||||
)}
|
||||
<ha-row-item>
|
||||
<ha-md-list-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
|
||||
@@ -103,7 +103,7 @@ export class HaAnalytics extends LitElement {
|
||||
.disabled=${loading}
|
||||
name="diagnostics"
|
||||
></ha-switch>
|
||||
</ha-row-item>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -139,8 +139,10 @@ export class HaAnalytics extends LitElement {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
ha-row-item {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
ha-md-list-item {
|
||||
--md-list-item-leading-space: 0;
|
||||
--md-list-item-trailing-space: 0;
|
||||
--md-item-overflow: visible;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
customElement,
|
||||
property,
|
||||
query,
|
||||
queryAll,
|
||||
state as litState,
|
||||
} from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
@@ -32,8 +31,6 @@ export class HaAnsiToHtml extends LitElement {
|
||||
|
||||
@query("pre") private _pre?: HTMLPreElement;
|
||||
|
||||
@queryAll("div") private _divs!: NodeListOf<HTMLDivElement>;
|
||||
|
||||
@litState() private _filter = "";
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -323,7 +320,7 @@ export class HaAnsiToHtml extends LitElement {
|
||||
*/
|
||||
filterLines(filter: string): boolean {
|
||||
this._filter = filter;
|
||||
const lines = this._divs;
|
||||
const lines = this.shadowRoot?.querySelectorAll("div") || [];
|
||||
let numberOfFoundLines = 0;
|
||||
if (!filter) {
|
||||
lines.forEach((line) => {
|
||||
|
||||
@@ -29,7 +29,7 @@ export interface AreaControlPickerItem extends PickerComboBoxItem {
|
||||
deviceClass?: string;
|
||||
}
|
||||
|
||||
const AREA_CONTROL_DOMAINS = [
|
||||
const AREA_CONTROL_DOMAINS: readonly AreaControlDomain[] = [
|
||||
"light",
|
||||
"fan",
|
||||
"switch",
|
||||
@@ -43,7 +43,7 @@ const AREA_CONTROL_DOMAINS = [
|
||||
"cover-door",
|
||||
"cover-window",
|
||||
"cover-damper",
|
||||
] as const satisfies readonly AreaControlDomain[];
|
||||
] as const;
|
||||
|
||||
@customElement("ha-area-controls-picker")
|
||||
export class HaAreaControlsPicker extends LitElement {
|
||||
@@ -130,7 +130,7 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
(excludeValues !== undefined && excludeValues.includes(id));
|
||||
|
||||
const controlEntities = getAreaControlEntities(
|
||||
AREA_CONTROL_DOMAINS,
|
||||
AREA_CONTROL_DOMAINS as unknown as AreaControlDomain[],
|
||||
areaId,
|
||||
excludeEntities,
|
||||
this.hass
|
||||
@@ -184,10 +184,7 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
const allEntityIds = Object.values(controlEntities).flat();
|
||||
const uniqueEntityIds = Array.from(new Set(allEntityIds));
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
uniqueEntityIds.forEach((entityId) => {
|
||||
if (isSelected(entityId)) {
|
||||
@@ -264,6 +261,7 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
${item.type === "entity" && item.stateObj
|
||||
? html`<ha-state-icon
|
||||
slot="start"
|
||||
.hass=${this.hass}
|
||||
.stateObj=${item.stateObj}
|
||||
></ha-state-icon>`
|
||||
: item.domain
|
||||
|
||||
@@ -61,6 +61,7 @@ export class HaAreasDisplayEditor extends LitElement {
|
||||
>
|
||||
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
|
||||
<ha-items-display-editor
|
||||
.hass=${this.hass}
|
||||
.items=${items}
|
||||
.value=${value}
|
||||
@value-changed=${this._areaDisplayChanged}
|
||||
|
||||
@@ -107,6 +107,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
|
||||
></ha-svg-icon>
|
||||
`}
|
||||
<ha-items-display-editor
|
||||
.hass=${this.hass}
|
||||
.items=${groupedAreasItems[floor.floor_id]}
|
||||
.value=${value}
|
||||
.floorId=${floor.floor_id}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
@@ -24,12 +24,11 @@ class HaBluePrintPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-select") private _select?: HTMLElement;
|
||||
|
||||
public open() {
|
||||
if (this._select) {
|
||||
const select = this.shadowRoot?.querySelector("ha-select");
|
||||
if (select) {
|
||||
// @ts-expect-error
|
||||
this._select.menuOpen = true;
|
||||
select.menuOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import type WaDrawer from "@home-assistant/webawesome/dist/components/drawer/drawer";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { css, html, LitElement, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { SwipeGestureRecognizer } from "../common/util/swipe-gesture-recognizer";
|
||||
import { configContext } from "../data/context";
|
||||
import { ScrollableFadeMixin } from "../mixins/scrollable-fade-mixin";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { isIosApp } from "../util/is_ios";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export const BOTTOM_SHEET_ANIMATION_DURATION_MS = 300;
|
||||
|
||||
@@ -49,6 +47,8 @@ const SWIPE_LOCKED_CLASSES = new Set(["volume-slider-container", "forecast"]);
|
||||
*/
|
||||
@customElement("ha-bottom-sheet")
|
||||
export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ attribute: "aria-labelledby" })
|
||||
public ariaLabelledBy?: string;
|
||||
|
||||
@@ -67,16 +67,10 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _sliderInteractionActive = false;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig?: ContextType<typeof configContext>;
|
||||
|
||||
@query("#drawer") private _drawer!: HTMLElement;
|
||||
|
||||
@query("#body") private _bodyElement!: HTMLDivElement;
|
||||
|
||||
@query("[autofocus]") private _autofocusElement?: HTMLElement;
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
return this._bodyElement;
|
||||
}
|
||||
@@ -95,25 +89,25 @@ export class HaBottomSheet extends ScrollableFadeMixin(LitElement) {
|
||||
await this.updateComplete;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const element = this._autofocusElement;
|
||||
if (
|
||||
this._hassConfig?.auth.external &&
|
||||
isIosApp(this._hassConfig.auth.external)
|
||||
) {
|
||||
if (element) {
|
||||
if (!element.id) {
|
||||
element.id = "ha-bottom-sheet-autofocus";
|
||||
}
|
||||
this._hassConfig.auth.external.fireMessage({
|
||||
type: "focus_element",
|
||||
payload: {
|
||||
element_id: element.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
element?.focus();
|
||||
// disabled till iOS app fix the "focus_element" implementation
|
||||
// if (this.hass && isIosApp(this.hass.auth.external)) {
|
||||
// const element = this.renderRoot.querySelector("[autofocus]");
|
||||
// if (element !== null) {
|
||||
// if (!element.id) {
|
||||
// element.id = "ha-bottom-sheet-autofocus";
|
||||
// }
|
||||
// this.hass.auth.external?.fireMessage({
|
||||
// type: "focus_element",
|
||||
// payload: {
|
||||
// element_id: element.id,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
(
|
||||
this.renderRoot.querySelector("[autofocus]") as HTMLElement | null
|
||||
)?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ export class HaCheckListItem extends CheckListItemBase {
|
||||
separateCheckboxClick = false;
|
||||
|
||||
async onChange(event) {
|
||||
event.stopPropagation();
|
||||
super.onChange(event);
|
||||
fireEvent(this, event.type);
|
||||
}
|
||||
|
||||