mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-09 19:02:50 +00:00
Compare commits
50 Commits
e2e-playwr
...
clock-date
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85e7a7c7af | ||
|
|
fa7341a473 | ||
|
|
b3d935bde2 | ||
|
|
929fd51f47 | ||
|
|
b5d61d4041 | ||
|
|
79780b111c | ||
|
|
a592a5f222 | ||
|
|
2ac8bf9179 | ||
|
|
fd21dd2fd4 | ||
|
|
370ccd95da | ||
|
|
ed2161effd | ||
|
|
19d902afc6 | ||
|
|
672d235c3e | ||
|
|
5246ce3f72 | ||
|
|
c83924efa7 | ||
|
|
bdbcec4d90 | ||
|
|
a074c80ec3 | ||
|
|
3795ad1253 | ||
|
|
7bb466a75b | ||
|
|
bff2514eed | ||
|
|
602d41b31d | ||
|
|
85d10cf982 | ||
|
|
a3ff3346db | ||
|
|
38a314ced4 | ||
|
|
2cf7452ed1 | ||
|
|
ae97cc1c8d | ||
|
|
65bba30266 | ||
|
|
8ee3544a32 | ||
|
|
fcddf8f548 | ||
|
|
c7824d4059 | ||
|
|
8c4f5206b1 | ||
|
|
cc2a7972fc | ||
|
|
33079bb12c | ||
|
|
34152e522e | ||
|
|
a0dc331056 | ||
|
|
4a56c1404f | ||
|
|
7e7845853d | ||
|
|
f8fe7a7d82 | ||
|
|
8b40b55324 | ||
|
|
ab55d1fdde | ||
|
|
597099f153 | ||
|
|
40ba2ade58 | ||
|
|
901fa4cdda | ||
|
|
edf007718a | ||
|
|
5abaeea1f9 | ||
|
|
1ce0a7eab2 | ||
|
|
c0c02eb548 | ||
|
|
18d5b84a02 | ||
|
|
ebc58f025a | ||
|
|
cb2758d868 |
296
.github/workflows/e2e.yaml
vendored
296
.github/workflows/e2e.yaml
vendored
@@ -1,296 +0,0 @@
|
||||
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:local
|
||||
|
||||
- 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) ─────────
|
||||
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 }}
|
||||
# Unique identifier so BrowserStack Local tunnels from parallel CI runs
|
||||
# don't collide with each other.
|
||||
BROWSERSTACK_LOCAL_IDENTIFIER: ${{ github.run_id }}-${{ github.run_attempt }}
|
||||
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/
|
||||
|
||||
# Start BrowserStack Local tunnel so BrowserStack's cloud browsers can
|
||||
# reach the tests running on localhost.
|
||||
- name: Start BrowserStack Local tunnel
|
||||
uses: browserstack/github-actions/setup-local@93aebce225b754566349151c0676b26b005e591b # v1.0.4
|
||||
with:
|
||||
local-testing: start
|
||||
local-identifier: ${{ env.BROWSERSTACK_LOCAL_IDENTIFIER }}
|
||||
|
||||
- name: Run Playwright tests (BrowserStack)
|
||||
run: yarn test:e2e:browserstack
|
||||
env:
|
||||
BROWSERSTACK: "true"
|
||||
BROWSERSTACK_LOCAL_IDENTIFIER: ${{ env.BROWSERSTACK_LOCAL_IDENTIFIER }}
|
||||
|
||||
- name: Stop BrowserStack Local tunnel
|
||||
uses: browserstack/github-actions/setup-local@93aebce225b754566349151c0676b26b005e591b # v1.0.4
|
||||
if: always()
|
||||
with:
|
||||
local-testing: stop
|
||||
local-identifier: ${{ env.BROWSERSTACK_LOCAL_IDENTIFIER }}
|
||||
|
||||
- name: Upload blob report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: blob-report-browserstack
|
||||
path: test/e2e/reports/
|
||||
retention-days: 3
|
||||
|
||||
# ── Merge reports and upload ──────────────────────────────────────
|
||||
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: Download blob report (BrowserStack)
|
||||
uses: actions/download-artifact@65a9edc5881444af0b9093a5e628f2fe47ea3b2e # v4.1.7
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: blob-report-browserstack
|
||||
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
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -54,14 +54,7 @@ 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
|
||||
|
||||
@@ -176,14 +176,11 @@ module.exports.babelOptions = ({
|
||||
{
|
||||
// Use unambiguous for dependencies so that require() is correctly injected into CommonJS files
|
||||
// Exclusions are needed in some cases where ES modules have no static imports or exports, such as polyfills
|
||||
// (otherwise babel-plugin-polyfill-corejs3 injects bare require("core-js/modules/...") calls
|
||||
// that rspack does not transform, causing ReferenceError in browsers like Safari 14).
|
||||
sourceType: "unambiguous",
|
||||
include: /\/node_modules\//,
|
||||
exclude: [
|
||||
"element-internals-polyfill",
|
||||
"@?lit(?:-labs|-element|-html)?",
|
||||
"@formatjs/(?:ecma402-abstract|intl-\\w+)",
|
||||
].map((p) => new RegExp(`/node_modules/${p}/`)),
|
||||
},
|
||||
],
|
||||
@@ -320,22 +317,4 @@ 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,13 +1,9 @@
|
||||
// @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,11 +0,0 @@
|
||||
// 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
|
||||
// ReferenceError on browsers (notably Safari 14) if environment
|
||||
// detection mis-classifies the page. Since browser bundles never need to
|
||||
// access Node built-in modules, return undefined unconditionally.
|
||||
//
|
||||
// Wired up via rspack `NormalModuleReplacementPlugin` in build-scripts/rspack.cjs.
|
||||
module.exports = function () {
|
||||
return undefined;
|
||||
};
|
||||
@@ -45,10 +45,3 @@ gulp.task(
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-e2e-test-app",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
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"
|
||||
)
|
||||
);
|
||||
@@ -266,24 +266,3 @@ 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,23 +201,3 @@ 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,7 +4,6 @@ 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";
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
createDemoConfig,
|
||||
createGalleryConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
const bothBuilds = (createConfigFunc, params) => [
|
||||
@@ -211,22 +210,3 @@ 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,15 +50,4 @@ 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"
|
||||
),
|
||||
};
|
||||
|
||||
@@ -173,16 +173,6 @@ const createRspackConfig = ({
|
||||
path.resolve(paths.root_dir, "src/util/empty.js")
|
||||
)
|
||||
: false,
|
||||
// core-js ships a Node-only helper that evaluates
|
||||
// `Function('return require("...")')()` when its runtime environment
|
||||
// detection mis-classifies the page as Node. That produces a
|
||||
// ReferenceError on browsers (observed on Safari 14). Since browser
|
||||
// bundles never need to access Node built-in modules, replace it with
|
||||
// a CommonJS no-op stub matching the helper's API (returns undefined).
|
||||
new rspack.NormalModuleReplacementPlugin(
|
||||
/core-js[\\/]internals[\\/]get-built-in-node-module(?:\.js)?$/,
|
||||
path.resolve(__dirname, "get-built-in-node-module-shim.cjs")
|
||||
),
|
||||
!isProdBuild && new LogStartCompilePlugin(),
|
||||
isProdBuild &&
|
||||
new StatsWriterPlugin({
|
||||
@@ -337,11 +327,6 @@ 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,
|
||||
@@ -349,5 +334,4 @@ module.exports = {
|
||||
createGalleryConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
};
|
||||
|
||||
@@ -42,7 +42,7 @@ class HcDemo extends HassElement {
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
const hass = provideHass(this, initial, true);
|
||||
const hass = (this.hass = provideHass(this, initial));
|
||||
|
||||
mockHistory(hass);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export class HaDemo extends HomeAssistantAppEl {
|
||||
this._updateHass(hassUpdate),
|
||||
};
|
||||
|
||||
const hass = provideHass(this, initial, true);
|
||||
const hass = (this.hass = provideHass(this, initial));
|
||||
const localizePromise =
|
||||
// @ts-ignore
|
||||
this._loadFragmentTranslations(hass.language, "page-demo").then(
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
});
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
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,5 +1,4 @@
|
||||
import type { LocalizeFunc } from "../../../src/common/translations/localize";
|
||||
import type { LovelaceInfo } from "../../../src/data/lovelace/resource";
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
import {
|
||||
selectedDemoConfig,
|
||||
@@ -28,9 +27,6 @@ export const mockLovelace = (
|
||||
);
|
||||
});
|
||||
|
||||
hass.mockWS("lovelace/info", () =>
|
||||
Promise.resolve({ resource_mode: "storage" } as LovelaceInfo)
|
||||
);
|
||||
hass.mockWS("lovelace/config/save", () => Promise.resolve());
|
||||
hass.mockWS("lovelace/resources", () => Promise.resolve([]));
|
||||
hass.mockWS("lovelace/dashboards/list", () => Promise.resolve([]));
|
||||
|
||||
@@ -1,56 +1,58 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
"gas",
|
||||
"data_size",
|
||||
"irradiance",
|
||||
"wind_speed",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"frequency",
|
||||
"precipitation_intensity",
|
||||
"volume",
|
||||
"precipitation",
|
||||
"battery",
|
||||
"nitrogen_dioxide",
|
||||
"speed",
|
||||
"signal_strength",
|
||||
"pm1",
|
||||
"nitrous_oxide",
|
||||
"atmospheric_pressure",
|
||||
"data_rate",
|
||||
"temperature",
|
||||
"power_factor",
|
||||
"aqi",
|
||||
"current",
|
||||
"volume_flow_rate",
|
||||
"humidity",
|
||||
"duration",
|
||||
"ozone",
|
||||
"distance",
|
||||
"pressure",
|
||||
"pm25",
|
||||
"weight",
|
||||
"energy",
|
||||
"carbon_monoxide",
|
||||
"apparent_power",
|
||||
"illuminance",
|
||||
"energy_storage",
|
||||
"moisture",
|
||||
"power",
|
||||
"water",
|
||||
"carbon_dioxide",
|
||||
"ph",
|
||||
"reactive_power",
|
||||
"monetary",
|
||||
"nitrogen_monoxide",
|
||||
"pm10",
|
||||
"sound_pressure",
|
||||
"sulphur_dioxide",
|
||||
],
|
||||
}));
|
||||
hass.mockWS("sensor/numeric_device_classes", () => [
|
||||
{
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
"gas",
|
||||
"data_size",
|
||||
"irradiance",
|
||||
"wind_speed",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
"frequency",
|
||||
"precipitation_intensity",
|
||||
"volume",
|
||||
"precipitation",
|
||||
"battery",
|
||||
"nitrogen_dioxide",
|
||||
"speed",
|
||||
"signal_strength",
|
||||
"pm1",
|
||||
"nitrous_oxide",
|
||||
"atmospheric_pressure",
|
||||
"data_rate",
|
||||
"temperature",
|
||||
"power_factor",
|
||||
"aqi",
|
||||
"current",
|
||||
"volume_flow_rate",
|
||||
"humidity",
|
||||
"duration",
|
||||
"ozone",
|
||||
"distance",
|
||||
"pressure",
|
||||
"pm25",
|
||||
"weight",
|
||||
"energy",
|
||||
"carbon_monoxide",
|
||||
"apparent_power",
|
||||
"illuminance",
|
||||
"energy_storage",
|
||||
"moisture",
|
||||
"power",
|
||||
"water",
|
||||
"carbon_dioxide",
|
||||
"ph",
|
||||
"reactive_power",
|
||||
"monetary",
|
||||
"nitrogen_monoxide",
|
||||
"pm10",
|
||||
"sound_pressure",
|
||||
"sulphur_dioxide",
|
||||
],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -228,12 +228,6 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
---
|
||||
title: List
|
||||
---
|
||||
|
||||
# List
|
||||
|
||||
The list family provides accessible, keyboard-navigable list containers and
|
||||
item variants. Pick the container based on semantics, then the item based on
|
||||
interactivity.
|
||||
|
||||
## Containers
|
||||
|
||||
### `<ha-list-base>`
|
||||
|
||||
A styled container with roving-tabindex keyboard navigation. Host role is
|
||||
`list`. Children should be `<ha-list-item-*>`. Arrow keys rove focus;
|
||||
Home/End jump to the first/last enabled item; Enter/Space activates the
|
||||
focused item.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------------ | ------- | ------- | ------------------------------ |
|
||||
| `wrap-focus` | Boolean | `false` | Arrow keys wrap past the ends. |
|
||||
| `aria-label` | String | — | Accessible name. |
|
||||
|
||||
**Events**
|
||||
|
||||
- `ha-list-activated` — Enter/Space on a focused item. Detail
|
||||
`{ index: number, item: HaListItemBase }`.
|
||||
|
||||
**Methods**
|
||||
|
||||
- `focus()` — focus the active item (or the first focusable one).
|
||||
- `focusItemAtIndex(index)` — make the item at `index` active and focus it.
|
||||
- `getActiveItemIndex()` — current active index, or `-1`.
|
||||
- `setActiveItemIndex(index, focusItem?)` — move the active index without
|
||||
necessarily focusing.
|
||||
- `updateListItems()` — re-discover slotted items (called automatically on
|
||||
slotchange).
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `base` — the outer `<div role="list">`.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-list-gap` — spacing between items. Defaults to `0`.
|
||||
- `--ha-list-padding` — padding around the list. Defaults to `0`.
|
||||
|
||||
### `<ha-list-selectable>`
|
||||
|
||||
Selectable list. Extends `ha-list-base`. Host role is `listbox`; items must be
|
||||
`<ha-list-item-option>` (role `option`). Set `multi` for multi-select; the
|
||||
host reflects `aria-multiselectable`.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ------- | ------- | ------- | -------------------------------------- |
|
||||
| `multi` | Boolean | `false` | Allow multiple options to be selected. |
|
||||
|
||||
**Events**
|
||||
|
||||
- `ha-list-selected` — selection changed. Detail
|
||||
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
|
||||
`index` is a `number` in single mode (`-1` when nothing selected) and a
|
||||
`Set<number>` in multi mode.
|
||||
|
||||
**Methods / getters**
|
||||
|
||||
- `selected` (getter) — current selection (`number` or `Set<number>`).
|
||||
- `selectedItems` (getter) — selected `HaListItemOption` elements, in index
|
||||
order.
|
||||
- `setSelected(indices)` — replace the entire selection.
|
||||
- `select(index)` — add `index` to the selection (replaces in single mode).
|
||||
- `toggle(index, force?)` — toggle a single index, or force on/off.
|
||||
- `clearSelection()` — clear all.
|
||||
|
||||
### `<ha-list-nav>`
|
||||
|
||||
Same as `ha-list-base`, but wrapped in a `<nav>` landmark
|
||||
(`<nav><div role="list">…</div></nav>`). Use `aria-label` to name the
|
||||
landmark — the value is forwarded to the inner `<nav>`. Items should be
|
||||
`<ha-list-item-button>` with an `href`.
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `nav` — the `<nav>` wrapper.
|
||||
- `base` — the inner `<div role="list">`.
|
||||
|
||||
## Items
|
||||
|
||||
All items inherit from `ha-row-item`, which provides the row layout and the
|
||||
shared slots/attributes below.
|
||||
|
||||
### Shared row layout (`ha-row-item`)
|
||||
|
||||
**Slots**
|
||||
|
||||
- `start` — leading container (icon/avatar).
|
||||
- `end` — trailing container (meta/chevron).
|
||||
- `headline` — primary text (overrides the `headline` attribute).
|
||||
- `supporting-text` — secondary text (overrides the `supporting-text` attribute).
|
||||
- `content` — escape hatch: replaces the entire middle column.
|
||||
|
||||
**Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| ----------------- | ------- | ------- | --------------------------------------- |
|
||||
| `headline` | String | — | Primary text. Overridden by the slot. |
|
||||
| `supporting-text` | String | — | Secondary text. Overridden by the slot. |
|
||||
| `disabled` | Boolean | `false` | Dims the row and blocks pointer events. |
|
||||
|
||||
**CSS parts**
|
||||
|
||||
`base`, `start`, `content`, `headline`, `supporting-text`, `end`.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-row-item-padding-block` — vertical padding.
|
||||
- `--ha-row-item-padding-inline` — horizontal padding.
|
||||
- `--ha-row-item-gap` — gap between `start`, `content`, and `end`.
|
||||
- `--ha-row-item-min-height` — minimum row height (default `48px`).
|
||||
|
||||
### `<ha-list-item-base>`
|
||||
|
||||
Non-interactive list row. Host role is `listitem`. Inherits everything from
|
||||
`ha-row-item`.
|
||||
|
||||
**Attributes**
|
||||
|
||||
- `interactive` (Boolean, default `false`) — opt this row into the parent
|
||||
list's roving tabindex. Useful for sortable rows that need keyboard focus
|
||||
but no click action. Interactive subclasses set this automatically.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-list-item-focus-radius` — focus outline border-radius.
|
||||
- `--ha-list-item-focus-width` — focus outline width (steady state).
|
||||
- `--ha-list-item-focus-width-start` — focus outline width at the start of
|
||||
the focus-in animation.
|
||||
- `--ha-list-item-focus-offset` — focus outline offset.
|
||||
- `--ha-list-item-focus-background` — background color on keyboard focus.
|
||||
|
||||
### `<ha-list-item-button>`
|
||||
|
||||
Interactive row. Renders an inner `<a>` when `href` is set, otherwise a
|
||||
`<button>`. The full row is the hit target. When placed inside a list using
|
||||
roving tabindex, the host is the tab stop and the inner element carries
|
||||
`tabindex="-1"`.
|
||||
|
||||
**Attributes**
|
||||
|
||||
- `href` (String) — when set, renders an `<a>` instead of a `<button>`.
|
||||
- `target` (String) — anchor `target` (requires `href`).
|
||||
- `rel` (String) — anchor `rel` (requires `href`).
|
||||
- `download` (String) — anchor `download` (requires `href`).
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `ripple` — the ripple effect element.
|
||||
|
||||
### `<ha-list-item-option>`
|
||||
|
||||
Selectable row. Host role is `option`; reflects `aria-selected`. Designed to
|
||||
sit inside `<ha-list-selectable>`, which owns selection state and toggles
|
||||
`selected` on this item — the option itself does not fire selection events.
|
||||
|
||||
**Attributes**
|
||||
|
||||
- `selected` (Boolean, default `false`, reflected) — set by the parent
|
||||
`ha-list-selectable`.
|
||||
- `value` (String) — value identifying the option.
|
||||
- `appearance` (`"line"` | `"checkbox"`, default `"line"`) — `"line"`
|
||||
highlights the row; `"checkbox"` renders a decorative `<ha-checkbox>`.
|
||||
- `selection-position` (`"start"` | `"end"`, default `"start"`) — side the
|
||||
checkbox sits on when `appearance="checkbox"`.
|
||||
|
||||
**CSS parts**
|
||||
|
||||
- `checkbox` — wrapper around the `<ha-checkbox>` when `appearance="checkbox"`.
|
||||
- `ripple` — the ripple effect element.
|
||||
|
||||
**CSS custom properties**
|
||||
|
||||
- `--ha-list-item-selected-background` — background color when selected
|
||||
(`appearance="line"`).
|
||||
@@ -1,415 +0,0 @@
|
||||
import {
|
||||
mdiAccount,
|
||||
mdiChevronRight,
|
||||
mdiCog,
|
||||
mdiHome,
|
||||
mdiInformationOutline,
|
||||
mdiMapMarker,
|
||||
mdiOpenInNew,
|
||||
mdiViewDashboard,
|
||||
mdiWifi,
|
||||
} from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import "../../../../src/components/item/ha-list-item-base";
|
||||
import "../../../../src/components/item/ha-list-item-button";
|
||||
import "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-base";
|
||||
import "../../../../src/components/list/ha-list-nav";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
|
||||
|
||||
type Appearance = "line" | "checkbox";
|
||||
type Position = "start" | "end";
|
||||
|
||||
const appearances: Appearance[] = ["line", "checkbox"];
|
||||
const positions: Position[] = ["start", "end"];
|
||||
const selectedStates = [false, true];
|
||||
const disabledStates = [false, true];
|
||||
|
||||
@customElement("demo-components-ha-list")
|
||||
export class DemoHaList extends LitElement {
|
||||
@state() private _buttonClicks = 0;
|
||||
|
||||
@state() private _single: number | Set<number> = -1;
|
||||
|
||||
@state() private _multiLine: number | Set<number> = new Set();
|
||||
|
||||
@state() private _multiCheckStart: number | Set<number> = new Set();
|
||||
|
||||
@state() private _multiCheckEnd: number | Set<number> = new Set();
|
||||
|
||||
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<h2>ha-list-base</h2>
|
||||
<p>
|
||||
Styled container with keyboard focus navigation. Children should be
|
||||
<code>ha-list-item-*</code>.
|
||||
</p>
|
||||
|
||||
<ha-card header="Info list (non-interactive rows)">
|
||||
<ha-list-base aria-label="Device info">
|
||||
<ha-list-item-base
|
||||
headline="IP address"
|
||||
supporting-text="192.168.1.42"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiWifi}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Location" supporting-text="Living room">
|
||||
<ha-svg-icon slot="start" .path=${mdiMapMarker}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Firmware" supporting-text="2026.4.1">
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiInformationOutline}
|
||||
></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Vertical list (default)">
|
||||
<ha-list-base aria-label="Example list">
|
||||
<ha-list-item-button>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<span slot="headline">First row</span>
|
||||
<span slot="supporting-text">Supporting text</span>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<ha-svg-icon slot="start" .path=${mdiAccount}></ha-svg-icon>
|
||||
<span slot="headline">Second row</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button disabled>
|
||||
<span slot="headline">Disabled row</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">Fourth row</span>
|
||||
</ha-list-item-button>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Vertical list with wrap-focus">
|
||||
<ha-list-base wrap-focus aria-label="Wrap focus">
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">A</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">B</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button>
|
||||
<span slot="headline">C</span>
|
||||
</ha-list-item-button>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-item-base</h2>
|
||||
<p>Non-interactive base row with slot permutations.</p>
|
||||
|
||||
<ha-card header="Slot permutations">
|
||||
<ha-list-base aria-label="Slot permutations">
|
||||
<ha-list-item-base headline="Headline only"></ha-list-item-base>
|
||||
<ha-list-item-base
|
||||
headline="Headline"
|
||||
supporting-text="Supporting text"
|
||||
></ha-list-item-base>
|
||||
<ha-list-item-base headline="Start + headline">
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Start + headline + end">
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base
|
||||
headline="Full row"
|
||||
supporting-text="All slots filled"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base>
|
||||
<div slot="content" class="custom-content">
|
||||
<strong>Custom content escape hatch</strong>
|
||||
<span>Replaces the whole middle column</span>
|
||||
</div>
|
||||
</ha-list-item-base>
|
||||
<ha-list-item-base headline="Disabled row" disabled>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
</ha-list-item-base>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-item-button</h2>
|
||||
<p>
|
||||
Interactive row. Renders an inner <code><a></code> when
|
||||
<code>href</code> is set, otherwise a <code><button></code>.
|
||||
</p>
|
||||
|
||||
<ha-card header="Button (default) / link (with href)">
|
||||
<ha-list-base aria-label="Button items">
|
||||
<ha-list-item-button @click=${this._onButtonClick}>
|
||||
<ha-svg-icon slot="start" .path=${mdiHome}></ha-svg-icon>
|
||||
<span slot="headline">Button (clicks: ${this._buttonClicks})</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button
|
||||
href="https://www.home-assistant.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
<span slot="headline">Link (opens in new tab)</span>
|
||||
<span slot="supporting-text"
|
||||
>Cmd/Ctrl-click still opens in new tab</span
|
||||
>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button disabled>
|
||||
<span slot="headline">Disabled button</span>
|
||||
</ha-list-item-button>
|
||||
<ha-list-item-button href="#nope" disabled>
|
||||
<span slot="headline">Disabled link</span>
|
||||
</ha-list-item-button>
|
||||
</ha-list-base>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-selectable + ha-list-item-option</h2>
|
||||
<p>
|
||||
Selectable list (<code>role="listbox"</code>). Items must be
|
||||
<code>ha-list-item-option</code>. Set <code>multi</code> for
|
||||
multi-select.
|
||||
</p>
|
||||
|
||||
<ha-card header="Single select, appearance=line">
|
||||
<ha-list-selectable
|
||||
aria-label="Single select"
|
||||
@ha-list-selected=${this._onSingle}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._single, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>selected: ${JSON.stringify(this._toJson(this._single))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Multi select, appearance=line">
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi select line"
|
||||
@ha-list-selected=${this._onMultiLine}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._multiLine, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>selected: ${JSON.stringify(this._toJson(this._multiLine))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
header='Multi select, appearance=checkbox, selection-position="start"'
|
||||
>
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox start"
|
||||
@ha-list-selected=${this._onMultiCheckStart}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="start"
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._multiCheckStart, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
|
||||
>
|
||||
</ha-card>
|
||||
|
||||
<ha-card
|
||||
header='Multi select, appearance=checkbox, selection-position="end"'
|
||||
>
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox end"
|
||||
@ha-list-selected=${this._onMultiCheckEnd}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${o}
|
||||
?selected=${this._isSel(this._multiCheckEnd, i)}
|
||||
>
|
||||
<span slot="headline">${o}</span>
|
||||
<span slot="supporting-text">${o.length} characters</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
<pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
|
||||
>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Option: all combinations">
|
||||
<div class="grid">
|
||||
${appearances.map((appearance) =>
|
||||
positions.map((position) =>
|
||||
selectedStates.map((selected) =>
|
||||
disabledStates.map(
|
||||
(disabled) => html`
|
||||
<div role="listbox" class="wrap" aria-label="single option">
|
||||
<ha-list-item-option
|
||||
appearance=${appearance}
|
||||
selection-position=${position}
|
||||
?selected=${selected}
|
||||
?disabled=${disabled}
|
||||
>
|
||||
<span slot="headline"
|
||||
>${appearance} / pos=${position}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>selected=${String(selected)}
|
||||
disabled=${String(disabled)}</span
|
||||
>
|
||||
</ha-list-item-option>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<h2>ha-list-nav</h2>
|
||||
<p>
|
||||
Same as <code>ha-list-base</code> but wrapped in a
|
||||
<code><nav></code> landmark.
|
||||
</p>
|
||||
|
||||
<ha-card header="Sidebar-style navigation">
|
||||
<ha-list-nav aria-label="Primary navigation">
|
||||
${[
|
||||
{ name: "Overview", path: "#overview", icon: mdiHome },
|
||||
{ name: "Dashboards", path: "#dashboards", icon: mdiViewDashboard },
|
||||
{ name: "Map", path: "#map", icon: mdiMapMarker },
|
||||
{ name: "Settings", path: "#settings", icon: mdiCog },
|
||||
].map(
|
||||
(p) => html`
|
||||
<ha-list-item-button .href=${p.path}>
|
||||
<ha-svg-icon slot="start" .path=${p.icon}></ha-svg-icon>
|
||||
<span slot="headline">${p.name}</span>
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</ha-list-item-button>
|
||||
`
|
||||
)}
|
||||
</ha-list-nav>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
private _isSel(value: number | Set<number>, index: number): boolean {
|
||||
if (typeof value === "number") {
|
||||
return value === index;
|
||||
}
|
||||
return value.has(index);
|
||||
}
|
||||
|
||||
private _toJson(value: number | Set<number>): unknown {
|
||||
return value instanceof Set ? [...value] : value;
|
||||
}
|
||||
|
||||
private _onButtonClick = () => {
|
||||
this._buttonClicks++;
|
||||
};
|
||||
|
||||
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._single = ev.detail.index;
|
||||
};
|
||||
|
||||
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiLine = ev.detail.index;
|
||||
};
|
||||
|
||||
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiCheckStart = ev.detail.index;
|
||||
};
|
||||
|
||||
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiCheckEnd = ev.detail.index;
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
padding: var(--ha-space-6);
|
||||
}
|
||||
h2 {
|
||||
margin: var(--ha-space-4) 0 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
p {
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
ha-card {
|
||||
max-width: 560px;
|
||||
}
|
||||
pre {
|
||||
padding: var(--ha-space-4);
|
||||
background: var(--secondary-background-color);
|
||||
margin: 0;
|
||||
}
|
||||
.custom-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-1);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--ha-space-3);
|
||||
padding: var(--ha-space-3);
|
||||
}
|
||||
.wrap {
|
||||
border: 1px solid var(--divider-color);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
}
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-list": DemoHaList;
|
||||
}
|
||||
}
|
||||
@@ -43,22 +43,12 @@ const fullOptions: SelectBoxOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const manyOptions: SelectBoxOption[] = [
|
||||
{ value: "opt1", label: "Option 1" },
|
||||
{ value: "opt2", label: "Option 2" },
|
||||
{ value: "opt3", label: "Option 3" },
|
||||
{ value: "opt4", label: "Option 4" },
|
||||
{ value: "opt5", label: "Option 5" },
|
||||
{ value: "opt6", label: "Option 6" },
|
||||
];
|
||||
|
||||
const selects: {
|
||||
id: string;
|
||||
label: string;
|
||||
class?: string;
|
||||
options: SelectBoxOption[];
|
||||
disabled?: boolean;
|
||||
maxColumns?: number;
|
||||
}[] = [
|
||||
{
|
||||
id: "basic",
|
||||
@@ -70,12 +60,6 @@ const selects: {
|
||||
label: "With description and image",
|
||||
options: fullOptions,
|
||||
},
|
||||
{
|
||||
id: "two-columns",
|
||||
label: "2 columns (maxColumns=2)",
|
||||
options: manyOptions,
|
||||
maxColumns: 2,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-ha-select-box")
|
||||
@@ -83,14 +67,13 @@ export class DemoHaSelectBox extends LitElement {
|
||||
@state() private value?: string = "off";
|
||||
|
||||
handleValueChanged(e: CustomEvent) {
|
||||
console.log(e.detail.value);
|
||||
this.value = e.detail.value as string;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${repeat(selects, (select) => {
|
||||
const { id, label, options, maxColumns } = select;
|
||||
const { id, label, options } = select;
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
@@ -98,7 +81,6 @@ export class DemoHaSelectBox extends LitElement {
|
||||
<ha-select-box
|
||||
.value=${this.value}
|
||||
.options=${options}
|
||||
.maxColumns=${maxColumns}
|
||||
@value-changed=${this.handleValueChanged}
|
||||
>
|
||||
</ha-select-box>
|
||||
|
||||
@@ -52,7 +52,6 @@ const SENSOR_DEVICE_CLASSES = [
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"timestamp",
|
||||
"uptime",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { extractSearchParam } from "../../src/common/url/search-params";
|
||||
import "../../src/components/ha-alert";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/animation/ha-fade-in";
|
||||
import "../../src/components/ha-fade-in";
|
||||
import "../../src/components/ha-spinner";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/progress/ha-progress-bar";
|
||||
|
||||
60
package.json
60
package.json
@@ -20,20 +20,7 @@
|
||||
"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:e2e": "yarn test:e2e:demo && yarn test:e2e:app && yarn test:e2e:gallery && node test/e2e/collect-blob-reports.mjs && npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob",
|
||||
"test:e2e:local": "yarn test:e2e:demo:local && yarn test:e2e:app:local && yarn test:e2e:gallery:local && node test/e2e/collect-blob-reports.mjs && npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob",
|
||||
"test:e2e:browserstack": "yarn test:e2e:demo:browserstack && yarn test:e2e:app:browserstack && yarn test:e2e:gallery:browserstack && node test/e2e/collect-blob-reports.mjs && npx playwright merge-reports -c test/e2e/playwright.merge.config.ts test/e2e/reports/blob",
|
||||
"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:local": "playwright test --config test/e2e/playwright.demo.config.ts --project=local",
|
||||
"test:e2e:demo:browserstack": "BROWSERSTACK=true playwright test --config test/e2e/playwright.demo.config.ts",
|
||||
"test:e2e:app": "playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:app:local": "playwright test --config test/e2e/playwright.app.config.ts --project=local",
|
||||
"test:e2e:app:browserstack": "BROWSERSTACK=true playwright test --config test/e2e/playwright.app.config.ts",
|
||||
"test:e2e:gallery": "playwright test --config test/e2e/playwright.gallery.config.ts",
|
||||
"test:e2e:gallery:local": "playwright test --config test/e2e/playwright.gallery.config.ts --project=local",
|
||||
"test:e2e:gallery:browserstack": "BROWSERSTACK=true playwright test --config test/e2e/playwright.gallery.config.ts"
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
"license": "Apache-2.0",
|
||||
@@ -46,28 +33,27 @@
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/lint": "6.9.5",
|
||||
"@codemirror/search": "6.7.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.1",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@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",
|
||||
"@formatjs/intl-datetimeformat": "7.4.0",
|
||||
"@formatjs/intl-displaynames": "7.3.3",
|
||||
"@formatjs/intl-durationformat": "0.10.5",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.4",
|
||||
"@formatjs/intl-listformat": "8.3.3",
|
||||
"@formatjs/intl-locale": "5.3.3",
|
||||
"@formatjs/intl-numberformat": "9.3.3",
|
||||
"@formatjs/intl-pluralrules": "6.3.3",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.3",
|
||||
"@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.3.1-ha.3",
|
||||
"@home-assistant/webawesome": "3.3.1-ha.1",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -79,6 +65,7 @@
|
||||
"@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-radio": "0.27.0",
|
||||
"@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",
|
||||
@@ -112,7 +99,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.3",
|
||||
"intl-messageformat": "11.2.2",
|
||||
"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",
|
||||
@@ -120,7 +107,7 @@
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.3",
|
||||
"marked": "18.0.2",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -146,18 +133,17 @@
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.3",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.60.0",
|
||||
"@html-eslint/eslint-plugin": "0.59.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",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "2.0.1",
|
||||
"@rspack/dev-server": "2.0.1",
|
||||
"@rspack/core": "2.0.0",
|
||||
"@rspack/dev-server": "2.0.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
@@ -180,7 +166,7 @@
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.3.0",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
@@ -191,14 +177,14 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.5.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "29.1.1",
|
||||
"jsdom": "29.0.2",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
@@ -214,7 +200,7 @@
|
||||
"terser-webpack-plugin": "5.5.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.1",
|
||||
"typescript-eslint": "8.59.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.5",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -227,7 +213,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"globals": "17.6.0",
|
||||
"globals": "17.5.0",
|
||||
"tslib": "2.8.1",
|
||||
"@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"
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="88" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="48" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="68" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<path d="M107.667 18.1167V14.7833H100.233L100.208 13.1083H107.667V9.78333L111.833 13.95L107.667 18.1167Z" fill="#B1B1B1"/>
|
||||
<rect x="124" width="88" height="28" rx="8" fill="white"/>
|
||||
<rect x="124.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="132" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="172" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<path d="M237.5 13.1667H222.5V11.5H237.5V13.1667ZM237.5 14.8333H222.5V16.5H237.5V14.8333Z" fill="#B1B1B1"/>
|
||||
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="#5E5E5E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H80C84.4183 0 88 3.58172 88 8V20C88 24.4183 84.4183 28 80 28H8C3.58172 28 0 24.4183 0 20V8Z" fill="#1C1C1C"/>
|
||||
<path d="M8 0.5H80C84.1421 0.5 87.5 3.85786 87.5 8V20C87.5 24.1421 84.1421 27.5 80 27.5H8C3.85786 27.5 0.5 24.1421 0.5 20V8C0.5 3.85786 3.85786 0.5 8 0.5Z" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="48" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="68" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<path d="M109.333 16.45C108.718 17.065 107.667 16.6294 107.667 15.7596C107.667 15.2204 107.23 14.7833 106.69 14.7833H101.058C100.601 14.7833 100.228 14.4159 100.221 13.9583C100.214 13.4909 100.591 13.1083 101.058 13.1083H106.693C107.231 13.1083 107.667 12.6723 107.667 12.1345C107.667 11.2668 108.716 10.8323 109.329 11.4458L110.613 12.7296C111.287 13.4036 111.287 14.4964 110.613 15.1704L109.333 16.45Z" fill="white" fill-opacity="0.48"/>
|
||||
<path d="M124 8C124 3.58172 127.582 0 132 0H204C208.418 0 212 3.58172 212 8V20C212 24.4183 208.418 28 204 28H132C127.582 28 124 24.4183 124 20V8Z" fill="#1C1C1C"/>
|
||||
<path d="M132 0.5H204C208.142 0.5 211.5 3.85786 211.5 8V20C211.5 24.1421 208.142 27.5 204 27.5H132C127.858 27.5 124.5 24.1421 124.5 20V8C124.5 3.85786 127.858 0.5 132 0.5Z" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="132" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="172" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<path d="M237.5 12.3333C237.5 12.7936 237.127 13.1667 236.667 13.1667H223.333C222.873 13.1667 222.5 12.7936 222.5 12.3333C222.5 11.8731 222.873 11.5 223.333 11.5H236.667C237.127 11.5 237.5 11.8731 237.5 12.3333ZM237.5 15.6667C237.5 15.2064 237.127 14.8333 236.667 14.8333H223.333C222.873 14.8333 222.5 15.2064 222.5 15.6667C222.5 16.1269 222.873 16.5 223.333 16.5H236.667C237.127 16.5 237.5 16.1269 237.5 15.6667Z" fill="white" fill-opacity="0.48"/>
|
||||
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="88" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="28" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="48" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="68" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<path d="M107.667 18.1167V14.7833H100.233L100.208 13.1083H107.667V9.78333L111.833 13.95L107.667 18.1167Z" fill="#B1B1B1"/>
|
||||
<rect x="124" width="88" height="28" rx="8" fill="white"/>
|
||||
<rect x="124.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="132" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="152" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="192" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<path d="M237.5 13.1667H222.5V11.5H237.5V13.1667ZM237.5 14.8333H222.5V16.5H237.5V14.8333Z" fill="#B1B1B1"/>
|
||||
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="#5E5E5E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H80C84.4183 0 88 3.58172 88 8V20C88 24.4183 84.4183 28 80 28H8C3.58172 28 0 24.4183 0 20V8Z" fill="#1C1C1C"/>
|
||||
<path d="M8 0.5H80C84.1421 0.5 87.5 3.85786 87.5 8V20C87.5 24.1421 84.1421 27.5 80 27.5H8C3.85786 27.5 0.5 24.1421 0.5 20V8C0.5 3.85786 3.85786 0.5 8 0.5Z" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="28" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="48" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="68" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<path d="M109.333 16.45C108.718 17.065 107.667 16.6294 107.667 15.7596C107.667 15.2204 107.23 14.7833 106.69 14.7833H101.058C100.601 14.7833 100.228 14.4159 100.221 13.9583C100.214 13.4909 100.591 13.1083 101.058 13.1083H106.693C107.231 13.1083 107.667 12.6723 107.667 12.1345C107.667 11.2668 108.716 10.8323 109.329 11.4458L110.613 12.7296C111.287 13.4036 111.287 14.4964 110.613 15.1704L109.333 16.45Z" fill="white" fill-opacity="0.48"/>
|
||||
<path d="M124 8C124 3.58172 127.582 0 132 0H204C208.418 0 212 3.58172 212 8V20C212 24.4183 208.418 28 204 28H132C127.582 28 124 24.4183 124 20V8Z" fill="#1C1C1C"/>
|
||||
<path d="M132 0.5H204C208.142 0.5 211.5 3.85786 211.5 8V20C211.5 24.1421 208.142 27.5 204 27.5H132C127.858 27.5 124.5 24.1421 124.5 20V8C124.5 3.85786 127.858 0.5 132 0.5Z" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="132" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="152" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="192" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<path d="M237.5 12.3333C237.5 12.7936 237.127 13.1667 236.667 13.1667H223.333C222.873 13.1667 222.5 12.7936 222.5 12.3333C222.5 11.8731 222.873 11.5 223.333 11.5H236.667C237.127 11.5 237.5 11.8731 237.5 12.3333ZM237.5 15.6667C237.5 15.2064 237.127 14.8333 236.667 14.8333H223.333C222.873 14.8333 222.5 15.2064 222.5 15.6667C222.5 16.1269 222.873 16.5 223.333 16.5H236.667C237.127 16.5 237.5 16.1269 237.5 15.6667Z" fill="white" fill-opacity="0.48"/>
|
||||
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="88" height="28" rx="8" fill="white"/>
|
||||
<rect x="0.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="8" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="48" y="8" width="12" height="12" rx="3" fill="black" fill-opacity="0.12"/>
|
||||
<rect x="68" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<path d="M107.667 18.1167V14.7833H100.233L100.208 13.1083H107.667V9.78333L111.833 13.95L107.667 18.1167Z" fill="#B1B1B1"/>
|
||||
<rect x="124" width="88" height="28" rx="8" fill="white"/>
|
||||
<rect x="124.5" y="0.5" width="87" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<rect x="132" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<path d="M237.5 13.1667H222.5V11.5H237.5V13.1667ZM237.5 14.8333H222.5V16.5H237.5V14.8333Z" fill="#B1B1B1"/>
|
||||
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="#5E5E5E"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,17 +0,0 @@
|
||||
<svg width="268" height="28" viewBox="0 0 268 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0 8C0 3.58172 3.58172 0 8 0H80C84.4183 0 88 3.58172 88 8V20C88 24.4183 84.4183 28 80 28H8C3.58172 28 0 24.4183 0 20V8Z" fill="#1C1C1C"/>
|
||||
<path d="M8 0.5H80C84.1421 0.5 87.5 3.85786 87.5 8V20C87.5 24.1421 84.1421 27.5 80 27.5H8C3.85786 27.5 0.5 24.1421 0.5 20V8C0.5 3.85786 3.85786 0.5 8 0.5Z" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="8" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="28" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="48" y="8" width="12" height="12" rx="3" fill="white" fill-opacity="0.24"/>
|
||||
<rect x="68" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<path d="M109.333 16.45C108.718 17.065 107.667 16.6294 107.667 15.7596C107.667 15.2204 107.23 14.7833 106.69 14.7833H101.058C100.601 14.7833 100.228 14.4159 100.221 13.9583C100.214 13.4909 100.591 13.1083 101.058 13.1083H106.693C107.231 13.1083 107.667 12.6723 107.667 12.1345C107.667 11.2668 108.716 10.8323 109.329 11.4458L110.613 12.7296C111.287 13.4036 111.287 14.4964 110.613 15.1704L109.333 16.45Z" fill="white" fill-opacity="0.48"/>
|
||||
<path d="M124 8C124 3.58172 127.582 0 132 0H204C208.418 0 212 3.58172 212 8V20C212 24.4183 208.418 28 204 28H132C127.582 28 124 24.4183 124 20V8Z" fill="#1C1C1C"/>
|
||||
<path d="M132 0.5H204C208.142 0.5 211.5 3.85786 211.5 8V20C211.5 24.1421 208.142 27.5 204 27.5H132C127.858 27.5 124.5 24.1421 124.5 20V8C124.5 3.85786 127.858 0.5 132 0.5Z" stroke="white" stroke-opacity="0.24"/>
|
||||
<rect x="132" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="152" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="172" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<rect x="192" y="8" width="12" height="12" rx="3" fill="#009AC7"/>
|
||||
<path d="M237.5 12.3333C237.5 12.7936 237.127 13.1667 236.667 13.1667H223.333C222.873 13.1667 222.5 12.7936 222.5 12.3333C222.5 11.8731 222.873 11.5 223.333 11.5H236.667C237.127 11.5 237.5 11.8731 237.5 12.3333ZM237.5 15.6667C237.5 15.2064 237.127 14.8333 236.667 14.8333H223.333C222.873 14.8333 222.5 15.2064 222.5 15.6667C222.5 16.1269 222.873 16.5 223.333 16.5H236.667C237.127 16.5 237.5 16.1269 237.5 15.6667Z" fill="white" fill-opacity="0.48"/>
|
||||
<path d="M257.167 16.5H253L258.833 4.83337V11.5H263L257.167 23.1667V16.5Z" fill="white" fill-opacity="0.24"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260429.0"
|
||||
version = "20260325.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { isUnavailableState } from "../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface EntityUnitStubConfig {
|
||||
entity: string;
|
||||
attribute?: string;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes the display unit for an entity.
|
||||
*
|
||||
* @param hass - Home Assistant instance
|
||||
* @param stateObj - Entity state object
|
||||
* @param config - Element configuration
|
||||
* @returns Computed entity unit
|
||||
*/
|
||||
export const computeEntityUnitDisplay = (
|
||||
hass: HomeAssistant,
|
||||
stateObj: HassEntity | undefined,
|
||||
config: EntityUnitStubConfig
|
||||
): string => {
|
||||
let unit;
|
||||
if (
|
||||
stateObj &&
|
||||
!isUnavailableState(stateObj.state) &&
|
||||
(config.attribute || stateObj.attributes.device_class !== "duration")
|
||||
) {
|
||||
// check for an explicitly defined unit in config
|
||||
unit = config.unit;
|
||||
|
||||
if (!unit) {
|
||||
if (!config.attribute) {
|
||||
// use entity's unit_of_measurement
|
||||
const stateParts = hass.formatEntityStateToParts(stateObj);
|
||||
unit = stateParts.find((part) => part.type === "unit")?.value;
|
||||
} else {
|
||||
// use attribute's unit if available
|
||||
const attrParts = hass.formatEntityAttributeValueToParts(
|
||||
stateObj,
|
||||
config.attribute
|
||||
);
|
||||
unit = attrParts.find((part) => part.type === "unit")?.value;
|
||||
}
|
||||
}
|
||||
|
||||
return unit ?? "";
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { blankBeforeUnit } from "../translations/blank_before_unit";
|
||||
import type { LocalizeFunc } from "../translations/localize";
|
||||
import { computeDomain } from "./compute_domain";
|
||||
import { SENSOR_TIMESTAMP_DEVICE_CLASSES } from "../../data/sensor";
|
||||
|
||||
export const computeStateDisplay = (
|
||||
localize: LocalizeFunc,
|
||||
@@ -267,8 +266,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
"wake_word",
|
||||
"datetime",
|
||||
].includes(domain) ||
|
||||
(domain === "sensor" &&
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
|
||||
(domain === "sensor" && attributes.device_class === "timestamp")
|
||||
) {
|
||||
try {
|
||||
return [
|
||||
|
||||
@@ -225,7 +225,6 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
"sulphur_dioxide",
|
||||
"temperature",
|
||||
"timestamp",
|
||||
"uptime",
|
||||
"volatile_organic_compounds",
|
||||
"volatile_organic_compounds_parts",
|
||||
"voltage",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Generates an RFC 4122 v4 UUID. Falls back to crypto.getRandomValues when
|
||||
// crypto.randomUUID is unavailable (e.g. non-secure HTTP contexts on a LAN).
|
||||
export const generateUuidV4 = (): string => {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
/* eslint-disable no-bitwise */
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
/* eslint-enable no-bitwise */
|
||||
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
|
||||
""
|
||||
);
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
|
||||
};
|
||||
@@ -10,17 +10,6 @@ export const setViewTransitionDisabled = (disabled: boolean): void => {
|
||||
isViewTransitionDisabled = disabled;
|
||||
};
|
||||
|
||||
const isAbortError = (err: unknown): boolean =>
|
||||
err instanceof DOMException
|
||||
? err.name === "AbortError"
|
||||
: err instanceof Error && err.name === "AbortError";
|
||||
|
||||
const ignoreAbortError = (err: unknown): void => {
|
||||
if (!isAbortError(err)) {
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Executes a synchronous callback within a View Transition if supported, otherwise runs it directly.
|
||||
*
|
||||
@@ -51,8 +40,7 @@ export const withViewTransition = (
|
||||
callbackInvoked = true;
|
||||
callback(true);
|
||||
});
|
||||
transition.ready.catch(ignoreAbortError);
|
||||
return transition.finished.catch(ignoreAbortError);
|
||||
return transition.finished;
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import WaAnimation from "@home-assistant/webawesome/dist/components/animation/animation";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@customElement("ha-fade-out")
|
||||
export class HaFadeOut extends WaAnimation {
|
||||
@property({ type: Boolean }) public play = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.iterations = 1;
|
||||
this.fill = "both";
|
||||
this.name = "fadeOut";
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-fade-out": HaFadeOut;
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import "@home-assistant/webawesome/dist/components/animation/animation";
|
||||
import { mdiInformationOutline } from "@mdi/js";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import "../animation/ha-fade-in";
|
||||
import "../animation/ha-fade-out";
|
||||
import "../ha-icon-button";
|
||||
|
||||
@customElement("ha-automation-row-event-chip")
|
||||
export class HaAutomationRowEventChip extends LitElement {
|
||||
@property({ reflect: true })
|
||||
public variant: "info" | "warning" | "success" | "danger" | "neutral" =
|
||||
"info";
|
||||
|
||||
@property({ type: Boolean })
|
||||
public interactive = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public show = false;
|
||||
|
||||
@state()
|
||||
private _hide = false;
|
||||
|
||||
@state()
|
||||
private _highlight = 0;
|
||||
|
||||
willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("show")) {
|
||||
this._highlight = 0;
|
||||
|
||||
if (!this.show && this.hasUpdated) {
|
||||
this._hide = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (!this.show && !this._hide) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
let base = html`<div><slot></slot></div>`;
|
||||
|
||||
if (this.interactive) {
|
||||
base = html`<button>
|
||||
<slot></slot>
|
||||
<ha-svg-icon .path=${mdiInformationOutline}></ha-svg-icon>
|
||||
</button>`;
|
||||
}
|
||||
|
||||
if (this.show && this._highlight) {
|
||||
return keyed(
|
||||
this._highlight,
|
||||
html`
|
||||
<wa-animation fill="both" .iterations=${1} name="headShake" play
|
||||
>${base}</wa-animation
|
||||
>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.show && this._hide) {
|
||||
return html`
|
||||
<ha-fade-out @wa-finish=${this._handleHideFinish}>${base}</ha-fade-out>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`<ha-fade-in .duration=${250}>${base}</ha-fade-in>`;
|
||||
}
|
||||
|
||||
public highlight() {
|
||||
this._highlight += 1;
|
||||
}
|
||||
|
||||
private _handleHideFinish() {
|
||||
this._hide = false;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
--background-color: var(--ha-color-fill-primary-normal-resting);
|
||||
--background-color-hover: var(--ha-color-fill-primary-normal-hover);
|
||||
--text-color: var(--ha-color-on-primary-normal);
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
|
||||
:host([variant="warning"]) {
|
||||
--background-color: var(--ha-color-fill-warning-normal-resting);
|
||||
--background-color-hover: var(--ha-color-fill-warning-normal-hover);
|
||||
--text-color: var(--ha-color-on-warning-normal);
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
--background-color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--background-color-hover: var(--ha-color-fill-neutral-normal-hover);
|
||||
--text-color: var(--ha-color-on-neutral-normal);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
--background-color: var(--ha-color-fill-success-normal-resting);
|
||||
--background-color-hover: var(--ha-color-fill-success-normal-hover);
|
||||
--text-color: var(--ha-color-on-success-normal);
|
||||
}
|
||||
|
||||
:host([variant="danger"]) {
|
||||
--background-color: var(--ha-color-fill-danger-normal-resting);
|
||||
--background-color-hover: var(--ha-color-fill-danger-normal-hover);
|
||||
--text-color: var(--ha-color-on-danger-normal);
|
||||
}
|
||||
|
||||
button,
|
||||
div {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
color: var(--text-color);
|
||||
display: inline-flex;
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-1) var(--ha-space-2);
|
||||
align-items: center;
|
||||
--mdc-icon-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--background-color-hover);
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: var(--wa-focus-ring);
|
||||
outline-offset: var(--wa-focus-ring-offset);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-automation-row-event-chip": HaAutomationRowEventChip;
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
"toggle-collapsed": undefined;
|
||||
"stop-sort-selection": undefined;
|
||||
"copy-row": undefined;
|
||||
"cut-row": undefined;
|
||||
"delete-row": undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,6 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiCheckCircle,
|
||||
mdiChevronDown,
|
||||
mdiChevronUp,
|
||||
mdiCircleOutline,
|
||||
mdiRestart,
|
||||
} from "@mdi/js";
|
||||
import { mdiChevronDown, mdiChevronUp, mdiRestart } from "@mdi/js";
|
||||
import { differenceInMinutes } from "date-fns";
|
||||
import type { DataZoomComponentOption } from "echarts/components";
|
||||
import type { EChartsType } from "echarts/core";
|
||||
@@ -53,10 +47,6 @@ export type CustomLegendOption = ECOption["legend"] & {
|
||||
name: string;
|
||||
value?: string; // Current value to display next to the name in the legend.
|
||||
itemStyle?: Record<string, any>;
|
||||
// If true, label click does not fire `legend-label-click` even when the
|
||||
// chart has `clickLabelForMoreInfo`; falls back to toggle. Used for items
|
||||
// without a corresponding entity (e.g. external statistics).
|
||||
noLabelClick?: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -91,9 +81,6 @@ export class HaChartBase extends LitElement {
|
||||
})
|
||||
private _themes!: Themes;
|
||||
|
||||
@property({ attribute: "click-label-for-more-info", type: Boolean })
|
||||
public clickLabelForMoreInfo = false;
|
||||
|
||||
@state() private _isZoomed = false;
|
||||
|
||||
@state() private _zoomRatio = 1;
|
||||
@@ -375,7 +362,6 @@ export class HaChartBase extends LitElement {
|
||||
let itemStyle: Record<string, any> = {};
|
||||
let id = "";
|
||||
let value = "";
|
||||
let noLabelClick = false;
|
||||
const name = typeof item === "string" ? item : (item.name ?? "");
|
||||
if (typeof item === "string") {
|
||||
id = item;
|
||||
@@ -383,9 +369,7 @@ export class HaChartBase extends LitElement {
|
||||
id = item.id ?? name;
|
||||
value = item.value ?? "";
|
||||
itemStyle = item.itemStyle ?? {};
|
||||
noLabelClick = item.noLabelClick ?? false;
|
||||
}
|
||||
const labelClickable = this.clickLabelForMoreInfo && !noLabelClick;
|
||||
const dataset =
|
||||
datasets.find((d) => d.id === id) ??
|
||||
datasets.find((d) => d.name === id);
|
||||
@@ -395,43 +379,26 @@ export class HaChartBase extends LitElement {
|
||||
...itemStyle,
|
||||
};
|
||||
const color = itemStyle?.color as string;
|
||||
const borderColor = itemStyle?.borderColor as string;
|
||||
return html`<li
|
||||
.id=${id}
|
||||
@click=${this._legendClick}
|
||||
@pointerdown=${this._legendPointerDown}
|
||||
@pointerup=${this._legendPointerCancel}
|
||||
@pointerleave=${this._legendPointerCancel}
|
||||
@pointercancel=${this._legendPointerCancel}
|
||||
@contextmenu=${this._legendContextMenu}
|
||||
class=${classMap({ hidden: this._hiddenDatasets.has(id) })}
|
||||
.title=${name}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="legend-toggle"
|
||||
data-id=${id}
|
||||
aria-pressed=${!this._hiddenDatasets.has(id)}
|
||||
.title=${this.hass.localize(
|
||||
"ui.components.history_charts.toggle_visibility"
|
||||
)}
|
||||
@click=${this._toggleDataset}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${this._hiddenDatasets.has(id)
|
||||
? mdiCircleOutline
|
||||
: mdiCheckCircle}
|
||||
style=${styleMap({
|
||||
color: this._hiddenDatasets.has(id) ? undefined : color,
|
||||
})}
|
||||
></ha-svg-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class=${classMap({ label: true, clickable: labelClickable })}
|
||||
data-id=${id}
|
||||
.title=${name}
|
||||
@click=${this._labelClick}
|
||||
>
|
||||
${name}
|
||||
</button>
|
||||
<div
|
||||
class="bullet"
|
||||
style=${styleMap({
|
||||
backgroundColor: color,
|
||||
borderColor: borderColor || color,
|
||||
})}
|
||||
></div>
|
||||
<div class="label">${name}</div>
|
||||
${value ? html`<div class="value">${value}</div>` : nothing}
|
||||
</li>`;
|
||||
})}
|
||||
@@ -1194,8 +1161,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleDataset(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
private _legendClick(ev: MouseEvent) {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
@@ -1203,46 +1169,13 @@ export class HaChartBase extends LitElement {
|
||||
this._longPressTriggered = false;
|
||||
return;
|
||||
}
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.id;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const id = (ev.currentTarget as HTMLElement)?.id;
|
||||
// Cmd+click on Mac (Ctrl+click is right-click there), Ctrl+click elsewhere
|
||||
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
|
||||
if (soloModifier) {
|
||||
this._soloLegend(id);
|
||||
return;
|
||||
}
|
||||
this._handleDatasetToggle(id);
|
||||
}
|
||||
|
||||
private _labelClick(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (this._longPressTriggered) {
|
||||
this._longPressTriggered = false;
|
||||
return;
|
||||
}
|
||||
const target = ev.currentTarget as HTMLElement;
|
||||
const id = target.dataset.id;
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const soloModifier = isMac ? ev.metaKey : ev.ctrlKey;
|
||||
if (soloModifier) {
|
||||
this._soloLegend(id);
|
||||
return;
|
||||
}
|
||||
if (target.classList.contains("clickable")) {
|
||||
fireEvent(this, "legend-label-click", { id });
|
||||
} else {
|
||||
this._handleDatasetToggle(id);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDatasetToggle(id: string) {
|
||||
if (this._hiddenDatasets.has(id)) {
|
||||
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
|
||||
this._hiddenDatasets.delete(i)
|
||||
@@ -1457,6 +1390,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
.chart-legend li {
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 2px;
|
||||
@@ -1473,54 +1407,33 @@ export class HaChartBase extends LitElement {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.chart-legend .label {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: start;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
line-height: 1;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.chart-legend .label.clickable:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.chart-legend .legend-toggle:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.chart-legend .value {
|
||||
color: var(--secondary-text-color);
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.chart-legend .legend-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
margin-inline-end: 0;
|
||||
.chart-legend .bullet {
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
display: block;
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
margin-inline-end: 4px;
|
||||
margin-inline-start: initial;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.chart-legend .legend-toggle:focus-visible,
|
||||
.chart-legend .label:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--ha-border-radius-small, 4px);
|
||||
}
|
||||
.chart-legend .legend-toggle ha-svg-icon {
|
||||
--mdc-icon-size: 18px;
|
||||
.chart-legend .hidden .bullet {
|
||||
border-color: var(--secondary-text-color) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
ha-assist-chip {
|
||||
height: 100%;
|
||||
@@ -1540,7 +1453,6 @@ declare global {
|
||||
"dataset-hidden": { id: string };
|
||||
"dataset-unhidden": { id: string };
|
||||
"chart-click": ECElementEvent;
|
||||
"legend-label-click": { id: string };
|
||||
"chart-zoom": {
|
||||
start: number;
|
||||
end: number;
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
formatNumber,
|
||||
} from "../../common/number/format_number";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
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";
|
||||
@@ -37,21 +36,6 @@ const CLIMATE_MODE_CONFIGS = [
|
||||
{ mode: "fan_only", action: "fan", cssVar: "--state-climate-fan_only-color" },
|
||||
] as const;
|
||||
|
||||
// Used to recover the underlying entity_id from a legend dataset id.
|
||||
// Kept in sync with the suffixes appended at dataset construction below
|
||||
// for climate / water_heater / humidifier multi-attribute charts.
|
||||
const ENTITY_DATASET_SUFFIXES = [
|
||||
"-current_temperature",
|
||||
"-target_temperature",
|
||||
"-target_temperature_mode",
|
||||
"-target_temperature_mode_low",
|
||||
...CLIMATE_MODE_CONFIGS.map((c) => `-${c.action}`),
|
||||
"-current_humidity",
|
||||
"-target_humidity",
|
||||
"-humidifying",
|
||||
"-on",
|
||||
];
|
||||
|
||||
@customElement("state-history-chart-line")
|
||||
export class StateHistoryChartLine extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -60,11 +44,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public names?: Record<string, string>;
|
||||
|
||||
@property({ attribute: false }) public colors?: Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
@property() public unit?: string;
|
||||
|
||||
@property() public identifier?: string;
|
||||
@@ -133,8 +112,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
@chart-zoom=${this._handleDataZoom}
|
||||
.expandLegend=${this.expandLegend}
|
||||
.hideResetButton=${this.hideResetButton}
|
||||
.clickLabelForMoreInfo=${this.clickForMoreInfo}
|
||||
@legend-label-click=${this._handleLegendLabelClick}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
@@ -246,24 +223,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleLegendLabelClick(
|
||||
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
|
||||
) {
|
||||
const id = ev.detail.id;
|
||||
let entityId = id;
|
||||
if (!this.hass.states[entityId]) {
|
||||
for (const suffix of ENTITY_DATASET_SUFFIXES) {
|
||||
if (id.endsWith(suffix)) {
|
||||
entityId = id.slice(0, -suffix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.hass.states[entityId]) {
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
changedProps.has("data") ||
|
||||
@@ -440,11 +399,9 @@ export class StateHistoryChartLine extends LitElement {
|
||||
this._chartTime = new Date();
|
||||
const endTime = this.endTime;
|
||||
const names = this.names || {};
|
||||
const colors = this.colors || {};
|
||||
entityStates.forEach((states, dataIdx) => {
|
||||
const domain = states.domain;
|
||||
const name = names[states.entity_id] || states.name;
|
||||
const color = colors[states.entity_id];
|
||||
// array containing [value1, value2, etc]
|
||||
let prevValues: any[] | null = null;
|
||||
|
||||
@@ -475,11 +432,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const addDataSet = (
|
||||
id: string,
|
||||
nameY: string,
|
||||
clr?: string,
|
||||
color?: string,
|
||||
fill = false
|
||||
) => {
|
||||
if (!clr) {
|
||||
clr = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
if (!color) {
|
||||
color = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
colorIndex++;
|
||||
}
|
||||
data.push({
|
||||
@@ -488,7 +445,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
type: "line",
|
||||
cursor: "default",
|
||||
name: nameY,
|
||||
color: clr,
|
||||
color,
|
||||
symbol: "circle",
|
||||
symbolSize: 1,
|
||||
step: "end",
|
||||
@@ -499,7 +456,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
areaStyle: fill
|
||||
? {
|
||||
color: clr + "7F",
|
||||
color: color + "7F",
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
@@ -747,7 +704,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(states.entity_id, name, color);
|
||||
addDataSet(states.entity_id, name);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: Date;
|
||||
|
||||
@@ -52,11 +52,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public names?: Record<string, string>;
|
||||
|
||||
@property({ attribute: false }) public colors?: Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public virtualize = false;
|
||||
|
||||
@property({ attribute: false }) public endTime?: Date;
|
||||
@@ -186,7 +181,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
.endTime=${this._computedEndTime}
|
||||
.paddingYAxis=${this._maxYWidth}
|
||||
.names=${this.names}
|
||||
.colors=${this.colors}
|
||||
.chartIndex=${index}
|
||||
.clickForMoreInfo=${this.clickForMoreInfo}
|
||||
.logarithmicScale=${this.logarithmicScale}
|
||||
@@ -405,12 +399,12 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
.entry-container {
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.entry-container.line {
|
||||
flex: 1;
|
||||
padding-top: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
|
||||
@@ -10,8 +10,6 @@ import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getGraphColorByIndex } from "../../common/color/colors";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import {
|
||||
@@ -48,13 +46,6 @@ export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
change: "sum",
|
||||
};
|
||||
|
||||
// When the chart has a single entity, ha-chart-base falls back to raw series
|
||||
// ids (`${statistic_id}-${type}`) for the legend (see _legendData branch at
|
||||
// the bottom of _generateData). Strip the type suffix to recover statistic_id.
|
||||
const STAT_TYPE_SUFFIXES = (
|
||||
Object.keys(supportedStatTypeMap) as StatisticType[]
|
||||
).map((t) => `-${t}`);
|
||||
|
||||
@customElement("statistics-chart")
|
||||
export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -68,11 +59,6 @@ export class StatisticsChart extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public names?: Record<string, string>;
|
||||
|
||||
@property({ attribute: false }) public colors?: Record<
|
||||
string,
|
||||
string | undefined
|
||||
>;
|
||||
|
||||
@property() public unit?: string;
|
||||
|
||||
@property({ attribute: false }) public startTime?: Date;
|
||||
@@ -200,9 +186,6 @@ export class StatisticsChart extends LitElement {
|
||||
@dataset-hidden=${this._datasetHidden}
|
||||
@dataset-unhidden=${this._datasetUnhidden}
|
||||
.expandLegend=${this.expandLegend}
|
||||
.clickLabelForMoreInfo=${this.clickForMoreInfo &&
|
||||
!this._statisticIds.every(isExternalStatistic)}
|
||||
@legend-label-click=${this._handleLegendLabelClick}
|
||||
></ha-chart-base>
|
||||
`;
|
||||
}
|
||||
@@ -217,28 +200,6 @@ export class StatisticsChart extends LitElement {
|
||||
this.requestUpdate("_hiddenStats");
|
||||
}
|
||||
|
||||
private _handleLegendLabelClick(
|
||||
ev: HASSDomEvent<HASSDomEvents["legend-label-click"]>
|
||||
) {
|
||||
const id = ev.detail.id;
|
||||
// External statistics aren't real entities; nothing to open.
|
||||
if (isExternalStatistic(id)) {
|
||||
return;
|
||||
}
|
||||
let entityId = id;
|
||||
if (!this.hass.states[entityId]) {
|
||||
for (const suffix of STAT_TYPE_SUFFIXES) {
|
||||
if (id.endsWith(suffix)) {
|
||||
entityId = id.slice(0, -suffix.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.hass.states[entityId]) {
|
||||
fireEvent(this, "hass-more-info", { entityId });
|
||||
}
|
||||
}
|
||||
|
||||
private _renderTooltip = (params: any) => {
|
||||
const rendered: Record<string, boolean> = {};
|
||||
const unit = this.unit
|
||||
@@ -439,7 +400,6 @@ export class StatisticsChart extends LitElement {
|
||||
name: string;
|
||||
color?: ZRColor;
|
||||
borderColor?: ZRColor;
|
||||
noLabelClick?: boolean;
|
||||
}[] = [];
|
||||
const statisticIds: string[] = [];
|
||||
let endTime: Date;
|
||||
@@ -490,7 +450,6 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
|
||||
const names = this.names || {};
|
||||
const colors = this.colors || {};
|
||||
statisticsData.forEach(([statistic_id, stats]) => {
|
||||
const meta = statisticsMetaData?.[statistic_id];
|
||||
let name = names[statistic_id];
|
||||
@@ -535,14 +494,11 @@ export class StatisticsChart extends LitElement {
|
||||
prevEndTime = end;
|
||||
};
|
||||
|
||||
let color = colors[statistic_id];
|
||||
if (color === undefined) {
|
||||
color = getGraphColorByIndex(
|
||||
colorIndex,
|
||||
this._computedStyle || getComputedStyle(this)
|
||||
);
|
||||
colorIndex++;
|
||||
}
|
||||
const color = getGraphColorByIndex(
|
||||
colorIndex,
|
||||
this._computedStyle || getComputedStyle(this)
|
||||
);
|
||||
colorIndex++;
|
||||
|
||||
const statTypes: this["statTypes"] = [];
|
||||
|
||||
@@ -647,7 +603,6 @@ export class StatisticsChart extends LitElement {
|
||||
name,
|
||||
color: series.color as ZRColor,
|
||||
borderColor: series.itemStyle?.borderColor,
|
||||
noLabelClick: isExternalStatistic(statistic_id),
|
||||
});
|
||||
}
|
||||
displayedLegend = displayedLegend || showLegend;
|
||||
@@ -783,11 +738,7 @@ export class StatisticsChart extends LitElement {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
this._legendData =
|
||||
legendData.length > 1
|
||||
? legendData.map(({ id, name, noLabelClick }) => ({
|
||||
id,
|
||||
name,
|
||||
noLabelClick,
|
||||
}))
|
||||
? legendData.map(({ id, name }) => ({ id, name }))
|
||||
: // if there is only one entity, let the base chart handle the legend
|
||||
undefined;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiPlus, mdiShape } from "@mdi/js";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
@@ -129,20 +129,6 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _pendingEntityId?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
changedProperties.has("hass") &&
|
||||
this.hass.states !== changedProperties.get("hass")?.states &&
|
||||
this.hass.states[this._pendingEntityId]
|
||||
) {
|
||||
this._setValue(this._pendingEntityId);
|
||||
this._pendingEntityId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Load title translations so it is available when the combo-box opens
|
||||
@@ -413,13 +399,7 @@ export class HaEntityPicker extends LitElement {
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
}
|
||||
if (item.entityId) this._setValue(item.entityId);
|
||||
},
|
||||
});
|
||||
return;
|
||||
|
||||
@@ -13,9 +13,9 @@ import {
|
||||
} from "../../data/entity/entity";
|
||||
import { forwardHaptic } from "../../data/haptics";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-control-switch";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-switch";
|
||||
|
||||
const isOn = (stateObj?: HassEntity) =>
|
||||
stateObj !== undefined &&
|
||||
@@ -35,7 +35,7 @@ export class HaEntityToggle extends LitElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
if (!this.stateObj) {
|
||||
return html`<ha-switch disabled></ha-switch> `;
|
||||
return html`<ha-control-switch disabled></ha-control-switch> `;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -62,14 +62,14 @@ export class HaEntityToggle extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const switchTemplate = html`<ha-switch
|
||||
const switchTemplate = html`<ha-control-switch
|
||||
aria-label=${`Toggle ${computeStateName(this.stateObj)} ${
|
||||
this._isOn ? "off" : "on"
|
||||
}`}
|
||||
.checked=${this._isOn}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@change=${this._toggleChanged}
|
||||
></ha-switch>`;
|
||||
></ha-control-switch>`;
|
||||
|
||||
if (!this.label) {
|
||||
return switchTemplate;
|
||||
@@ -160,14 +160,12 @@ export class HaEntityToggle extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
min-width: 38px;
|
||||
}
|
||||
ha-switch {
|
||||
--ha-switch-width: 38px;
|
||||
--ha-switch-size: 20px;
|
||||
--ha-switch-thumb-size: 14px;
|
||||
ha-control-switch {
|
||||
--control-switch-thickness: 20px;
|
||||
--control-switch-off-color: var(--state-inactive-color);
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
@@ -85,20 +85,6 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _pendingAreaId?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
this._pendingAreaId &&
|
||||
changedProperties.has("hass") &&
|
||||
this.hass.areas !== changedProperties.get("hass")?.areas &&
|
||||
this.hass.areas[this._pendingAreaId]
|
||||
) {
|
||||
this._setValue(this._pendingAreaId);
|
||||
this._pendingAreaId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
@@ -257,11 +243,7 @@ export class HaAreaPicker extends LitElement {
|
||||
createEntry: async (values) => {
|
||||
try {
|
||||
const area = await createAreaRegistryEntry(this.hass, values);
|
||||
if (this.hass.areas[area.area_id]) {
|
||||
this._setValue(area.area_id);
|
||||
} else {
|
||||
this._pendingAreaId = area.area_id;
|
||||
}
|
||||
this._setValue(area.area_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -2,8 +2,8 @@ import { mdiChevronUp } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-icon-button";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-icon-button";
|
||||
|
||||
@customElement("ha-automation-row")
|
||||
export class HaAutomationRow extends LitElement {
|
||||
@@ -27,9 +27,6 @@ export class HaAutomationRow extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public highlight?: boolean;
|
||||
|
||||
@property({ type: Boolean, reflect: true })
|
||||
public dim = false;
|
||||
|
||||
@query(".row")
|
||||
private _rowElement?: HTMLDivElement;
|
||||
|
||||
@@ -54,11 +51,7 @@ export class HaAutomationRow extends LitElement {
|
||||
<div class="leading-icon-wrapper">
|
||||
<slot name="leading-icon"></slot>
|
||||
</div>
|
||||
<div class="header">
|
||||
<slot name="header"></slot>
|
||||
<slot name="event"></slot>
|
||||
</div>
|
||||
|
||||
<slot class="header" name="header"></slot>
|
||||
<div class="icons">
|
||||
<slot name="icons"></slot>
|
||||
</div>
|
||||
@@ -127,7 +120,7 @@ export class HaAutomationRow extends LitElement {
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
padding: 0 0 0 var(--ha-space-3);
|
||||
padding: 0 var(--ha-space-3);
|
||||
min-height: 48px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
@@ -179,24 +172,12 @@ export class HaAutomationRow extends LitElement {
|
||||
border-top-right-radius: var(--ha-border-radius-square);
|
||||
border-top-left-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
::slotted([slot="header"]) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0 var(--ha-space-3);
|
||||
}
|
||||
::slotted([slot="header"]) {
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0 var(--ha-space-3);
|
||||
}
|
||||
::slotted([slot="event"]) {
|
||||
position: absolute;
|
||||
top: 13px;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
.icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -218,19 +199,6 @@ export class HaAutomationRow extends LitElement {
|
||||
:host([highlight]) .row:hover {
|
||||
background-color: rgba(var(--rgb-primary-color), 0.16);
|
||||
}
|
||||
|
||||
.icons,
|
||||
.leading-icon-wrapper,
|
||||
::slotted([slot="header"]) {
|
||||
transition: opacity var(--ha-animation-duration-normal);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host([dim]) .icons,
|
||||
:host([dim]) .leading-icon-wrapper,
|
||||
:host([dim]) ::slotted([slot="header"]) {
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
538
src/components/ha-clock-date-format-picker.ts
Normal file
538
src/components/ha-clock-date-format-picker.ts
Normal file
@@ -0,0 +1,538 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { resolveTimeZone } from "../common/datetime/resolve-time-zone";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../data/context";
|
||||
import {
|
||||
CLOCK_CARD_DATE_PARTS,
|
||||
formatClockCardDate,
|
||||
} from "../panels/lovelace/cards/clock/clock-date-format";
|
||||
import type { ClockCardDatePart } from "../panels/lovelace/cards/types";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-input-chip";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-input-helper-text";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-sortable";
|
||||
|
||||
type ClockDatePartSection = "weekday" | "day" | "month" | "year" | "separator";
|
||||
|
||||
type ClockDateSeparatorPart = Extract<
|
||||
ClockCardDatePart,
|
||||
"separator-dash" | "separator-slash" | "separator-dot" | "separator-new-line"
|
||||
>;
|
||||
|
||||
const CLOCK_DATE_PART_SECTION_ORDER: readonly ClockDatePartSection[] = [
|
||||
"day",
|
||||
"month",
|
||||
"year",
|
||||
"weekday",
|
||||
"separator",
|
||||
];
|
||||
|
||||
const CLOCK_DATE_SEPARATOR_VALUES: Record<ClockDateSeparatorPart, string> = {
|
||||
"separator-dash": "-",
|
||||
"separator-slash": "/",
|
||||
"separator-dot": ".",
|
||||
"separator-new-line": "",
|
||||
};
|
||||
|
||||
const getClockDatePartSection = (
|
||||
part: ClockCardDatePart
|
||||
): ClockDatePartSection => {
|
||||
if (part.startsWith("weekday-")) {
|
||||
return "weekday";
|
||||
}
|
||||
|
||||
if (part.startsWith("day-")) {
|
||||
return "day";
|
||||
}
|
||||
|
||||
if (part.startsWith("month-")) {
|
||||
return "month";
|
||||
}
|
||||
|
||||
if (part.startsWith("year-")) {
|
||||
return "year";
|
||||
}
|
||||
|
||||
return "separator";
|
||||
};
|
||||
|
||||
interface ClockDatePartSectionData {
|
||||
id: ClockDatePartSection;
|
||||
title: string;
|
||||
items: PickerComboBoxItem[];
|
||||
}
|
||||
|
||||
interface ClockDatePartValueItem {
|
||||
key: string;
|
||||
item: string;
|
||||
idx: number;
|
||||
}
|
||||
|
||||
@customElement("ha-clock-date-format-picker")
|
||||
export class HaClockDateFormatPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string[] | string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _hassConfig!: ContextType<typeof configContext>;
|
||||
|
||||
@query("ha-generic-picker", true) private _picker?: HaGenericPicker;
|
||||
|
||||
private _editIndex?: number;
|
||||
|
||||
protected render() {
|
||||
const value = this._value;
|
||||
const valueItems = this._getValueItems(value);
|
||||
const sections = this._buildSections();
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required && !value.length}
|
||||
.value=${this._getPickerValue()}
|
||||
.sections=${this._getSectionHeaders(sections)}
|
||||
.getItems=${this._getItems(sections)}
|
||||
@value-changed=${this._pickerValueChanged}
|
||||
>
|
||||
<div slot="field" class="container">
|
||||
<ha-sortable
|
||||
no-style
|
||||
@item-moved=${this._moveItem}
|
||||
.disabled=${this.disabled}
|
||||
handle-selector="button.primary.action"
|
||||
filter=".add"
|
||||
>
|
||||
<ha-chip-set>
|
||||
${repeat(
|
||||
valueItems,
|
||||
(entry: ClockDatePartValueItem) => entry.key,
|
||||
({ item, idx }) => this._renderValueChip(item, idx, sections)
|
||||
)}
|
||||
${this.disabled
|
||||
? nothing
|
||||
: html`
|
||||
<ha-assist-chip
|
||||
@click=${this._addItem}
|
||||
.disabled=${this.disabled}
|
||||
label=${this._i18n.localize("ui.common.add")}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
`}
|
||||
</ha-chip-set>
|
||||
</ha-sortable>
|
||||
</div>
|
||||
</ha-generic-picker>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _getValueItems = memoizeOne(
|
||||
(value: string[]): ClockDatePartValueItem[] => {
|
||||
const occurrences = new Map<string, number>();
|
||||
|
||||
return value.map((item, idx) => {
|
||||
const occurrence = occurrences.get(item) ?? 0;
|
||||
occurrences.set(item, occurrence + 1);
|
||||
|
||||
return {
|
||||
key: `${item}:${occurrence}`,
|
||||
item,
|
||||
idx,
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _renderValueChip(
|
||||
item: string,
|
||||
idx: number,
|
||||
sections: ClockDatePartSectionData[]
|
||||
) {
|
||||
const label = this._getItemLabel(item, sections);
|
||||
const isValid = !!label;
|
||||
|
||||
return html`
|
||||
<ha-input-chip
|
||||
data-idx=${idx}
|
||||
@remove=${this._removeItem}
|
||||
@click=${this._editItem}
|
||||
.label=${label ?? item}
|
||||
.selected=${!this.disabled}
|
||||
.disabled=${this.disabled}
|
||||
class=${!isValid ? "invalid" : ""}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiDragHorizontalVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-input-chip>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _addItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._editIndex = undefined;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private async _editItem(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
this._editIndex = idx;
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return !this.value ? [] : ensureArray(this.value);
|
||||
}
|
||||
|
||||
private _toValue = memoizeOne((value: string[]): string[] | undefined =>
|
||||
value.length === 0 ? undefined : value
|
||||
);
|
||||
|
||||
private _buildSections(): ClockDatePartSectionData[] {
|
||||
const itemsBySection: Record<ClockDatePartSection, PickerComboBoxItem[]> = {
|
||||
weekday: [],
|
||||
day: [],
|
||||
month: [],
|
||||
year: [],
|
||||
separator: [],
|
||||
};
|
||||
|
||||
const previewDate = new Date();
|
||||
const previewTimeZone = resolveTimeZone(
|
||||
this._i18n.locale.time_zone,
|
||||
this._hassConfig.config.time_zone
|
||||
);
|
||||
|
||||
CLOCK_CARD_DATE_PARTS.forEach((part) => {
|
||||
const section = getClockDatePartSection(part);
|
||||
const label =
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.parts.${part}`
|
||||
) ?? part;
|
||||
|
||||
const secondary =
|
||||
section === "separator"
|
||||
? CLOCK_DATE_SEPARATOR_VALUES[part as ClockDateSeparatorPart]
|
||||
: formatClockCardDate(
|
||||
previewDate,
|
||||
{ parts: [part] },
|
||||
this._i18n.locale.language,
|
||||
previewTimeZone
|
||||
);
|
||||
|
||||
itemsBySection[section].push({
|
||||
id: part,
|
||||
primary: label,
|
||||
secondary,
|
||||
sorting_label: label,
|
||||
});
|
||||
});
|
||||
|
||||
return CLOCK_DATE_PART_SECTION_ORDER.map((section) => ({
|
||||
id: section,
|
||||
title:
|
||||
this._i18n.localize(
|
||||
`ui.panel.lovelace.editor.card.clock.date.sections.${section}`
|
||||
) ?? section,
|
||||
items: itemsBySection[section],
|
||||
})).filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
private _getSectionHeaders(
|
||||
sections: ClockDatePartSectionData[]
|
||||
): { id: string; label: string }[] {
|
||||
return sections.map((section) => ({
|
||||
id: section.id,
|
||||
label: section.title,
|
||||
}));
|
||||
}
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(sections: ClockDatePartSectionData[]) =>
|
||||
(
|
||||
searchString?: string,
|
||||
section?: string
|
||||
): (PickerComboBoxItem | string)[] => {
|
||||
const normalizedSearch = searchString?.trim().toLowerCase();
|
||||
|
||||
const filteredSections = sections
|
||||
.map((sectionData) => {
|
||||
if (!normalizedSearch) {
|
||||
return sectionData;
|
||||
}
|
||||
|
||||
return {
|
||||
...sectionData,
|
||||
items: sectionData.items.filter(
|
||||
(item) =>
|
||||
item.primary.toLowerCase().includes(normalizedSearch) ||
|
||||
item.secondary?.toLowerCase().includes(normalizedSearch) ||
|
||||
item.id.toLowerCase().includes(normalizedSearch)
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter((sectionData) => sectionData.items.length > 0);
|
||||
|
||||
if (section) {
|
||||
return (
|
||||
filteredSections.find((candidate) => candidate.id === section)
|
||||
?.items || []
|
||||
);
|
||||
}
|
||||
|
||||
const groupedItems: (PickerComboBoxItem | string)[] = [];
|
||||
|
||||
filteredSections.forEach((sectionData) => {
|
||||
groupedItems.push(sectionData.title, ...sectionData.items);
|
||||
});
|
||||
|
||||
return groupedItems;
|
||||
}
|
||||
);
|
||||
|
||||
private _getItemLabel(
|
||||
value: string,
|
||||
sections: ClockDatePartSectionData[]
|
||||
): string | undefined {
|
||||
for (const section of sections) {
|
||||
const item = section.items.find((candidate) => candidate.id === value);
|
||||
|
||||
if (item) {
|
||||
if (section.id === "separator") {
|
||||
if (value === "separator-new-line") {
|
||||
return item.primary;
|
||||
}
|
||||
|
||||
return item.secondary ?? item.primary;
|
||||
}
|
||||
|
||||
return `${item.secondary} [${item.primary} ${section.title}]`;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _getPickerValue(): string | undefined {
|
||||
if (this._editIndex != null) {
|
||||
return this._value[this._editIndex];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async _moveItem(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const { oldIndex, newIndex } = ev.detail;
|
||||
|
||||
const value = this._value;
|
||||
const newValue = value.concat();
|
||||
const element = newValue.splice(oldIndex, 1)[0];
|
||||
newValue.splice(newIndex, 0, element);
|
||||
|
||||
this._setValue(newValue);
|
||||
}
|
||||
|
||||
private async _removeItem(ev: Event) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
const idx = parseInt(
|
||||
(ev.currentTarget as HTMLElement).dataset.idx ?? "",
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(idx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const value = [...this._value];
|
||||
value.splice(idx, 1);
|
||||
|
||||
if (this._editIndex !== undefined) {
|
||||
if (this._editIndex === idx) {
|
||||
this._editIndex = undefined;
|
||||
} else if (this._editIndex > idx) {
|
||||
this._editIndex -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
}
|
||||
|
||||
private _pickerValueChanged(ev: ValueChangedEvent<string>): void {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (this.disabled || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = [...this._value];
|
||||
|
||||
if (this._editIndex != null) {
|
||||
newValue[this._editIndex] = value;
|
||||
this._editIndex = undefined;
|
||||
} else {
|
||||
newValue.push(value);
|
||||
}
|
||||
|
||||
this._setValue(newValue);
|
||||
|
||||
if (this._picker) {
|
||||
this._picker.value = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _setValue(value: string[]) {
|
||||
const newValue = this._toValue(value);
|
||||
this.value = newValue;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
background-color: var(--mdc-text-field-fill-color, whitesmoke);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
border-end-end-radius: var(--ha-border-radius-square);
|
||||
border-end-start-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
|
||||
.container:after {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
width: 100%;
|
||||
background-color: var(
|
||||
--mdc-text-field-idle-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
transition:
|
||||
height 180ms ease-in-out,
|
||||
background-color 180ms ease-in-out;
|
||||
}
|
||||
|
||||
:host([disabled]) .container:after {
|
||||
background-color: var(
|
||||
--mdc-text-field-disabled-line-color,
|
||||
rgba(0, 0, 0, 0.42)
|
||||
);
|
||||
}
|
||||
|
||||
.container:focus-within:after {
|
||||
height: 2px;
|
||||
background-color: var(--mdc-theme-primary);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.add {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
ha-chip-set {
|
||||
padding: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sortable-fallback {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.sortable-ghost {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.sortable-drag {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: var(--ha-space-2) 0 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-clock-date-format-picker": HaClockDateFormatPicker;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { CompletionItem } from "./ha-code-editor-completion-items";
|
||||
import "./ha-code-editor-completion-items";
|
||||
|
||||
@customElement("ha-code-editor-jinja-arg-hover")
|
||||
export class HaCodeEditorJinjaArgHover extends LitElement {
|
||||
/** Bold heading shown above the items grid (e.g. entity/device/area name). */
|
||||
@property({ attribute: false }) public heading?: string;
|
||||
|
||||
@property({ attribute: false }) public items: CompletionItem[] = [];
|
||||
|
||||
render() {
|
||||
return html`
|
||||
${this.heading
|
||||
? html`<div class="heading">${this.heading}</div>`
|
||||
: nothing}
|
||||
<ha-code-editor-completion-items
|
||||
.items=${this.items}
|
||||
></ha-code-editor-completion-items>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
max-width: 360px;
|
||||
}
|
||||
|
||||
.heading {
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-code-editor-jinja-arg-hover": HaCodeEditorJinjaArgHover;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-code-editor-jinja-hover")
|
||||
export class HaCodeEditorJinjaHover extends LitElement {
|
||||
@property({ attribute: false }) public completion!: Completion;
|
||||
|
||||
@property({ attribute: false }) public docUrl?: string;
|
||||
|
||||
@property({ attribute: false }) public openDocumentation =
|
||||
"Open documentation";
|
||||
|
||||
render() {
|
||||
const info =
|
||||
typeof this.completion.info === "string"
|
||||
? this.completion.info
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<div class="sig">
|
||||
<strong>${this.completion.label}</strong>
|
||||
${this.completion.detail
|
||||
? html`<span class="detail">(${this.completion.detail})</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
${this.docUrl
|
||||
? html`<a
|
||||
class="doc-link"
|
||||
href=${this.docUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title=${this.openDocumentation}
|
||||
><ha-svg-icon .path=${mdiHelpCircleOutline}></ha-svg-icon
|
||||
></a>`
|
||||
: nothing}
|
||||
</div>
|
||||
${info ? html`<div class="desc">${info}</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
max-width: 360px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sig {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: 0.9em;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.doc-link {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--secondary-text-color);
|
||||
opacity: 0.7;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.doc-link:hover {
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.doc-link ha-svg-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 0.9em;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-code-editor-jinja-hover": HaCodeEditorJinjaHover;
|
||||
}
|
||||
}
|
||||
@@ -36,13 +36,9 @@ import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type {
|
||||
JinjaArgType,
|
||||
HassArgHoverContext,
|
||||
} from "../resources/jinja_ha_completions";
|
||||
import type { JinjaArgType } from "../resources/jinja_ha_completions";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import { labelsContext } from "../data/context";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import "./ha-code-editor-completion-items";
|
||||
@@ -95,8 +91,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
@property({ type: Boolean }) public error = false;
|
||||
|
||||
@property({ type: Boolean }) public lint = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "disable-fullscreen" })
|
||||
public disableFullscreen = false;
|
||||
|
||||
@@ -165,40 +159,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
return !!this.renderRoot.querySelector(`span.${className}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a YAML parse error (or null to clear) into the lint gutter as a
|
||||
* diagnostic. Avoids re-parsing the document — the caller (ha-yaml-editor)
|
||||
* already has the error from its own js-yaml load() call.
|
||||
*/
|
||||
public setYamlError(
|
||||
err: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
reason?: string;
|
||||
} | null
|
||||
): void {
|
||||
if (!this.codemirror || !this._loadedCodeMirror) return;
|
||||
let diagnostics: {
|
||||
from: number;
|
||||
to: number;
|
||||
severity: "error";
|
||||
message: string;
|
||||
}[] = [];
|
||||
if (err) {
|
||||
const doc = this.codemirror.state.doc;
|
||||
const pos = err.mark ? Math.min(err.mark.position, doc.length) : 0;
|
||||
const line = doc.lineAt(pos);
|
||||
const message = `${
|
||||
err.reason ||
|
||||
this.hass?.localize("ui.components.yaml-editor.error") ||
|
||||
"YAML syntax error"
|
||||
}${err.mark ? ` (${this.hass?.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
diagnostics = [{ from: pos, to: line.to, severity: "error", message }];
|
||||
}
|
||||
this.codemirror.dispatch(
|
||||
this._loadedCodeMirror.setDiagnostics(this.codemirror.state, diagnostics)
|
||||
);
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.classList.toggle("in-dialog", this.inDialog);
|
||||
@@ -256,37 +216,16 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
transactions.push({
|
||||
effects: [
|
||||
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
if (changedProps.has("readOnly")) {
|
||||
transactions.push({
|
||||
effects: [
|
||||
this._loadedCodeMirror!.readonlyCompartment!.reconfigure(
|
||||
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
|
||||
),
|
||||
this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
),
|
||||
],
|
||||
});
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
if (changedProps.has("lint")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.yamlLintCompartment!.reconfigure(
|
||||
this.lint && !this.readOnly
|
||||
? [this._loadedCodeMirror!.lintGutter()]
|
||||
: []
|
||||
effects: this._loadedCodeMirror!.readonlyCompartment!.reconfigure(
|
||||
this._loadedCodeMirror!.EditorView!.editable.of(!this.readOnly)
|
||||
),
|
||||
});
|
||||
this._updateToolbarButtons();
|
||||
}
|
||||
if (changedProps.has("linewrap")) {
|
||||
transactions.push({
|
||||
@@ -369,7 +308,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
...this._loadedCodeMirror.searchKeymap,
|
||||
...this._loadedCodeMirror.historyKeymap,
|
||||
...this._loadedCodeMirror.tabKeyBindings,
|
||||
...this._loadedCodeMirror.lintKeymap,
|
||||
saveKeyBinding,
|
||||
]),
|
||||
this._loadedCodeMirror.search({ top: true }),
|
||||
@@ -384,23 +322,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror.linewrapCompartment.of(
|
||||
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
|
||||
),
|
||||
this._loadedCodeMirror.yamlLintCompartment.of(
|
||||
this.lint && !this.readOnly ? [this._loadedCodeMirror.lintGutter()] : []
|
||||
),
|
||||
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
|
||||
this._loadedCodeMirror.tooltips({
|
||||
position: "absolute",
|
||||
}),
|
||||
this._loadedCodeMirror.hoverTooltip(
|
||||
(view, pos) =>
|
||||
this._loadedCodeMirror!.haJinjaHoverSource(
|
||||
view,
|
||||
pos,
|
||||
this.hass ? documentationUrl(this.hass, "") : undefined,
|
||||
this.hass ? this._hassArgHoverContext() : undefined
|
||||
),
|
||||
{ hoverTime: 300 }
|
||||
),
|
||||
...(this.placeholder ? [placeholder(this.placeholder)] : []),
|
||||
];
|
||||
|
||||
@@ -650,48 +575,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a HassArgHoverContext from the current hass object so that
|
||||
* haJinjaHoverSource can resolve entity / device / area friendly names
|
||||
* without importing the full HomeAssistant type into the resource file.
|
||||
*/
|
||||
private _hassArgHoverContext(): HassArgHoverContext {
|
||||
const hass = this.hass!;
|
||||
const labelMap: Record<
|
||||
string,
|
||||
{ name: string; description?: string | null }
|
||||
> = {};
|
||||
for (const label of this._labels ?? []) {
|
||||
labelMap[label.label_id] = {
|
||||
name: label.name,
|
||||
description: label.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
states: hass.states as HassArgHoverContext["states"],
|
||||
devices: hass.devices as HassArgHoverContext["devices"],
|
||||
areas: hass.areas as HassArgHoverContext["areas"],
|
||||
floors: hass.floors as HassArgHoverContext["floors"],
|
||||
entities: hass.entities as HassArgHoverContext["entities"],
|
||||
labels: labelMap,
|
||||
formatEntityState: (entityId) =>
|
||||
hass.formatEntityState(hass.states[entityId]),
|
||||
formatEntityName: (entityId) => {
|
||||
const stateObj = hass.states[entityId];
|
||||
return (
|
||||
(stateObj?.attributes.friendly_name as string | undefined) ??
|
||||
hass.entities[entityId]?.name ??
|
||||
undefined
|
||||
);
|
||||
},
|
||||
formatAttributeName: (entityId, attribute) =>
|
||||
hass.formatEntityAttributeName(hass.states[entityId], attribute),
|
||||
formatAttributeValue: (entityId, attribute) =>
|
||||
hass.formatEntityAttributeValue(hass.states[entityId], attribute),
|
||||
localize: (key) => hass.localize(key as never),
|
||||
};
|
||||
}
|
||||
|
||||
private _renderInfo = (completion: Completion): CompletionInfo => {
|
||||
const key =
|
||||
typeof completion.apply === "string"
|
||||
|
||||
@@ -55,11 +55,7 @@ export class HaConditionIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = conditionIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.condition
|
||||
).then((icn) => {
|
||||
const icon = conditionIcon(this.hass, this.condition).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
|
||||
@@ -196,7 +196,6 @@ export class HaControlSwitch extends LitElement {
|
||||
--control-switch-background-opacity: 0.2;
|
||||
--control-switch-hover-background-opacity: 0.4;
|
||||
--control-switch-thickness: 40px;
|
||||
--control-switch-min-touch-size: 40px;
|
||||
--control-switch-border-radius: var(--ha-border-radius-lg);
|
||||
--control-switch-padding: 4px;
|
||||
--mdc-icon-size: 20px;
|
||||
@@ -220,35 +219,21 @@ export class HaControlSwitch extends LitElement {
|
||||
width: 100%;
|
||||
border-radius: var(--control-switch-border-radius);
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
padding: var(--control-switch-padding);
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: var(--control-switch-min-touch-size);
|
||||
min-height: var(--control-switch-min-touch-size);
|
||||
}
|
||||
.switch[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.switch[disabled]::before {
|
||||
pointer-events: none;
|
||||
}
|
||||
.switch .background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
border-radius: inherit;
|
||||
background-color: var(--control-switch-off-color);
|
||||
transition: background-color 180ms ease-in-out;
|
||||
opacity: var(--control-switch-background-opacity);
|
||||
|
||||
@@ -3,14 +3,13 @@ import { customElement, property } from "lit/decorators";
|
||||
|
||||
@customElement("ha-fade-in")
|
||||
export class HaFadeIn extends WaAnimation {
|
||||
@property() public name = "fadeIn";
|
||||
|
||||
@property() public fill: FillMode = "both";
|
||||
|
||||
@property({ type: Boolean }) public play = true;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.iterations = 1;
|
||||
this.fill = "both";
|
||||
this.name = "fadeIn";
|
||||
}
|
||||
@property({ type: Number }) public iterations = 1;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -13,17 +13,14 @@ import type { RelatedResult } from "../data/search";
|
||||
import { findRelated } from "../data/search";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-floor-icon";
|
||||
import "./ha-icon";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-list";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "./list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "./list/types";
|
||||
|
||||
@customElement("ha-filter-floor-areas")
|
||||
export class HaFilterFloorAreas extends LitElement {
|
||||
@@ -78,33 +75,27 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
</div>
|
||||
${this._shouldRender
|
||||
? html`
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@ha-list-selected=${this._handleListChanged}
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.areas.caption"
|
||||
)}
|
||||
>
|
||||
<ha-list class="ha-scrollbar">
|
||||
${repeat(
|
||||
areas?.floors || [],
|
||||
(floor) => floor.floor_id,
|
||||
(floor) => html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
<ha-check-list-item
|
||||
.value=${floor.floor_id}
|
||||
.type=${"floors"}
|
||||
.selected=${this.value?.floors?.includes(
|
||||
floor.floor_id
|
||||
) || false}
|
||||
graphic="icon"
|
||||
@request-selected=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
>
|
||||
<ha-floor-icon
|
||||
slot="start"
|
||||
slot="graphic"
|
||||
.floor=${floor}
|
||||
></ha-floor-icon>
|
||||
<span slot="headline">${floor.name} </span>
|
||||
</ha-list-item-option>
|
||||
${floor.name}
|
||||
</ha-check-list-item>
|
||||
${repeat(
|
||||
floor.areas,
|
||||
(area, index) =>
|
||||
@@ -119,7 +110,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
(area) => area.area_id,
|
||||
(area) => this._renderArea(area)
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
</ha-list>
|
||||
`
|
||||
: nothing}
|
||||
</ha-expansion-panel>
|
||||
@@ -128,83 +119,79 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
private _renderArea(area, last = false) {
|
||||
const hasFloor = !!area.floor_id;
|
||||
|
||||
return html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
<ha-check-list-item
|
||||
.value=${area.area_id}
|
||||
.selected=${this.value?.areas?.includes(area.area_id) || false}
|
||||
.type=${"areas"}
|
||||
graphic="icon"
|
||||
@request-selected=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
class=${classMap({
|
||||
rtl: computeRTL(this.hass),
|
||||
floor: hasFloor,
|
||||
})}
|
||||
>
|
||||
${hasFloor
|
||||
? html`<ha-tree-indicator
|
||||
slot="start"
|
||||
.end=${last}
|
||||
></ha-tree-indicator>`
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
.end=${last}
|
||||
slot="graphic"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
: nothing}
|
||||
${area.icon
|
||||
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
|
||||
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
slot="graphic"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
<span slot="headline">${area.name}</span>
|
||||
</ha-list-item-option>
|
||||
${area.name}
|
||||
</ha-check-list-item>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
|
||||
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
|
||||
private _handleItemKeydown(ev) {
|
||||
if (ev.key === " " || ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._handleItemClick(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleItemClick(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
const listItem = ev.currentTarget;
|
||||
const type = listItem?.type;
|
||||
const value = listItem?.value;
|
||||
|
||||
if (ev.detail.selected === listItem.selected || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.detail.diff?.added.size) {
|
||||
const addedIndex = ev.detail.diff.added.values().next().value;
|
||||
if (addedIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const addedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
addedIndex
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
if (this.value?.[type]?.includes(value)) {
|
||||
this.value = {
|
||||
...this.value,
|
||||
[type]: this.value[type].filter((val) => val !== value),
|
||||
};
|
||||
} else {
|
||||
if (!this.value) {
|
||||
this.value = {};
|
||||
}
|
||||
this.value = {
|
||||
...this.value,
|
||||
[addedItem.type]: [
|
||||
...(this.value[addedItem.type] || []),
|
||||
addedItem.value,
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const removedIndex = ev.detail.diff?.removed.values().next().value;
|
||||
if (removedIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const removedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
removedIndex
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[removedItem.type]: this.value![removedItem.type].filter(
|
||||
(val) => val !== removedItem.value
|
||||
),
|
||||
[type]: [...(this.value[type] || []), value],
|
||||
};
|
||||
}
|
||||
|
||||
listItem.selected = this.value[type]?.includes(value);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
|
||||
this.renderRoot.querySelector("ha-list")!.style.height =
|
||||
`${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
@@ -330,7 +317,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
.floor::part(base) {
|
||||
ha-check-list-item {
|
||||
--mdc-list-item-graphic-margin: 16px;
|
||||
}
|
||||
.floor {
|
||||
padding-left: 48px;
|
||||
padding-inline-start: 48px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
@@ -104,20 +104,6 @@ export class HaFloorPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _pendingFloorId?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
this._pendingFloorId &&
|
||||
changedProperties.has("hass") &&
|
||||
this.hass.floors !== changedProperties.get("hass")?.floors &&
|
||||
this.hass.floors[this._pendingFloorId]
|
||||
) {
|
||||
this._setValue(this._pendingFloorId);
|
||||
this._pendingFloorId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
@@ -450,11 +436,7 @@ export class HaFloorPicker extends LitElement {
|
||||
floor_id: floor.floor_id,
|
||||
});
|
||||
});
|
||||
if (this.hass.floors[floor.floor_id]) {
|
||||
this._setValue(floor.floor_id);
|
||||
} else {
|
||||
this._pendingFloorId = floor.floor_id;
|
||||
}
|
||||
this._setValue(floor.floor_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -72,8 +72,6 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
key: string
|
||||
) => string;
|
||||
|
||||
@property({ attribute: false }) public context?: Record<string, any>;
|
||||
|
||||
protected getFormProperties(): Record<string, any> {
|
||||
return {};
|
||||
}
|
||||
@@ -220,15 +218,13 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
private _generateContext(
|
||||
schema: HaFormSchema
|
||||
): Record<string, any> | undefined {
|
||||
if (!schema.context && !this.context) {
|
||||
if (!schema.context) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const context = { ...this.context };
|
||||
if (schema.context) {
|
||||
for (const [context_key, data_key] of Object.entries(schema.context)) {
|
||||
context[context_key] = this.data[data_key];
|
||||
}
|
||||
const context = {};
|
||||
for (const [context_key, data_key] of Object.entries(schema.context)) {
|
||||
context[context_key] = this.data[data_key];
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ export class HaFormfield extends FormfieldBase {
|
||||
input.checked = !input.checked;
|
||||
fireEvent(input, "change");
|
||||
break;
|
||||
case "HA-RADIO":
|
||||
input.checked = true;
|
||||
fireEvent(input, "change");
|
||||
break;
|
||||
default:
|
||||
input.click();
|
||||
break;
|
||||
|
||||
@@ -469,8 +469,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
--ha-bottom-sheet-padding: 0;
|
||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
||||
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
|
||||
--ha-bottom-sheet-content-padding: 0 var(--safe-area-inset-right)
|
||||
var(--safe-area-inset-bottom) var(--safe-area-inset-left);
|
||||
}
|
||||
|
||||
ha-picker-field.opened {
|
||||
|
||||
@@ -32,7 +32,7 @@ class HaHumidifierState extends LitElement {
|
||||
|
||||
${currentStatus && !isUnavailableState(this.stateObj.state)
|
||||
? html`<div class="current">
|
||||
${this.hass.localize("ui.card.humidifier.currently")}:
|
||||
${this.hass.localize("ui.card.climate.currently")}:
|
||||
<div class="unit">${currentStatus}</div>
|
||||
</div>`
|
||||
: ""}`;
|
||||
|
||||
@@ -53,10 +53,7 @@ export class HaIconButton extends LitElement {
|
||||
.download=${this.download}
|
||||
>
|
||||
${this.path
|
||||
? html`<ha-svg-icon
|
||||
aria-hidden="true"
|
||||
.path=${this.path}
|
||||
></ha-svg-icon>`
|
||||
? html`<ha-svg-icon .path=${this.path}></ha-svg-icon>`
|
||||
: html`<span><slot></slot></span>`}
|
||||
</ha-button>
|
||||
`;
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiPlus } from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import type { TemplateResult, PropertyValues } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
property,
|
||||
@@ -117,19 +117,6 @@ export class HaLabelPicker extends LitElement {
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
|
||||
@state() private _pendingLabelId?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
if (
|
||||
this._pendingLabelId &&
|
||||
changedProperties.has("_labels") &&
|
||||
this._labels?.some((l) => l.label_id === this._pendingLabelId)
|
||||
) {
|
||||
this._setValue(this._pendingLabelId);
|
||||
this._pendingLabelId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
@@ -261,11 +248,7 @@ export class HaLabelPicker extends LitElement {
|
||||
createEntry: async (values) => {
|
||||
try {
|
||||
const label = await createLabelRegistryEntry(this.hass, values);
|
||||
if (this._labels?.some((l) => l.label_id === label.label_id)) {
|
||||
this._setValue(label.label_id);
|
||||
} else {
|
||||
this._pendingLabelId = label.label_id;
|
||||
}
|
||||
this._setValue(label.label_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/list/ha-list-nav";
|
||||
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import type { PageNavigation } from "../layouts/hass-tabs-subpage";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-icon-next";
|
||||
import "./ha-md-list";
|
||||
import "./ha-md-list-item";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-config-navigation-list")
|
||||
class HaConfigNavigationList extends LitElement {
|
||||
@customElement("ha-navigation-list")
|
||||
class HaNavigationList extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -23,11 +24,16 @@ class HaConfigNavigationList extends LitElement {
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<ha-list-nav .ariaLabel=${this.label}>
|
||||
<ha-md-list
|
||||
innerRole="menu"
|
||||
itemRoles="menuitem"
|
||||
innerAriaLabel=${ifDefined(this.label)}
|
||||
>
|
||||
${this.pages.map((page) => {
|
||||
const externalApp = page.path.endsWith("#external-app-configuration");
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
<ha-md-list-item
|
||||
.type=${externalApp ? "button" : "link"}
|
||||
.href=${externalApp ? undefined : page.path}
|
||||
@click=${externalApp ? this._handleExternalApp : undefined}
|
||||
>
|
||||
@@ -49,10 +55,10 @@ class HaConfigNavigationList extends LitElement {
|
||||
${!this.narrow
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: ""}
|
||||
</ha-list-item-button>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-list-nav>
|
||||
</ha-md-list>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -77,11 +83,14 @@ class HaConfigNavigationList extends LitElement {
|
||||
.icon-background ha-svg-icon {
|
||||
color: #fff;
|
||||
}
|
||||
ha-md-list-item {
|
||||
font-size: var(--navigation-list-item-title-font-size);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-navigation-list": HaConfigNavigationList;
|
||||
"ha-navigation-list": HaNavigationList;
|
||||
}
|
||||
}
|
||||
22
src/components/ha-radio.ts
Normal file
22
src/components/ha-radio.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { RadioBase } from "@material/mwc-radio/mwc-radio-base";
|
||||
import { styles } from "@material/mwc-radio/mwc-radio.css";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-radio")
|
||||
export class HaRadio extends RadioBase {
|
||||
static override styles = [
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
--mdc-theme-secondary: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-radio": HaRadio;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "./radio/ha-radio-group";
|
||||
import "./radio/ha-radio-option";
|
||||
import "./ha-radio";
|
||||
import type { HaRadio } from "./ha-radio";
|
||||
|
||||
interface SelectBoxOptionImage {
|
||||
src: string;
|
||||
@@ -37,28 +36,19 @@ export class HaSelectBox extends LitElement {
|
||||
@property({ type: Number, attribute: "max_columns" })
|
||||
public maxColumns?: number;
|
||||
|
||||
@property({ type: Boolean, attribute: "stacked_image" })
|
||||
public stackedImage = false;
|
||||
|
||||
render() {
|
||||
const maxColumns = this.maxColumns ?? 3;
|
||||
const columns = Math.min(maxColumns, this.options.length);
|
||||
|
||||
return html`
|
||||
<ha-radio-group
|
||||
class="list"
|
||||
style=${styleMap({ "--columns": columns })}
|
||||
.value=${this.value}
|
||||
@change=${this._radioChanged}
|
||||
>
|
||||
<div class="list" style=${styleMap({ "--columns": columns })}>
|
||||
${this.options.map((option) => this._renderOption(option))}
|
||||
</ha-radio-group>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOption(option: SelectBoxOption) {
|
||||
const horizontal = this.maxColumns === 1 && !this.stackedImage;
|
||||
const stacked = this.maxColumns === 1 && this.stackedImage;
|
||||
const horizontal = this.maxColumns === 1;
|
||||
const disabled = option.disabled || this.disabled || false;
|
||||
const selected = option.value === this.value;
|
||||
|
||||
@@ -76,28 +66,23 @@ export class HaSelectBox extends LitElement {
|
||||
<label
|
||||
class="option ${classMap({
|
||||
horizontal: horizontal,
|
||||
stacked: stacked,
|
||||
selected: selected,
|
||||
})}"
|
||||
?disabled=${disabled}
|
||||
@click=${this._labelClick}
|
||||
>
|
||||
<div class="content">
|
||||
<ha-radio-option
|
||||
aria-describedby=${ifDefined(
|
||||
option.description ? `desc-${option.value}` : undefined
|
||||
)}
|
||||
aria-labelledby=${`label-${option.value}`}
|
||||
<ha-radio
|
||||
.checked=${option.value === this.value}
|
||||
.value=${option.value}
|
||||
.disabled=${disabled}
|
||||
></ha-radio-option>
|
||||
@change=${this._radioChanged}
|
||||
@click=${stopPropagation}
|
||||
></ha-radio>
|
||||
<div class="text">
|
||||
<span id=${`label-${option.value}`} class="label"
|
||||
>${option.label}</span
|
||||
>
|
||||
<span class="label">${option.label}</span>
|
||||
${option.description
|
||||
? html`<span class="description" id="desc-${option.value}"
|
||||
>${option.description}</span
|
||||
>`
|
||||
? html`<span class="description">${option.description}</span>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,9 +95,14 @@ export class HaSelectBox extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _labelClick(ev) {
|
||||
ev.stopPropagation();
|
||||
ev.currentTarget.querySelector("ha-radio")?.click();
|
||||
}
|
||||
|
||||
private _radioChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const radio = ev.currentTarget as HaRadioGroup;
|
||||
const radio = ev.currentTarget as HaRadio;
|
||||
const value = radio.value;
|
||||
if (this.disabled || value === undefined || value === (this.value ?? "")) {
|
||||
return;
|
||||
@@ -123,7 +113,7 @@ export class HaSelectBox extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.list::part(form-control-input) {
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns, 1), minmax(0, 1fr));
|
||||
gap: var(--ha-space-3);
|
||||
@@ -151,9 +141,8 @@ export class HaSelectBox extends LitElement {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.option .content ha-radio-option {
|
||||
--ha-radio-option-control-margin: 0;
|
||||
margin: 0;
|
||||
.option .content ha-radio {
|
||||
margin: -12px;
|
||||
flex: none;
|
||||
}
|
||||
.option .content .text {
|
||||
@@ -162,7 +151,6 @@ export class HaSelectBox extends LitElement {
|
||||
gap: var(--ha-space-1);
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
}
|
||||
.option .content .text .label {
|
||||
color: var(--primary-text-color);
|
||||
@@ -199,16 +187,6 @@ export class HaSelectBox extends LitElement {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option.stacked {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.option.stacked img {
|
||||
max-width: 100%;
|
||||
max-height: var(--ha-select-box-image-size, 96px);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.option:before {
|
||||
content: "";
|
||||
display: block;
|
||||
|
||||
@@ -59,7 +59,7 @@ export class HaSelect extends LitElement {
|
||||
value: string | number | undefined
|
||||
) => {
|
||||
// just in case value is a number, convert it to string to avoid falsy value
|
||||
const valueStr = value !== undefined ? String(value) : undefined;
|
||||
const valueStr = String(value);
|
||||
if (!options || !valueStr) {
|
||||
return valueStr;
|
||||
}
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type {
|
||||
AutomationBehavior,
|
||||
AutomationBehaviorConditionMode,
|
||||
AutomationBehaviorSelector,
|
||||
AutomationBehaviorTriggerMode,
|
||||
} from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-input-helper-text";
|
||||
import type { SelectBoxOption } from "../ha-select-box";
|
||||
import "../ha-select-box";
|
||||
|
||||
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
|
||||
"any",
|
||||
"first",
|
||||
"last",
|
||||
];
|
||||
|
||||
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
|
||||
|
||||
@customElement("ha-selector-automation_behavior")
|
||||
export class HaSelectorAutomationBehavior extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selector!: AutomationBehaviorSelector;
|
||||
|
||||
@property() public value?: AutomationBehavior;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public localizeValue?: (key: string) => string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
const { mode } = this.selector.automation_behavior ?? {};
|
||||
const modeKey = mode ?? "trigger";
|
||||
|
||||
const isTrigger = modeKey === "trigger";
|
||||
|
||||
const options = this._behaviors().map<SelectBoxOption>((behavior) => ({
|
||||
value: behavior,
|
||||
label: this._localizeOption(behavior, "label"),
|
||||
description: this._localizeOption(behavior, "description"),
|
||||
disabled: this.disabled,
|
||||
...(isTrigger && {
|
||||
image: {
|
||||
src: `/static/images/form/automation_behavior_trigger_${behavior}.svg`,
|
||||
src_dark: `/static/images/form/automation_behavior_trigger_${behavior}_dark.svg`,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
return html`
|
||||
<ha-select-box
|
||||
.hass=${this.hass}
|
||||
.options=${options}
|
||||
.value=${this.value ?? ""}
|
||||
max_columns="1"
|
||||
?stacked_image=${isTrigger}
|
||||
@value-changed=${this._valueChanged}
|
||||
></ha-select-box>
|
||||
${this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _behaviors(): AutomationBehavior[] {
|
||||
const mode = this.selector.automation_behavior?.mode;
|
||||
return mode === "condition" ? CONDITION_BEHAVIORS : TRIGGER_BEHAVIORS;
|
||||
}
|
||||
|
||||
private _localizeOption(
|
||||
behavior: AutomationBehavior,
|
||||
field: "label" | "description"
|
||||
): string {
|
||||
const { translation_key: translationKey, mode } =
|
||||
this.selector.automation_behavior ?? {};
|
||||
|
||||
if (this.localizeValue && translationKey) {
|
||||
const translated = this.localizeValue(
|
||||
`${translationKey}.options.${behavior}.${field}`
|
||||
);
|
||||
if (translated) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
return this.hass.localize(
|
||||
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
|
||||
);
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value as AutomationBehavior;
|
||||
if (this.disabled || value === this.value) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-select-box {
|
||||
--ha-select-box-image-size: 28px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-automation_behavior": HaSelectorAutomationBehavior;
|
||||
}
|
||||
}
|
||||
@@ -78,28 +78,22 @@ export class HaObjectSelector extends LitElement {
|
||||
};
|
||||
|
||||
private _renderItem(item: any, index: number) {
|
||||
const fields = this.selector.object!.fields!;
|
||||
const preferredLabel = this.selector.object!.label_field;
|
||||
const hasValidLabelField = preferredLabel && preferredLabel in fields;
|
||||
const labelField =
|
||||
this.selector.object!.label_field ||
|
||||
Object.keys(this.selector.object!.fields!)[0];
|
||||
|
||||
const label = hasValidLabelField
|
||||
? formatSelectorValue(
|
||||
this.hass,
|
||||
item[preferredLabel!],
|
||||
fields[preferredLabel!]?.selector
|
||||
)
|
||||
: Object.entries(fields)
|
||||
.map(([key, field]) =>
|
||||
formatSelectorValue(this.hass, item[key], field.selector)
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
const labelSelector = this.selector.object!.fields![labelField].selector;
|
||||
|
||||
const label = labelSelector
|
||||
? formatSelectorValue(this.hass, item[labelField], labelSelector)
|
||||
: "";
|
||||
|
||||
let description = "";
|
||||
|
||||
const descriptionField = this.selector.object!.description_field;
|
||||
if (descriptionField && descriptionField in fields) {
|
||||
const descriptionSelector = fields[descriptionField]?.selector;
|
||||
if (descriptionField) {
|
||||
const descriptionSelector =
|
||||
this.selector.object!.fields![descriptionField].selector;
|
||||
|
||||
description = descriptionSelector
|
||||
? formatSelectorValue(
|
||||
|
||||
@@ -15,11 +15,10 @@ import "../ha-dropdown-item";
|
||||
import "../ha-formfield";
|
||||
import "../ha-generic-picker";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-radio";
|
||||
import "../ha-select";
|
||||
import "../ha-select-box";
|
||||
import "../ha-sortable";
|
||||
import "../radio/ha-radio-group";
|
||||
import "../radio/ha-radio-option";
|
||||
|
||||
@customElement("ha-selector-select")
|
||||
export class HaSelectSelector extends LitElement {
|
||||
@@ -109,23 +108,24 @@ export class HaSelectSelector extends LitElement {
|
||||
) {
|
||||
if (!this.selector.select?.multiple) {
|
||||
return html`
|
||||
<ha-radio-group
|
||||
.label=${this.label}
|
||||
.disabled=${this.disabled}
|
||||
.value=${this.value}
|
||||
@change=${this._radioChanged}
|
||||
>
|
||||
<div>
|
||||
${this.label}
|
||||
${options.map(
|
||||
(item: SelectOption) => html`
|
||||
<ha-radio-option
|
||||
.value=${item.value}
|
||||
.disabled=${!!item.disabled}
|
||||
<ha-formfield
|
||||
.label=${item.label}
|
||||
.disabled=${item.disabled || this.disabled}
|
||||
>
|
||||
${item.label}
|
||||
</ha-radio-option>
|
||||
<ha-radio
|
||||
.checked=${item.value === this.value}
|
||||
.value=${item.value}
|
||||
.disabled=${item.disabled || this.disabled}
|
||||
@change=${this._radioChanged}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
`
|
||||
)}
|
||||
</ha-radio-group>
|
||||
</div>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiClose, mdiConnection, mdiMemory, mdiPencil, mdiUsb } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -29,32 +27,21 @@ const MANUAL_ENTRY_ID = "__manual_entry__";
|
||||
const SERIAL_PORTS_REFRESH_INTERVAL = 5000;
|
||||
|
||||
type SerialPortType =
|
||||
| "recommended"
|
||||
| "serial_proxy"
|
||||
| "integration"
|
||||
| "usb"
|
||||
| "embedded"
|
||||
| "unnamed"
|
||||
| "not_recommended";
|
||||
|
||||
const SECTION_ORDER: SerialPortType[] = [
|
||||
"recommended",
|
||||
"serial_proxy",
|
||||
"integration",
|
||||
"usb",
|
||||
"embedded",
|
||||
"unnamed",
|
||||
"not_recommended",
|
||||
];
|
||||
|
||||
type BaseSerialPortType =
|
||||
| "serial_proxy"
|
||||
| "integration"
|
||||
| "usb"
|
||||
| "embedded"
|
||||
| "unnamed";
|
||||
|
||||
const TYPE_ICONS: Record<BaseSerialPortType, string> = {
|
||||
const SECTION_ORDER: SerialPortType[] = [
|
||||
"serial_proxy",
|
||||
"integration",
|
||||
"usb",
|
||||
"embedded",
|
||||
"unnamed",
|
||||
];
|
||||
|
||||
const TYPE_ICONS: Record<SerialPortType, string> = {
|
||||
serial_proxy: mdiEsphomeLogo,
|
||||
integration: mdiConnection,
|
||||
usb: mdiUsb,
|
||||
@@ -64,7 +51,7 @@ const TYPE_ICONS: Record<BaseSerialPortType, string> = {
|
||||
|
||||
const ESPHOME_HASS_SCHEME = "esphome-hass://";
|
||||
|
||||
const getBasePortType = (port: SerialPort): BaseSerialPortType => {
|
||||
const getPortType = (port: SerialPort): SerialPortType => {
|
||||
if (port.device.startsWith(ESPHOME_HASS_SCHEME)) {
|
||||
return "serial_proxy";
|
||||
}
|
||||
@@ -80,37 +67,6 @@ const getBasePortType = (port: SerialPort): BaseSerialPortType => {
|
||||
return "unnamed";
|
||||
};
|
||||
|
||||
interface SerialPickerItem extends PickerComboBoxItem {
|
||||
port_type: SerialPortType;
|
||||
used_by?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const integrationName = (
|
||||
localize: HomeAssistant["localize"],
|
||||
domain: string
|
||||
): string => localize(`component.${domain}.title`) || domain;
|
||||
|
||||
const getPortType = (
|
||||
port: SerialPort,
|
||||
recommendedDomains: Set<string>
|
||||
): SerialPortType => {
|
||||
const matchingDomains = port.matching_integrations ?? [];
|
||||
|
||||
// If the current integration matches this port, it is recommended
|
||||
if (matchingDomains.some((d) => recommendedDomains.has(d))) {
|
||||
return "recommended";
|
||||
}
|
||||
|
||||
// If any other integrations match it, the port is not recommended
|
||||
if (recommendedDomains.size > 0 && matchingDomains.length > 0) {
|
||||
return "not_recommended";
|
||||
}
|
||||
|
||||
// Otherwise, classify the port
|
||||
return getBasePortType(port);
|
||||
};
|
||||
|
||||
@customElement("ha-selector-serial_port")
|
||||
export class HaSerialPortSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -129,8 +85,6 @@ export class HaSerialPortSelector extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@property({ attribute: false }) public context?: Record<string, any>;
|
||||
|
||||
@state() private _serialPorts?: SerialPort[];
|
||||
|
||||
@state() private _manualEntry = false;
|
||||
@@ -218,29 +172,24 @@ export class HaSerialPortSelector extends LitElement {
|
||||
language: string,
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
localize: HomeAssistant["localize"],
|
||||
recommendedDomains: Set<string>
|
||||
): Record<SerialPortType, SerialPickerItem[]> => {
|
||||
const grouped: Record<SerialPortType, SerialPickerItem[]> = {
|
||||
recommended: [],
|
||||
localize: HomeAssistant["localize"]
|
||||
): Record<SerialPortType, PickerComboBoxItem[]> => {
|
||||
const grouped: Record<SerialPortType, PickerComboBoxItem[]> = {
|
||||
serial_proxy: [],
|
||||
integration: [],
|
||||
usb: [],
|
||||
embedded: [],
|
||||
unnamed: [],
|
||||
not_recommended: [],
|
||||
};
|
||||
|
||||
for (const port of ports) {
|
||||
const type = getPortType(port, recommendedDomains);
|
||||
const type = getPortType(port);
|
||||
let primary: string;
|
||||
let description: string | undefined;
|
||||
let secondary: string | undefined;
|
||||
const searchLabels: Record<string, string | null> = {
|
||||
device: port.device,
|
||||
manufacturer: port.manufacturer,
|
||||
description: port.description,
|
||||
interface_description: port.interface_description ?? null,
|
||||
serial_number: port.serial_number,
|
||||
};
|
||||
|
||||
@@ -274,25 +223,13 @@ export class HaSerialPortSelector extends LitElement {
|
||||
searchLabels.port_name = port.device;
|
||||
}
|
||||
} else {
|
||||
const productManufacturer =
|
||||
primary =
|
||||
port.description && port.manufacturer
|
||||
? `${port.description} — ${port.manufacturer}`
|
||||
: port.description || port.manufacturer;
|
||||
|
||||
// Prefer the interface description if one exists
|
||||
if (
|
||||
port.interface_description &&
|
||||
port.interface_description !== port.description
|
||||
) {
|
||||
primary = port.interface_description;
|
||||
description = productManufacturer || undefined;
|
||||
} else {
|
||||
primary = productManufacturer || port.device;
|
||||
description = undefined;
|
||||
}
|
||||
: port.description || port.manufacturer || port.device;
|
||||
|
||||
const parts: string[] = [];
|
||||
if (primary !== port.device) {
|
||||
if (port.description || port.manufacturer) {
|
||||
parts.push(port.device);
|
||||
}
|
||||
if (port.vid && port.pid) {
|
||||
@@ -301,31 +238,16 @@ export class HaSerialPortSelector extends LitElement {
|
||||
if (port.serial_number) {
|
||||
parts.push(`S/N: ${port.serial_number}`);
|
||||
}
|
||||
|
||||
secondary = parts.join(" · ");
|
||||
}
|
||||
|
||||
let used_by: string | undefined;
|
||||
if (type === "not_recommended" && port.matching_integrations.length) {
|
||||
const integrations = port.matching_integrations
|
||||
.map((d) => integrationName(localize, d))
|
||||
.join(", ");
|
||||
used_by = localize("ui.components.selectors.serial_port.used_by", {
|
||||
integrations,
|
||||
});
|
||||
searchLabels.used_by = used_by;
|
||||
secondary = parts.length ? parts.join(" · ") : undefined;
|
||||
}
|
||||
|
||||
grouped[type].push({
|
||||
id: port.device,
|
||||
primary,
|
||||
secondary,
|
||||
icon_path: TYPE_ICONS[getBasePortType(port)],
|
||||
icon_path: TYPE_ICONS[type],
|
||||
search_labels: searchLabels,
|
||||
sorting_label: primary,
|
||||
port_type: type,
|
||||
used_by,
|
||||
description: description,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -343,42 +265,6 @@ export class HaSerialPortSelector extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _sectionLabel(type: SerialPortType): string {
|
||||
const key = `ui.components.selectors.serial_port.type.${type}` as const;
|
||||
if (type === "recommended" && this._selectorDomain) {
|
||||
return this.hass.localize(key, {
|
||||
integration: integrationName(this.hass.localize, this._selectorDomain),
|
||||
});
|
||||
}
|
||||
return this.hass.localize(key);
|
||||
}
|
||||
|
||||
private get _selectorDomain(): string | undefined {
|
||||
return this.context?.handler;
|
||||
}
|
||||
|
||||
private _memoRecommendedDomains = memoizeOne(
|
||||
(domain: string | undefined, extra: string[] | undefined): Set<string> => {
|
||||
const domains = new Set<string>();
|
||||
if (domain) {
|
||||
domains.add(domain);
|
||||
}
|
||||
if (extra) {
|
||||
for (const d of extra) {
|
||||
domains.add(d);
|
||||
}
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
);
|
||||
|
||||
private get _recommendedDomains(): Set<string> {
|
||||
return this._memoRecommendedDomains(
|
||||
this._selectorDomain,
|
||||
this.selector?.serial_port?.extra_recommended_domains
|
||||
);
|
||||
}
|
||||
|
||||
private _getPickerItems = (
|
||||
searchString?: string,
|
||||
section?: string
|
||||
@@ -392,8 +278,7 @@ export class HaSerialPortSelector extends LitElement {
|
||||
this.hass.locale.language,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.localize,
|
||||
this._recommendedDomains
|
||||
this.hass.localize
|
||||
);
|
||||
|
||||
const items: (PickerComboBoxItem | string)[] = [];
|
||||
@@ -401,7 +286,7 @@ export class HaSerialPortSelector extends LitElement {
|
||||
if (section && section !== type) {
|
||||
continue;
|
||||
}
|
||||
let groupItems: SerialPickerItem[] = grouped[type];
|
||||
let groupItems = grouped[type];
|
||||
if (searchString) {
|
||||
groupItems = multiTermSortedSearch(
|
||||
groupItems,
|
||||
@@ -414,7 +299,11 @@ export class HaSerialPortSelector extends LitElement {
|
||||
continue;
|
||||
}
|
||||
if (!section) {
|
||||
items.push(this._sectionLabel(type));
|
||||
items.push(
|
||||
this.hass.localize(
|
||||
`ui.components.selectors.serial_port.type.${type}` as const
|
||||
)
|
||||
);
|
||||
}
|
||||
items.push(...groupItems);
|
||||
}
|
||||
@@ -432,48 +321,17 @@ export class HaSerialPortSelector extends LitElement {
|
||||
},
|
||||
];
|
||||
|
||||
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => {
|
||||
const manual = item.id === MANUAL_ENTRY_ID;
|
||||
const { port_type, used_by, description } = item as SerialPickerItem;
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
compact
|
||||
.borderTop=${manual}
|
||||
style=${styleMap({
|
||||
marginTop: manual ? "var(--ha-space-3)" : "",
|
||||
opacity: port_type === "not_recommended" ? "0.6" : "",
|
||||
backgroundColor:
|
||||
port_type === "recommended"
|
||||
? "var(--ha-assist-chip-active-container-color)"
|
||||
: "",
|
||||
})}
|
||||
>
|
||||
${item.icon_path
|
||||
? html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
<span slot="headline" style="white-space: normal">${item.primary}</span>
|
||||
${used_by
|
||||
? html`<span slot="supporting-text" style="white-space: normal"
|
||||
>${used_by}</span
|
||||
>`
|
||||
: nothing}
|
||||
${description
|
||||
? html`<span slot="supporting-text" style="white-space: normal"
|
||||
>${description}</span
|
||||
>`
|
||||
: nothing}
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text" style="white-space: normal"
|
||||
>${item.secondary}</span
|
||||
>`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
private _rowRenderer = (item: PickerComboBoxItem) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
${item.icon_path
|
||||
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>`
|
||||
: nothing}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
protected render() {
|
||||
const usbLoaded = this.hass && isComponentLoaded(this.hass.config, "usb");
|
||||
@@ -535,8 +393,7 @@ export class HaSerialPortSelector extends LitElement {
|
||||
this.hass.locale.language,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.localize,
|
||||
this._recommendedDomains
|
||||
this.hass.localize
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
@@ -558,12 +415,13 @@ export class HaSerialPortSelector extends LitElement {
|
||||
this.hass.locale.language,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.localize,
|
||||
this._recommendedDomains
|
||||
this.hass.localize
|
||||
);
|
||||
return SECTION_ORDER.filter((type) => grouped[type].length).map((type) => ({
|
||||
id: type,
|
||||
label: this._sectionLabel(type),
|
||||
label: this.hass.localize(
|
||||
`ui.components.selectors.serial_port.type.${type}` as const
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { UiClockDateFormatSelector } from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-clock-date-format-picker";
|
||||
|
||||
@customElement("ha-selector-ui_clock_date_format")
|
||||
export class HaSelectorUiClockDateFormat extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: UiClockDateFormatSelector;
|
||||
|
||||
@property() public value?: string | string[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-clock-date-format-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
></ha-clock-date-format-picker>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-ui_clock_date_format": HaSelectorUiClockDateFormat;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import type { HomeAssistant } from "../../types";
|
||||
const LOAD_ELEMENTS = {
|
||||
action: () => import("./ha-selector-action"),
|
||||
addon: () => import("./ha-selector-addon"),
|
||||
automation_behavior: () => import("./ha-selector-automation-behavior"),
|
||||
app: () => import("./ha-selector-app"),
|
||||
area: () => import("./ha-selector-area"),
|
||||
areas_display: () => import("./ha-selector-areas-display"),
|
||||
@@ -65,6 +64,7 @@ const LOAD_ELEMENTS = {
|
||||
location: () => import("./ha-selector-location"),
|
||||
color_temp: () => import("./ha-selector-color-temp"),
|
||||
ui_action: () => import("./ha-selector-ui-action"),
|
||||
ui_clock_date_format: () => import("./ha-selector-ui-clock-date-format"),
|
||||
ui_color: () => import("./ha-selector-ui-color"),
|
||||
ui_state_content: () => import("./ha-selector-ui-state-content"),
|
||||
};
|
||||
|
||||
@@ -32,11 +32,7 @@ export class HaServiceIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = serviceIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.service
|
||||
).then((icn) => {
|
||||
const icon = serviceIcon(this.hass, this.service).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ class HaServicePicker extends LitElement {
|
||||
protected firstUpdated(props: PropertyValues<this>) {
|
||||
super.firstUpdated(props);
|
||||
this.hass.loadBackendTranslation("services");
|
||||
getServiceIcons(this.hass.connection, this.hass.config);
|
||||
getServiceIcons(this.hass);
|
||||
}
|
||||
|
||||
private _rowRenderer: RenderItemFunction<ServiceComboBoxItem> = (
|
||||
|
||||
@@ -29,17 +29,14 @@ export class HaServiceSectionIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = serviceSectionIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.service,
|
||||
this.section
|
||||
).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
const icon = serviceSectionIcon(this.hass, this.service, this.section).then(
|
||||
(icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
return this._renderFallback();
|
||||
}
|
||||
return this._renderFallback();
|
||||
});
|
||||
);
|
||||
|
||||
return html`${until(icon)}`;
|
||||
}
|
||||
|
||||
@@ -36,15 +36,14 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin";
|
||||
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { HomeAssistant, PanelInfo, Route } from "../types";
|
||||
import { isMobileClient } from "../util/is_mobile";
|
||||
import "./animation/ha-fade-in";
|
||||
import "./ha-fade-in";
|
||||
import "./ha-icon";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-md-list";
|
||||
import "./ha-md-list-item";
|
||||
import "./ha-spinner";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tooltip";
|
||||
import "./item/ha-list-item-button";
|
||||
import "./list/ha-list-nav";
|
||||
import "./user/ha-user-badge";
|
||||
|
||||
const SORT_VALUE_URL_PATHS = {
|
||||
@@ -353,12 +352,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
|
||||
private _renderAllPanels(selectedPanel: string) {
|
||||
const renderList = (content, cls: string, scrollable: boolean) =>
|
||||
html`<ha-list-nav
|
||||
html`<ha-md-list
|
||||
class=${classMap({
|
||||
"ha-scrollbar": scrollable,
|
||||
[cls]: true,
|
||||
})}
|
||||
>${content}</ha-list-nav
|
||||
>${content}</ha-md-list
|
||||
>`;
|
||||
|
||||
if (!this._panelOrder || !this._hiddenPanels) {
|
||||
@@ -430,8 +429,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
const iconPath = getPanelIconPath(panel);
|
||||
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
<ha-md-list-item
|
||||
.href=${`/${urlPath}`}
|
||||
type="link"
|
||||
id="sidebar-panel-${urlPath}"
|
||||
class=${classMap({ selected: isSelected })}
|
||||
>
|
||||
@@ -439,7 +439,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
|
||||
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
|
||||
<span class="item-text" slot="headline">${title}</span>
|
||||
</ha-list-item-button>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand && title
|
||||
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
|
||||
: nothing}
|
||||
@@ -456,8 +456,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
}
|
||||
const isSelected = selectedPanel === "config";
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
<ha-md-list-item
|
||||
class="configuration ${classMap({ selected: isSelected })}"
|
||||
type="button"
|
||||
href="/config"
|
||||
id="sidebar-config"
|
||||
>
|
||||
@@ -479,7 +480,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
>
|
||||
`
|
||||
: nothing}
|
||||
</ha-list-item-button>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-config",
|
||||
@@ -495,9 +496,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
: 0;
|
||||
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
<ha-md-list-item
|
||||
class="notifications"
|
||||
@click=${this._handleShowNotificationDrawer}
|
||||
type="button"
|
||||
id="sidebar-notifications"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
|
||||
@@ -512,7 +514,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
${notificationCount > 0
|
||||
? html`<span class="badge" slot="end">${notificationCount}</span>`
|
||||
: nothing}
|
||||
</ha-list-item-button>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-notifications",
|
||||
@@ -527,8 +529,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
const isSelected = selectedPanel === "profile";
|
||||
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
<ha-md-list-item
|
||||
href="/profile"
|
||||
type="link"
|
||||
id="sidebar-profile"
|
||||
class=${classMap({
|
||||
user: true,
|
||||
@@ -544,7 +547,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.user ? this.hass.user.name : ""}</span
|
||||
>
|
||||
</ha-list-item-button>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand && this.hass.user
|
||||
? this._renderToolTip("sidebar-profile", this.hass.user.name)
|
||||
: nothing}
|
||||
@@ -556,15 +559,16 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-list-item-button
|
||||
<ha-md-list-item
|
||||
@click=${this._handleExternalAppConfiguration}
|
||||
type="button"
|
||||
id="sidebar-external-config"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
|
||||
>
|
||||
</ha-list-item-button>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-external-config",
|
||||
@@ -575,10 +579,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
}
|
||||
|
||||
private _renderToolTip(id: string, text: string) {
|
||||
if (isMobileClient) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-tooltip
|
||||
for=${id}
|
||||
show-delay="0"
|
||||
@@ -713,10 +713,10 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
ha-list-nav {
|
||||
ha-md-list {
|
||||
overflow-x: hidden;
|
||||
background: none;
|
||||
margin-left: var(--safe-area-inset-left, 0px);
|
||||
margin-block: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@@ -726,38 +726,42 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
ha-list-nav.before-spacer {
|
||||
ha-md-list.before-spacer {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
ha-list-nav.after-spacer {
|
||||
ha-md-list.after-spacer {
|
||||
padding-top: 0;
|
||||
min-height: fit-content;
|
||||
}
|
||||
|
||||
ha-list-item-button {
|
||||
ha-md-list-item {
|
||||
flex-shrink: 0;
|
||||
margin: 0 var(--ha-space-1) var(--ha-space-1);
|
||||
box-sizing: border-box;
|
||||
margin: var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--ha-row-item-min-height: var(--ha-space-10);
|
||||
--ha-row-item-padding-block: 0;
|
||||
--md-list-item-one-line-container-height: var(--ha-space-10);
|
||||
--md-list-item-top-space: 0;
|
||||
--md-list-item-bottom-space: 0;
|
||||
width: var(--ha-space-12);
|
||||
position: relative;
|
||||
--md-list-item-label-text-color: var(--sidebar-text-color);
|
||||
--md-list-item-leading-space: var(--ha-space-3);
|
||||
--md-list-item-trailing-space: var(--ha-space-3);
|
||||
--md-list-item-leading-icon-size: var(--ha-space-6);
|
||||
transition: width var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
ha-list-item-button::part(headline) {
|
||||
color: var(--sidebar-text-color);
|
||||
}
|
||||
:host([expanded]) ha-list-item-button {
|
||||
:host([expanded]) ha-md-list-item {
|
||||
width: 248px;
|
||||
}
|
||||
:host([narrow][expanded]) ha-list-item-button {
|
||||
:host([narrow][expanded]) ha-md-list-item {
|
||||
width: calc(240px - var(--safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
ha-list-item-button.selected::part(headline) {
|
||||
color: var(--sidebar-selected-icon-color);
|
||||
ha-md-list-item.selected {
|
||||
--md-list-item-label-text-color: var(--sidebar-selected-icon-color);
|
||||
--md-ripple-hover-color: var(--sidebar-selected-icon-color);
|
||||
}
|
||||
ha-list-item-button.selected::before {
|
||||
ha-md-list-item.selected::before {
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -779,12 +783,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
color: var(--sidebar-icon-color);
|
||||
}
|
||||
|
||||
ha-list-item-button.selected ha-svg-icon[slot="start"],
|
||||
ha-list-item-button.selected ha-icon[slot="start"] {
|
||||
ha-md-list-item.selected ha-svg-icon[slot="start"],
|
||||
ha-md-list-item.selected ha-icon[slot="start"] {
|
||||
color: var(--sidebar-selected-icon-color);
|
||||
}
|
||||
|
||||
ha-list-item-button .item-text {
|
||||
ha-md-list-item .item-text {
|
||||
display: block;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
@@ -797,7 +801,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
max-width var(--ha-animation-duration-normal) ease,
|
||||
opacity var(--ha-animation-duration-normal) ease;
|
||||
}
|
||||
:host([expanded]) ha-list-item-button .item-text {
|
||||
:host([expanded]) ha-md-list-item .item-text {
|
||||
max-width: 100%;
|
||||
opacity: 1;
|
||||
transition-delay: 0ms, 80ms;
|
||||
@@ -839,17 +843,13 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
ha-user-badge {
|
||||
width: var(--ha-space-10);
|
||||
height: var(--ha-space-10);
|
||||
ha-md-list-item.user {
|
||||
--md-list-item-leading-icon-size: var(--ha-space-10);
|
||||
--md-list-item-leading-space: var(--ha-space-1);
|
||||
}
|
||||
|
||||
ha-list-item-button.user {
|
||||
--ha-row-item-padding-inline: var(--ha-space-2) var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-list-item-button.user.rtl {
|
||||
--ha-row-item-padding-inline: var(--ha-space-4) var(--ha-space-3);
|
||||
ha-md-list-item.user.rtl {
|
||||
--md-list-item-leading-space: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-user-badge {
|
||||
@@ -869,8 +869,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.menu,
|
||||
ha-list-item-button,
|
||||
ha-list-item-button .item-text,
|
||||
ha-md-list-item,
|
||||
ha-md-list-item .item-text,
|
||||
.title {
|
||||
transition: 1ms;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import { forwardHaptic } from "../data/haptics";
|
||||
* @cssprop --ha-switch-checked-thumb-border-color-hover - Border color of the checked thumb on hover.
|
||||
* @cssprop --ha-switch-thumb-box-shadow - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
|
||||
* @cssprop --ha-switch-disabled-opacity - Opacity of the switch when disabled. Defaults to `0.2`.
|
||||
* @cssprop --ha-switch-min-touch-size - Minimum touch target size around the switch. Defaults to `40px`.
|
||||
* @cssprop --ha-switch-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
* @cssprop --ha-switch-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
|
||||
*
|
||||
@@ -90,23 +89,8 @@ export class HaSwitch extends Switch {
|
||||
}
|
||||
|
||||
label {
|
||||
position: relative;
|
||||
height: max(var(--thumb-size), var(--wa-form-control-toggle-size));
|
||||
}
|
||||
label::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: var(--ha-switch-min-touch-size, 40px);
|
||||
min-height: var(--ha-switch-min-touch-size, 40px);
|
||||
}
|
||||
label.disabled::before {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.switch {
|
||||
background-color: var(
|
||||
|
||||
@@ -157,24 +157,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
),
|
||||
};
|
||||
|
||||
@state() private _pendingEntityId?: string;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
changedProps.has("hass") &&
|
||||
this.hass.states !== changedProps.get("hass")?.states &&
|
||||
this.hass.states[this._pendingEntityId]
|
||||
) {
|
||||
this._addTarget(this._pendingEntityId, "entity");
|
||||
this._pendingEntityId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _createFuseIndex = (states, keys: FuseWeightedKey[]) =>
|
||||
@@ -544,11 +532,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._addTarget(item.entityId, "entity");
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
// prevent error that new entity_id isn't in hass object
|
||||
requestAnimationFrame(() => {
|
||||
this._addTarget(item.entityId!, "entity");
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import {
|
||||
mdiAlertCircleOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiChevronDown,
|
||||
mdiHelpCircleOutline,
|
||||
mdiProgressClock,
|
||||
mdiProgressWrench,
|
||||
mdiStopCircleOutline,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import type { Trace } from "../data/trace";
|
||||
import "./ha-button";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import { formatDateTimeWithSeconds } from "../common/datetime/format_date_time";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
|
||||
@customElement("ha-trace-picker")
|
||||
class HaTracePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public traces!: Trace[];
|
||||
|
||||
@property({ attribute: false }) public value?: string;
|
||||
|
||||
@query("ha-generic-picker") private tracePicker?: HaGenericPicker;
|
||||
|
||||
protected render() {
|
||||
return html` <ha-generic-picker
|
||||
name="trace"
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.trace.select_trace"
|
||||
)}
|
||||
.value=${this.value}
|
||||
.getItems=${this._getTraces}
|
||||
required
|
||||
>
|
||||
<ha-button
|
||||
slot="field"
|
||||
appearance="filled"
|
||||
variant="neutral"
|
||||
size="small"
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
${this._renderTracePickerValue(this.value!)}
|
||||
<ha-svg-icon slot="end" .path=${mdiChevronDown}></ha-svg-icon>
|
||||
</ha-button>
|
||||
</ha-generic-picker>`;
|
||||
}
|
||||
|
||||
private _openPicker(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
this.tracePicker?.open();
|
||||
}
|
||||
|
||||
private _getTraces = (): PickerComboBoxItem[] =>
|
||||
this.traces?.map((trace) => {
|
||||
const renderRuntime = () =>
|
||||
(
|
||||
(new Date(trace.timestamp.finish!).getTime() -
|
||||
new Date(trace.timestamp.start).getTime()) /
|
||||
1000
|
||||
).toFixed(2);
|
||||
|
||||
const item: PickerComboBoxItem = {
|
||||
id: trace.run_id,
|
||||
primary: formatDateTimeWithSeconds(
|
||||
new Date(trace.timestamp.start),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
),
|
||||
};
|
||||
if (trace.state === "running") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.still_running"
|
||||
);
|
||||
item.icon_path = mdiProgressClock;
|
||||
} else if (trace.state === "debugged") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.debugged"
|
||||
);
|
||||
item.icon_path = mdiProgressWrench;
|
||||
} else if (trace.script_execution === "finished") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.finished",
|
||||
{
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = mdiCheckCircleOutline;
|
||||
} else if (trace.script_execution === "aborted") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.aborted",
|
||||
{
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = mdiAlertCircleOutline;
|
||||
} else if (trace.script_execution === "cancelled") {
|
||||
item.secondary = this.hass.localize(
|
||||
"ui.panel.config.automation.trace.picker.cancelled",
|
||||
{
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = mdiAlertCircleOutline;
|
||||
} else {
|
||||
let message:
|
||||
| "stopped_failed_conditions"
|
||||
| "stopped_failed_single"
|
||||
| "stopped_failed_max_runs"
|
||||
| "stopped_error"
|
||||
| "stopped_unknown_reason";
|
||||
let error: string | undefined;
|
||||
let icon: string;
|
||||
|
||||
switch (trace.script_execution) {
|
||||
case "failed_conditions":
|
||||
message = "stopped_failed_conditions";
|
||||
icon = mdiStopCircleOutline;
|
||||
break;
|
||||
case "failed_single":
|
||||
message = "stopped_failed_single";
|
||||
icon = mdiStopCircleOutline;
|
||||
break;
|
||||
case "failed_max_runs":
|
||||
message = "stopped_failed_max_runs";
|
||||
icon = mdiStopCircleOutline;
|
||||
break;
|
||||
case "error":
|
||||
message = "stopped_error";
|
||||
error = trace.error!;
|
||||
icon = mdiAlertCircleOutline;
|
||||
break;
|
||||
default:
|
||||
message = "stopped_unknown_reason";
|
||||
icon = mdiHelpCircleOutline;
|
||||
}
|
||||
|
||||
item.secondary = this.hass.localize(
|
||||
`ui.panel.config.automation.trace.picker.${message}`,
|
||||
{
|
||||
error,
|
||||
executiontime: renderRuntime(),
|
||||
}
|
||||
);
|
||||
item.icon_path = icon;
|
||||
}
|
||||
return item;
|
||||
}) ?? [];
|
||||
|
||||
private _renderTracePickerValue = (runId: string) => {
|
||||
const trace = this.traces?.find((t) => t.run_id === runId);
|
||||
return html`${trace
|
||||
? formatDateTimeWithSeconds(
|
||||
new Date(trace.timestamp.start),
|
||||
this.hass.locale,
|
||||
this.hass.config
|
||||
)
|
||||
: runId}`;
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
}
|
||||
ha-button {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-trace-picker": HaTracePicker;
|
||||
}
|
||||
}
|
||||
@@ -69,11 +69,7 @@ export class HaTriggerIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = triggerIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.trigger
|
||||
).then((icn) => {
|
||||
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import "./ha-alert";
|
||||
import "./ha-button";
|
||||
import "./ha-code-editor";
|
||||
import type { HaCodeEditor } from "./ha-code-editor";
|
||||
@@ -57,8 +58,15 @@ export class HaYamlEditor extends LitElement {
|
||||
@property({ attribute: "has-extra-actions", type: Boolean })
|
||||
public hasExtraActions = false;
|
||||
|
||||
@property({ attribute: "show-errors", type: Boolean })
|
||||
public showErrors = true;
|
||||
|
||||
@state() private _yaml = "";
|
||||
|
||||
@state() private _error = "";
|
||||
|
||||
@state() private _showingError = false;
|
||||
|
||||
@query("ha-code-editor") _codeEditor?: HaCodeEditor;
|
||||
|
||||
public setValue(value): void {
|
||||
@@ -118,14 +126,16 @@ export class HaYamlEditor extends LitElement {
|
||||
.disableFullscreen=${this.disableFullscreen}
|
||||
.inDialog=${this.inDialog}
|
||||
mode="yaml"
|
||||
lint
|
||||
autocomplete-entities
|
||||
autocomplete-icons
|
||||
.error=${this.isValid === false}
|
||||
@value-changed=${this._onChange}
|
||||
@editor-save=${this._onEditorSave}
|
||||
@blur=${this._onBlur}
|
||||
dir="ltr"
|
||||
></ha-code-editor>
|
||||
${this._showingError
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
${this.copyClipboard || this.hasExtraActions
|
||||
? html`
|
||||
<div class="card-actions">
|
||||
@@ -148,13 +158,9 @@ export class HaYamlEditor extends LitElement {
|
||||
private _onChange(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
this._yaml = ev.detail.value;
|
||||
let parsed: unknown;
|
||||
let parsed;
|
||||
let isValid = true;
|
||||
let errorMsg: string | undefined;
|
||||
let yamlError: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
message?: string;
|
||||
} | null = null;
|
||||
let errorMsg;
|
||||
|
||||
if (this._yaml) {
|
||||
try {
|
||||
@@ -162,13 +168,15 @@ export class HaYamlEditor extends LitElement {
|
||||
} catch (err: any) {
|
||||
// Invalid YAML
|
||||
isValid = false;
|
||||
yamlError = err;
|
||||
errorMsg = `${this.hass.localize("ui.components.yaml-editor.error", { reason: err.reason })}${err.mark ? ` (${this.hass.localize("ui.components.yaml-editor.error_location", { line: err.mark.line + 1, column: err.mark.column + 1 })})` : ""}`;
|
||||
}
|
||||
} else {
|
||||
parsed = {};
|
||||
}
|
||||
this._codeEditor?.setYamlError(yamlError);
|
||||
this._error = errorMsg ?? "";
|
||||
if (isValid) {
|
||||
this._showingError = false;
|
||||
}
|
||||
|
||||
this.value = parsed;
|
||||
this.isValid = isValid;
|
||||
@@ -180,23 +188,16 @@ export class HaYamlEditor extends LitElement {
|
||||
} as any);
|
||||
}
|
||||
|
||||
private _onBlur(): void {
|
||||
if (this.showErrors && this._error) {
|
||||
this._showingError = true;
|
||||
}
|
||||
}
|
||||
|
||||
get yaml() {
|
||||
return this._yaml;
|
||||
}
|
||||
|
||||
get codemirror() {
|
||||
return this._codeEditor?.codemirror;
|
||||
}
|
||||
|
||||
get hasComments(): boolean {
|
||||
return this._codeEditor?.hasComments ?? false;
|
||||
}
|
||||
|
||||
private _onEditorSave(ev: CustomEvent): void {
|
||||
fireEvent(this, "editor-save");
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
private async _copyYaml(): Promise<void> {
|
||||
if (this.yaml) {
|
||||
await copyToClipboard(this.yaml);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiMagnify } from "@mdi/js";
|
||||
import { css, html, type PropertyValues } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import { HaInput } from "./ha-input";
|
||||
|
||||
/**
|
||||
@@ -15,6 +17,10 @@ import { HaInput } from "./ha-input";
|
||||
*/
|
||||
@customElement("ha-input-search")
|
||||
export class HaInputSearch extends HaInput {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.withClear = true;
|
||||
@@ -29,7 +35,7 @@ export class HaInputSearch extends HaInput {
|
||||
!this.placeholder &&
|
||||
(!this.hasUpdated || changedProps.has("_i18n"))
|
||||
) {
|
||||
this.placeholder = this.i18n?.localize?.("ui.common.search") || "Search";
|
||||
this.placeholder = this._i18n.localize("ui.common.search");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,21 +2,19 @@ import "@home-assistant/webawesome/dist/components/animation/animation";
|
||||
import "@home-assistant/webawesome/dist/components/input/input";
|
||||
import type WaInput from "@home-assistant/webawesome/dist/components/input/input";
|
||||
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiClose, mdiEye, mdiEyeOff } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
css,
|
||||
html,
|
||||
nothing,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tooltip";
|
||||
@@ -127,10 +125,6 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
@query("wa-input")
|
||||
private _input?: WaInput;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
protected i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
private readonly _hasSlotController = new HasSlotController(
|
||||
this,
|
||||
"label",
|
||||
@@ -239,22 +233,19 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
${this.renderStartDefault()}
|
||||
</slot>
|
||||
<slot name="end" slot="end"> ${this.renderEndDefault()} </slot>
|
||||
<slot name="clear-button" slot="clear-button">
|
||||
<ha-icon-button
|
||||
@click=${this._handleClearClick}
|
||||
.label=${this.i18n?.localize?.("ui.components.input.clear") ||
|
||||
"Clear"}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
<slot name="clear-icon" slot="clear-icon">
|
||||
<ha-icon-button .path=${mdiClose}></ha-icon-button>
|
||||
</slot>
|
||||
<slot name="password-toggle-button" slot="password-toggle-button">
|
||||
<slot name="show-password-icon" slot="show-password-icon">
|
||||
<ha-icon-button
|
||||
@keydown=${stopPropagation}
|
||||
@click=${this._handlePasswordToggle}
|
||||
.label=${this.i18n?.localize?.(
|
||||
`ui.components.input.${this.passwordVisible ? "hide_password" : "show_password"}`
|
||||
) || (this.passwordVisible ? "Hide password" : "Show password")}
|
||||
.path=${this.passwordVisible ? mdiEyeOff : mdiEye}
|
||||
.path=${mdiEye}
|
||||
></ha-icon-button>
|
||||
</slot>
|
||||
<slot name="hide-password-icon" slot="hide-password-icon">
|
||||
<ha-icon-button
|
||||
@keydown=${stopPropagation}
|
||||
.path=${mdiEyeOff}
|
||||
></ha-icon-button>
|
||||
</slot>
|
||||
<div
|
||||
@@ -302,14 +293,6 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
}
|
||||
};
|
||||
|
||||
private _handleClearClick() {
|
||||
this._input?.clear();
|
||||
}
|
||||
|
||||
private _handlePasswordToggle() {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
waInputStyles,
|
||||
css`
|
||||
@@ -431,12 +414,6 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
|
||||
ha-icon-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
|
||||
:host([appearance="outlined"]) wa-input.no-label {
|
||||
--ha-icon-button-size: 24px;
|
||||
--mdc-icon-size: 18px;
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { HaRowItem } from "./ha-row-item";
|
||||
|
||||
/**
|
||||
* @element ha-list-item-base
|
||||
* @extends {HaRowItem}
|
||||
*
|
||||
* @summary
|
||||
* Non-interactive list row (role `listitem`). Base class for
|
||||
* `ha-list-item-button`, `ha-list-item-option`.
|
||||
*
|
||||
* @cssprop --ha-list-item-focus-radius - Focus outline border-radius.
|
||||
* @cssprop --ha-list-item-focus-width - Focus outline width (steady state).
|
||||
* @cssprop --ha-list-item-focus-width-start - Focus outline width at the start of the focus-in animation.
|
||||
* @cssprop --ha-list-item-focus-offset - Focus outline offset.
|
||||
* @cssprop --ha-list-item-focus-background - Background color applied on keyboard focus.
|
||||
*
|
||||
* @attr {boolean} interactive - Opts the row into the parent list's roving tabindex. Interactive subclasses set this automatically.
|
||||
*/
|
||||
@customElement("ha-list-item-base")
|
||||
export class HaListItemBase extends HaRowItem {
|
||||
/**
|
||||
* Whether the item takes keyboard focus. Read by the parent list to decide
|
||||
* if it should be part of the roving-tabindex ring. Interactive subclasses
|
||||
* (`ha-list-item-button`, `-option`, `-todo`) override the default to `true`.
|
||||
* For the plain base row, set the `interactive` attribute to opt into focus
|
||||
* (useful for sortable rows where you need keyboard reorder but no click
|
||||
* action).
|
||||
*/
|
||||
@property({ type: Boolean, reflect: true }) public interactive = false;
|
||||
|
||||
/** Host `role` attribute. Subclasses override. */
|
||||
protected readonly defaultRole: string = "listitem";
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.hasAttribute("role")) {
|
||||
this.setAttribute("role", this.defaultRole);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the item (Enter/Space from the parent list). Default dispatches
|
||||
* a click on the host. Subclasses that wrap a native element (e.g. `<a>`)
|
||||
* override this to click the inner element so browser default actions
|
||||
* (like anchor navigation) fire.
|
||||
*/
|
||||
public activate(): void {
|
||||
this.click();
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaRowItem.styles,
|
||||
css`
|
||||
:host {
|
||||
--ha-list-item-focus-radius: var(--ha-border-radius-sm);
|
||||
--ha-list-item-focus-width: 2px;
|
||||
--ha-list-item-focus-width-start: var(--ha-space-2);
|
||||
--ha-list-item-focus-offset: -2px;
|
||||
--ha-list-item-focus-background: var(
|
||||
--ha-color-fill-neutral-quiet-hover
|
||||
);
|
||||
}
|
||||
:host(:focus) {
|
||||
outline: none;
|
||||
}
|
||||
.base {
|
||||
border-radius: var(--ha-list-item-focus-radius);
|
||||
outline: var(--ha-list-item-focus-width) solid transparent;
|
||||
outline-offset: var(--ha-list-item-focus-offset);
|
||||
transition:
|
||||
outline-color var(--ha-animation-duration-fast) ease-out,
|
||||
background-color var(--ha-animation-duration-fast) ease-out;
|
||||
}
|
||||
@keyframes ha-list-item-focus-in {
|
||||
from {
|
||||
outline-width: var(--ha-list-item-focus-width-start);
|
||||
outline-offset: calc(-1 * var(--ha-list-item-focus-width-start));
|
||||
}
|
||||
to {
|
||||
outline-width: var(--ha-list-item-focus-width);
|
||||
outline-offset: var(--ha-list-item-focus-offset);
|
||||
}
|
||||
}
|
||||
:host(:focus-visible) .base {
|
||||
outline-color: var(--ha-color-focus);
|
||||
background-color: var(--ha-list-item-focus-background);
|
||||
animation: ha-list-item-focus-in var(--ha-animation-duration-normal)
|
||||
ease-in;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-item-base": HaListItemBase;
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import "../ha-ripple";
|
||||
import { HaListItemBase } from "./ha-list-item-base";
|
||||
|
||||
/**
|
||||
* @element ha-list-item-button
|
||||
* @extends {HaListItemBase}
|
||||
*
|
||||
* @summary
|
||||
* Interactive list row. Renders an inner `<a>` when `href` is set, otherwise
|
||||
* a `<button>`. The full row is the hit target. When placed in a list using
|
||||
* roving tabindex, the host is the tab stop and the inner element carries
|
||||
* `tabindex="-1"`. For a non-interactive row, use `ha-list-item-base`.
|
||||
*
|
||||
* @csspart ripple - The ripple effect element.
|
||||
*
|
||||
* @attr {string} href - URL. When set, renders an `<a>` instead of a `<button>`.
|
||||
* @attr {string} target - Anchor `target` attribute (requires `href`).
|
||||
* @attr {string} rel - Anchor `rel` attribute (requires `href`).
|
||||
* @attr {string} download - Anchor `download` attribute (requires `href`).
|
||||
*/
|
||||
@customElement("ha-list-item-button")
|
||||
export class HaListItemButton extends HaListItemBase {
|
||||
public override interactive = true;
|
||||
|
||||
@property({ type: String }) public href?: string;
|
||||
|
||||
@property({ type: String }) public target?: string;
|
||||
|
||||
@property({ type: String }) public rel?: string;
|
||||
|
||||
@property({ type: String }) public download?: string;
|
||||
|
||||
public override activate(): void {
|
||||
this.renderRoot.querySelector<HTMLElement>("#item")?.click();
|
||||
}
|
||||
|
||||
protected _renderBase(inner: TemplateResult): TemplateResult {
|
||||
if (this.href !== undefined) {
|
||||
return html`<a
|
||||
part="base"
|
||||
class="base interactive"
|
||||
id="item"
|
||||
href=${ifDefined(this.disabled ? undefined : this.href)}
|
||||
target=${ifDefined(this.target)}
|
||||
rel=${ifDefined(this.rel)}
|
||||
download=${ifDefined(this.download)}
|
||||
tabindex="-1"
|
||||
aria-disabled=${this.disabled ? "true" : "false"}
|
||||
>
|
||||
${this._renderRipple()}${inner}
|
||||
</a>`;
|
||||
}
|
||||
return html`<button
|
||||
part="base"
|
||||
class="base interactive"
|
||||
id="item"
|
||||
type="button"
|
||||
?disabled=${this.disabled}
|
||||
tabindex="-1"
|
||||
>
|
||||
${this._renderRipple()}${inner}
|
||||
</button>`;
|
||||
}
|
||||
|
||||
private _renderRipple() {
|
||||
return html`<ha-ripple
|
||||
part="ripple"
|
||||
for="item"
|
||||
?disabled=${this.disabled}
|
||||
></ha-ripple>`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemBase.styles,
|
||||
css`
|
||||
:host {
|
||||
cursor: pointer;
|
||||
--ha-ripple-color: var(--primary-text-color);
|
||||
}
|
||||
:host([disabled]) {
|
||||
cursor: default;
|
||||
}
|
||||
.base.interactive {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: inherit;
|
||||
text-decoration: none;
|
||||
appearance: none;
|
||||
cursor: inherit;
|
||||
}
|
||||
:host([disabled]) .base.interactive {
|
||||
color: var(--disabled-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-item-button": HaListItemButton;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../ha-checkbox";
|
||||
import "../ha-ripple";
|
||||
import { HaListItemBase } from "./ha-list-item-base";
|
||||
|
||||
export type HaListItemOptionAppearance = "line" | "checkbox";
|
||||
|
||||
export type HaListItemOptionSelectionPosition = "start" | "end";
|
||||
|
||||
/**
|
||||
* @element ha-list-item-option
|
||||
* @extends {HaListItemBase}
|
||||
*
|
||||
* @summary
|
||||
* Selectable list row (role `option`). Selection state is driven by the parent
|
||||
* `ha-list-selectable`; reflects `aria-selected`. When `appearance="checkbox"`, renders
|
||||
* a decorative `<ha-checkbox>` (clicks on the row are handled by the listbox).
|
||||
*
|
||||
* @csspart checkbox - Wrapper around the `<ha-checkbox>` when `appearance="checkbox"`.
|
||||
* @csspart ripple - The ripple effect element.
|
||||
*
|
||||
* @cssprop --ha-list-item-selected-background - Background color when selected (`appearance="line"`).
|
||||
*
|
||||
* @attr {boolean} selected - Whether the option is selected. Set by the parent `ha-list-selectable`.
|
||||
* @attr {string} value - Value identifying the option.
|
||||
* @attr {("line"|"checkbox")} appearance - Visual style. "line" highlights the row; "checkbox" renders an `ha-checkbox`.
|
||||
* @attr {("start"|"end")} selection-position - Side the checkbox sits on when `appearance="checkbox"`.
|
||||
*/
|
||||
@customElement("ha-list-item-option")
|
||||
export class HaListItemOption extends HaListItemBase {
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
@property({ type: String }) public value?: string;
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
public appearance: HaListItemOptionAppearance = "line";
|
||||
|
||||
@property({ type: String, reflect: true, attribute: "selection-position" })
|
||||
public selectionPosition: HaListItemOptionSelectionPosition = "start";
|
||||
|
||||
protected override readonly defaultRole = "option";
|
||||
|
||||
public override interactive = true;
|
||||
|
||||
public update(changed: Map<string, unknown>) {
|
||||
super.update(changed);
|
||||
if (changed.has("selected")) {
|
||||
this.setAttribute("aria-selected", this.selected ? "true" : "false");
|
||||
}
|
||||
if (changed.has("disabled")) {
|
||||
this.setAttribute("aria-disabled", this.disabled ? "true" : "false");
|
||||
}
|
||||
}
|
||||
|
||||
protected _renderBase(inner: TemplateResult): TemplateResult {
|
||||
return html`<div part="base" class="base" id="item">
|
||||
${this._renderRipple()}${this.selectionPosition === "start"
|
||||
? this._renderCheckbox()
|
||||
: nothing}${inner}${this.selectionPosition === "end"
|
||||
? this._renderCheckbox()
|
||||
: nothing}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _renderRipple() {
|
||||
return html`<ha-ripple
|
||||
part="ripple"
|
||||
for="item"
|
||||
?disabled=${this.disabled}
|
||||
></ha-ripple>`;
|
||||
}
|
||||
|
||||
private _renderCheckbox() {
|
||||
if (this.appearance !== "checkbox") {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div part="checkbox" class="checkbox" inert>
|
||||
<ha-checkbox
|
||||
.checked=${this.selected}
|
||||
.disabled=${this.disabled}
|
||||
></ha-checkbox>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemBase.styles,
|
||||
css`
|
||||
:host {
|
||||
cursor: pointer;
|
||||
--ha-ripple-color: var(--primary-text-color);
|
||||
--ha-list-item-selected-background: var(
|
||||
--ha-color-fill-primary-quiet-resting,
|
||||
rgba(var(--rgb-primary-color), 0.08)
|
||||
);
|
||||
}
|
||||
:host([disabled]) {
|
||||
cursor: default;
|
||||
}
|
||||
.base {
|
||||
cursor: inherit;
|
||||
}
|
||||
:host([appearance="line"][selected]:not([disabled])) .base,
|
||||
:host([appearance="line"][active]:not([disabled])) .base {
|
||||
background-color: var(--ha-list-item-selected-background);
|
||||
}
|
||||
:host([appearance="line"][selected]:not([disabled])) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
.checkbox ha-checkbox {
|
||||
pointer-events: none;
|
||||
}
|
||||
ha-checkbox::part(base) {
|
||||
gap: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-item-option": HaListItemOption;
|
||||
}
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import { HasSlotController } from "@home-assistant/webawesome/dist/internal/slot";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* @element ha-row-item
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @summary
|
||||
* Generic row layout primitive. Renders a horizontal row with optional
|
||||
* leading/trailing slots and a stacked middle column (headline +
|
||||
* supporting text). Role-agnostic; use `ha-list-item-base` and its
|
||||
* subclasses for list semantics.
|
||||
*
|
||||
* @slot start - Leading container (usually icon/avatar).
|
||||
* @slot end - Trailing container (usually meta/chevron).
|
||||
* @slot headline - Primary text (overrides the `headline` attribute).
|
||||
* @slot supporting-text - Secondary text (overrides the `supporting-text` attribute).
|
||||
* @slot content - Escape hatch: replaces the entire middle column (headline + supporting-text).
|
||||
*
|
||||
* @csspart base - The outer container.
|
||||
* @csspart start - The leading slot wrapper.
|
||||
* @csspart content - The middle column wrapper.
|
||||
* @csspart headline - The headline wrapper.
|
||||
* @csspart supporting-text - The supporting-text wrapper.
|
||||
* @csspart end - The trailing slot wrapper.
|
||||
*
|
||||
* @cssprop --ha-row-item-padding-block - Vertical padding inside the row.
|
||||
* @cssprop --ha-row-item-padding-inline - Horizontal padding inside the row.
|
||||
* @cssprop --ha-row-item-gap - Gap between start, content, and end.
|
||||
* @cssprop --ha-row-item-min-height - Minimum row height.
|
||||
*
|
||||
* @attr {string} headline - Primary text. Overridden by the `headline` slot.
|
||||
* @attr {string} supporting-text - Secondary text. Overridden by the `supporting-text` slot.
|
||||
* @attr {boolean} disabled - Dims the row and blocks pointer events.
|
||||
*/
|
||||
@customElement("ha-row-item")
|
||||
export class HaRowItem extends LitElement {
|
||||
@property({ type: String }) public headline?: string;
|
||||
|
||||
@property({ type: String, attribute: "supporting-text" })
|
||||
public supportingText?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
protected readonly _slotController = new HasSlotController(
|
||||
this,
|
||||
"start",
|
||||
"end",
|
||||
"headline",
|
||||
"supporting-text",
|
||||
"content"
|
||||
);
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return this._renderBase(this._renderInner());
|
||||
}
|
||||
|
||||
protected _renderBase(inner: TemplateResult): TemplateResult {
|
||||
return html`<div part="base" class="base" id="item">${inner}</div>`;
|
||||
}
|
||||
|
||||
protected _renderInner(): TemplateResult {
|
||||
const hasStart = this._slotController.test("start");
|
||||
const hasEnd = this._slotController.test("end");
|
||||
const hasContent = this._slotController.test("content");
|
||||
|
||||
return html`
|
||||
${hasStart
|
||||
? html`<div part="start" class="start">
|
||||
<slot name="start"></slot>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div part="content" class="content">
|
||||
${hasContent
|
||||
? html`<slot name="content"></slot>`
|
||||
: this._renderDefaultContent()}
|
||||
</div>
|
||||
${hasEnd
|
||||
? html`<div part="end" class="end">
|
||||
<slot name="end"></slot>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
protected _renderDefaultContent(): TemplateResult {
|
||||
const hasHeadlineSlot = this._slotController.test("headline");
|
||||
const hasSupportingSlot = this._slotController.test("supporting-text");
|
||||
|
||||
const showHeadline = hasHeadlineSlot || this.headline !== undefined;
|
||||
const showSupporting =
|
||||
hasSupportingSlot || this.supportingText !== undefined;
|
||||
|
||||
return html`
|
||||
${showHeadline
|
||||
? html`<div part="headline" class="headline">
|
||||
<slot name="headline">${this.headline ?? nothing}</slot>
|
||||
</div>`
|
||||
: nothing}
|
||||
${showSupporting
|
||||
? html`<div part="supporting-text" class="supporting">
|
||||
<slot name="supporting-text"
|
||||
>${this.supportingText ?? nothing}</slot
|
||||
>
|
||||
</div>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
color: var(--primary-text-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
--ha-row-item-padding-block: var(--ha-space-3);
|
||||
--ha-row-item-padding-inline: var(--ha-space-4);
|
||||
--ha-row-item-gap: var(--ha-space-4);
|
||||
--ha-row-item-min-height: 48px;
|
||||
}
|
||||
:host([disabled]) {
|
||||
color: var(--disabled-text-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
.base {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: var(--ha-row-item-gap);
|
||||
padding-block: var(--ha-row-item-padding-block);
|
||||
padding-inline: var(--ha-row-item-padding-inline);
|
||||
min-height: var(--ha-row-item-min-height);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
.start,
|
||||
.end {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.headline {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.supporting {
|
||||
color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-s);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-row-item": HaRowItem;
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import "./types";
|
||||
|
||||
/**
|
||||
* @element ha-list-base
|
||||
* @extends {LitElement}
|
||||
*
|
||||
* @summary
|
||||
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
|
||||
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
|
||||
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
|
||||
* `render()` to specialize.
|
||||
*
|
||||
* @slot - List items (`<ha-list-item-*>`).
|
||||
*
|
||||
* @csspart base - The outer container (`<div role="list">`).
|
||||
*
|
||||
* @cssprop --ha-list-gap - Spacing between items. Defaults to `0`.
|
||||
* @cssprop --ha-list-padding - Padding around the list content. Defaults to `0`.
|
||||
*
|
||||
* @attr {boolean} wrap-focus - Whether ArrowUp/Down navigation wraps at the ends.
|
||||
* @attr {string} aria-label - Accessible label for the list.
|
||||
*
|
||||
* @fires ha-list-activated - Fired when an item is activated via Enter/Space. `detail: { index, item }`.
|
||||
*/
|
||||
@customElement("ha-list-base")
|
||||
export class HaListBase extends LitElement {
|
||||
@property({ type: Boolean, attribute: "wrap-focus" })
|
||||
public wrapFocus = false;
|
||||
|
||||
@property({ type: String, attribute: "aria-label", reflect: true })
|
||||
public ariaLabel: string | null = null;
|
||||
|
||||
public items: readonly HaListItemBase[] = [];
|
||||
|
||||
/** Host `role` attribute. Empty string means no role is set. */
|
||||
protected readonly hostRole: string = "list";
|
||||
|
||||
private _activeItemIndex = -1;
|
||||
|
||||
private _firstFocusableIndex = -1;
|
||||
|
||||
private _lastFocusableIndex = -1;
|
||||
|
||||
private _hasFocusableItem = false;
|
||||
|
||||
private _unbindKeys?: () => void;
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (!this.hasAttribute("ha-list")) {
|
||||
this.setAttribute("ha-list", "");
|
||||
}
|
||||
if (!this.hasAttribute("role") && this.hostRole) {
|
||||
this.setAttribute("role", this.hostRole);
|
||||
}
|
||||
this._unbindKeys = tinykeys(this, {
|
||||
ArrowDown: this._onForward,
|
||||
ArrowUp: this._onBack,
|
||||
Home: this._onHome,
|
||||
End: this._onEnd,
|
||||
Enter: this._onActivate,
|
||||
Space: this._onActivate,
|
||||
});
|
||||
this.addEventListener("focusin", this._onFocusIn);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unbindKeys?.();
|
||||
this._unbindKeys = undefined;
|
||||
this.removeEventListener("focusin", this._onFocusIn);
|
||||
}
|
||||
|
||||
public firstUpdated(changed: PropertyValues) {
|
||||
super.firstUpdated(changed);
|
||||
this.updateListItems();
|
||||
}
|
||||
|
||||
public focus(options?: FocusOptions) {
|
||||
if (!this.items.length) {
|
||||
super.focus(options);
|
||||
return;
|
||||
}
|
||||
this.focusItemAtIndex(
|
||||
this._activeItemIndex >= 0 ? this._activeItemIndex : 0
|
||||
);
|
||||
}
|
||||
|
||||
public focusItemAtIndex(index: number) {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
this.setActiveItemIndex(index, true);
|
||||
}
|
||||
|
||||
public getActiveItemIndex(): number {
|
||||
return this._activeItemIndex;
|
||||
}
|
||||
|
||||
public setActiveItemIndex(index: number, focusItem = false) {
|
||||
if (!this._hasFocusableItem) {
|
||||
this._activeItemIndex = -1;
|
||||
return;
|
||||
}
|
||||
this._activeItemIndex = Math.max(0, Math.min(this.items.length - 1, index));
|
||||
if (!this._isFocusable(this._activeItemIndex)) {
|
||||
this._activeItemIndex = this._firstFocusableIndex;
|
||||
}
|
||||
this._applyActive(focusItem);
|
||||
}
|
||||
|
||||
public updateListItems() {
|
||||
const next = this._discoverListItems();
|
||||
const changed =
|
||||
next.length !== this.items.length ||
|
||||
next.some((it, i) => it !== this.items[i]);
|
||||
if (!changed) {
|
||||
return;
|
||||
}
|
||||
this.items = next;
|
||||
this._recomputeFocusableIndexes();
|
||||
if (
|
||||
this._activeItemIndex >= next.length ||
|
||||
!this._hasFocusableItem ||
|
||||
this._activeItemIndex < 0
|
||||
) {
|
||||
this._activeItemIndex = this._firstFocusableIndex;
|
||||
}
|
||||
this._applyActive(false);
|
||||
}
|
||||
|
||||
private _recomputeFocusableIndexes() {
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (this._isFocusable(i)) {
|
||||
if (first === -1) {
|
||||
first = i;
|
||||
}
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
this._firstFocusableIndex = first;
|
||||
this._lastFocusableIndex = last;
|
||||
this._hasFocusableItem = first !== -1;
|
||||
}
|
||||
|
||||
public handleSlotChange = () => {
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<div part="base" class="base">
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private _discoverListItems(): HaListItemBase[] {
|
||||
const slot =
|
||||
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
|
||||
if (!slot) {
|
||||
return [];
|
||||
}
|
||||
return slot
|
||||
.assignedElements({ flatten: true })
|
||||
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
|
||||
}
|
||||
|
||||
private _isFocusable(index: number): boolean {
|
||||
const item = this.items[index];
|
||||
return !!item && item.interactive && !item.disabled;
|
||||
}
|
||||
|
||||
private _applyActive(focusItem: boolean) {
|
||||
this.items.forEach((item, i) => {
|
||||
if (!item.interactive || item.disabled) {
|
||||
item.removeAttribute("tabindex");
|
||||
return;
|
||||
}
|
||||
item.tabIndex = i === this._activeItemIndex ? 0 : -1;
|
||||
});
|
||||
if (focusItem && this._activeItemIndex >= 0) {
|
||||
this.items[this._activeItemIndex]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _onFocusIn = (ev: FocusEvent) => {
|
||||
const path = ev.composedPath();
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (path.includes(this.items[i])) {
|
||||
if (i !== this._activeItemIndex) {
|
||||
this._activeItemIndex = i;
|
||||
this._applyActive(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _onForward = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, 1));
|
||||
};
|
||||
|
||||
private _onBack = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, -1));
|
||||
};
|
||||
|
||||
private _onHome = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._firstFocusableIndex);
|
||||
};
|
||||
|
||||
private _onEnd = (ev: KeyboardEvent) => {
|
||||
this._moveFocus(ev, this._lastFocusableIndex);
|
||||
};
|
||||
|
||||
private _onActivate = (ev: KeyboardEvent) => {
|
||||
if (!this._isFocusable(this._activeItemIndex)) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
const active = this.items[this._activeItemIndex];
|
||||
active.activate();
|
||||
fireEvent(this, "ha-list-activated", {
|
||||
index: this._activeItemIndex,
|
||||
item: active,
|
||||
});
|
||||
};
|
||||
|
||||
private _moveFocus(ev: KeyboardEvent, next: number) {
|
||||
if (!this._hasFocusableItem || next < 0 || next === this._activeItemIndex) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
this._activeItemIndex = next;
|
||||
this._applyActive(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step from `from` by `delta`, skipping non-interactive and disabled items.
|
||||
* Returns `from` when no other focusable item can be reached (honouring
|
||||
* `wrapFocus`).
|
||||
*/
|
||||
private _stepIndex(from: number, delta: 1 | -1): number {
|
||||
const n = this.items.length;
|
||||
if (!n || !this._hasFocusableItem) {
|
||||
return from;
|
||||
}
|
||||
let i = from;
|
||||
for (let step = 0; step < n; step++) {
|
||||
i += delta;
|
||||
if (i < 0 || i >= n) {
|
||||
if (!this.wrapFocus) {
|
||||
return from;
|
||||
}
|
||||
i = (i + n) % n;
|
||||
}
|
||||
if (this._isFocusable(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return from;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
--ha-list-gap: 0;
|
||||
--ha-list-padding: 0;
|
||||
}
|
||||
.base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-list-gap);
|
||||
padding: var(--ha-list-padding);
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-base": HaListBase;
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { HaListBase } from "./ha-list-base";
|
||||
|
||||
/**
|
||||
* @element ha-list-nav
|
||||
* @extends {HaListBase}
|
||||
*
|
||||
* @summary
|
||||
* Navigation list. Wraps the list in a `<nav>` landmark. Items should be
|
||||
* `<ha-list-item-button>` with an `href`. Use `aria-label` to name the landmark.
|
||||
*
|
||||
* @csspart nav - The `<nav>` wrapper.
|
||||
* @csspart base - The inner `<div role="list">`.
|
||||
*/
|
||||
@customElement("ha-list-nav")
|
||||
export class HaListNav extends HaListBase {
|
||||
// No host role — the inner <nav> carries the landmark semantics, and the
|
||||
// inner <div role="list"> preserves the list semantics for screen readers.
|
||||
protected override readonly hostRole = "";
|
||||
|
||||
// The label is forwarded to the inner <nav>
|
||||
@property({ type: String, attribute: "aria-label", reflect: false })
|
||||
public override ariaLabel: string | null = null;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<nav
|
||||
part="nav"
|
||||
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
|
||||
>
|
||||
<div part="base" class="base" role="list">
|
||||
<slot @slotchange=${this.handleSlotChange}></slot>
|
||||
</div>
|
||||
</nav>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-nav": HaListNav;
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import { HaListBase } from "./ha-list-base";
|
||||
import type { HaListSelectedDetail } from "./types";
|
||||
|
||||
/**
|
||||
* @element ha-list-selectable
|
||||
* @extends {HaListBase}
|
||||
*
|
||||
* @summary
|
||||
* Selection list (role `listbox`). Items must be `<ha-list-item-option>`.
|
||||
* Toggle single vs multi selection via the `multi` attribute.
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once.
|
||||
*
|
||||
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
|
||||
*/
|
||||
@customElement("ha-list-selectable")
|
||||
export class HaListSelectable extends HaListBase {
|
||||
@property({ type: Boolean, reflect: true }) public multi = false;
|
||||
|
||||
protected override readonly hostRole = "listbox";
|
||||
|
||||
private _selectedIndices?: Set<number>;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("click", this._onOptionClick);
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("click", this._onOptionClick);
|
||||
}
|
||||
|
||||
public updated(changed: Map<string, unknown>) {
|
||||
super.updated(changed);
|
||||
if (changed.has("multi")) {
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
|
||||
const first = Math.min(...this._selectedIndices!);
|
||||
this._setSelection(new Set([first]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current selection. `number` (or `-1` if nothing) when single,
|
||||
* `Set<number>` when multi.
|
||||
*/
|
||||
public get selected(): number | Set<number> {
|
||||
if (this.multi) {
|
||||
return new Set(this._selectedIndices);
|
||||
}
|
||||
return (this._selectedIndices?.size ?? 0) === 0
|
||||
? -1
|
||||
: this._selectedIndices!.values().next().value!;
|
||||
}
|
||||
|
||||
public get selectedItems(): HaListItemOption[] {
|
||||
return this._sortedSelectedIndices()
|
||||
.map((i) => this.items[i] as HaListItemOption | undefined)
|
||||
.filter((it): it is HaListItemOption => !!it);
|
||||
}
|
||||
|
||||
/** Replace the entire selection. */
|
||||
public setSelected(indices: number | number[] | Set<number>): void {
|
||||
const next =
|
||||
typeof indices === "number"
|
||||
? indices < 0
|
||||
? new Set<number>()
|
||||
: new Set([indices])
|
||||
: new Set(indices);
|
||||
if (!this.multi && next.size > 1) {
|
||||
const first = Math.min(...next);
|
||||
this._setSelection(new Set([first]));
|
||||
return;
|
||||
}
|
||||
this._setSelection(next);
|
||||
}
|
||||
|
||||
public select(index: number): void {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
const next = new Set(this._selectedIndices);
|
||||
next.add(index);
|
||||
this._setSelection(next);
|
||||
} else {
|
||||
this._setSelection(new Set([index]));
|
||||
}
|
||||
}
|
||||
|
||||
public toggle(index: number, force?: boolean): void {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
const next = new Set(this._selectedIndices);
|
||||
const isSelected = next.has(index);
|
||||
const shouldSelect = force !== undefined ? force : !isSelected;
|
||||
if (shouldSelect) {
|
||||
next.add(index);
|
||||
} else {
|
||||
next.delete(index);
|
||||
}
|
||||
this._setSelection(next);
|
||||
} else {
|
||||
const isSelected = this._selectedIndices!.has(index);
|
||||
const shouldSelect = force !== undefined ? force : !isSelected;
|
||||
this._setSelection(shouldSelect ? new Set([index]) : new Set());
|
||||
}
|
||||
}
|
||||
|
||||
public clearSelection(): void {
|
||||
this._setSelection(new Set());
|
||||
}
|
||||
|
||||
public updateListItems() {
|
||||
super.updateListItems();
|
||||
this._syncItemSelectedState();
|
||||
}
|
||||
|
||||
private _sortedSelectedIndices(): number[] {
|
||||
return [...this._selectedIndices!].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private _syncItemSelectedState() {
|
||||
if (!this._selectedIndices) {
|
||||
this._selectedIndices = new Set<number>();
|
||||
this.items.forEach((item, i) => {
|
||||
const opt = item as HaListItemOption;
|
||||
if (opt.selected) {
|
||||
this._selectedIndices!.add(i);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.items.forEach((item, i) => {
|
||||
const opt = item as HaListItemOption;
|
||||
const shouldBe = this._selectedIndices!.has(i);
|
||||
if (opt.selected !== shouldBe) {
|
||||
opt.selected = shouldBe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _setSelection(next: Set<number>): void {
|
||||
const prev = this._selectedIndices!;
|
||||
const added = new Set<number>();
|
||||
const removed = new Set<number>();
|
||||
next.forEach((i) => {
|
||||
if (!prev.has(i)) {
|
||||
added.add(i);
|
||||
}
|
||||
});
|
||||
prev.forEach((i) => {
|
||||
if (!next.has(i)) {
|
||||
removed.add(i);
|
||||
}
|
||||
});
|
||||
if (!added.size && !removed.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectedIndices = next;
|
||||
this._syncItemSelectedState();
|
||||
|
||||
const detail: HaListSelectedDetail = this.multi
|
||||
? { index: new Set(next), diff: { added, removed } }
|
||||
: {
|
||||
index: next.size === 0 ? -1 : next.values().next().value!,
|
||||
diff: { added, removed },
|
||||
};
|
||||
fireEvent(this, "ha-list-selected", detail);
|
||||
}
|
||||
|
||||
private _onOptionClick = (ev: Event) => {
|
||||
const path = ev.composedPath();
|
||||
for (const el of path) {
|
||||
if (el === this) {
|
||||
return;
|
||||
}
|
||||
if (el instanceof HaListItemOption) {
|
||||
const index = this.items.indexOf(el);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const item = this.items[index];
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
this.toggle(index);
|
||||
} else {
|
||||
this.select(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-selectable": HaListSelectable;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { HaListItemBase } from "../item/ha-list-item-base";
|
||||
|
||||
export interface HaListSelectedDetail {
|
||||
index: number | Set<number>;
|
||||
diff?: { added: Set<number>; removed: Set<number> };
|
||||
value?: string | string[];
|
||||
}
|
||||
|
||||
export interface HaListActivatedDetail {
|
||||
index: number;
|
||||
item: HaListItemBase;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"ha-list-selected": HaListSelectedDetail;
|
||||
"ha-list-activated": HaListActivatedDetail;
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export class HaProgressBar extends ProgressBar {
|
||||
--ha-progress-bar-track-color,
|
||||
var(--ha-color-fill-neutral-normal-hover)
|
||||
);
|
||||
--track-height: var(--ha-progress-bar-track-height, 12px);
|
||||
--track-height: var(--ha-progress-bar-track-height, 16px);
|
||||
--wa-transition-slow: var(--ha-animation-duration-slow);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import RadioGroup from "@home-assistant/webawesome/dist/components/radio-group/radio-group";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant radio group component
|
||||
*
|
||||
* @element ha-radio-group
|
||||
* @extends {RadioGroup}
|
||||
*
|
||||
* @summary
|
||||
* A Home Assistant themed radio group built on top of the Web Awesome radio group.
|
||||
* Groups `ha-radio-option` children so they behave as a single form control.
|
||||
*
|
||||
* @slot - The default slot where `ha-radio-option` elements are placed.
|
||||
* @slot label - The radio group's label. Required for accessibility. Alternatively, use the `label` attribute.
|
||||
* @slot hint - Text that describes how to use the radio group. Alternatively, use the `hint` attribute.
|
||||
*
|
||||
* @csspart form-control - The form control that wraps the label, input, and hint.
|
||||
* @csspart form-control-label - The label's wrapper.
|
||||
* @csspart form-control-input - The input's wrapper.
|
||||
* @csspart radios - The wrapper around the radio items, styled as a flex container by default.
|
||||
* @csspart hint - The hint's wrapper.
|
||||
*
|
||||
* @cssprop --ha-radio-group-required-marker - Marker shown after the label for required fields. Defaults to `--ha-input-required-marker`, then `"*"`.
|
||||
* @cssprop --ha-radio-group-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
|
||||
*
|
||||
* @attr {string} label - The radio group's label.
|
||||
* @attr {string} hint - The radio group's hint text.
|
||||
* @attr {string} name - The name of the radio group, submitted as a name/value pair with form data.
|
||||
* @attr {("horizontal"|"vertical")} orientation - The orientation in which to show radio items.
|
||||
* @attr {boolean} disabled - Disables the radio group and all child radios.
|
||||
* @attr {boolean} required - Ensures a child radio is checked before allowing the containing form to submit.
|
||||
*
|
||||
* @fires change - Emitted when the radio group's selected value changes.
|
||||
* @fires input - Emitted when the radio group receives user input.
|
||||
* @fires wa-invalid - Emitted when the form control has been checked for validity and its constraints aren't satisfied.
|
||||
*/
|
||||
@customElement("ha-radio-group")
|
||||
export class HaRadioGroup extends RadioGroup {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.radioTag = "ha-radio-option";
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
RadioGroup.styles,
|
||||
css`
|
||||
:host {
|
||||
--wa-form-control-required-content: var(
|
||||
--ha-radio-group-required-marker,
|
||||
var(--ha-input-required-marker, "*")
|
||||
);
|
||||
--wa-form-control-required-content-offset: var(
|
||||
--ha-radio-group-required-marker-offset,
|
||||
0.1rem
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-radio-group": HaRadioGroup;
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import Radio from "@home-assistant/webawesome/dist/components/radio/radio";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant radio option component
|
||||
*
|
||||
* @element ha-radio-option
|
||||
* @extends {Radio}
|
||||
*
|
||||
* @summary
|
||||
* A Home Assistant themed radio built on top of the Web Awesome radio.
|
||||
* Intended to be used as a child of `ha-radio-group`.
|
||||
*
|
||||
* @slot - The radio option's label.
|
||||
*
|
||||
* @csspart control - The circular container that wraps the radio's checked state.
|
||||
* @csspart checked-icon - The checked icon.
|
||||
* @csspart label - The container that wraps the radio option's label.
|
||||
*
|
||||
* @cssprop --ha-radio-option-active-color - Accent color used for the checked indicator and border. Defaults to `--ha-color-fill-primary-loud-resting`.
|
||||
* @cssprop --ha-radio-option-height - Minimum height of the option in `button` appearance. Defaults to `40px`.
|
||||
* @cssprop --ha-radio-option-toggle-size - Size of the radio toggle circle in `default` appearance. Defaults to `20px`.
|
||||
* @cssprop --ha-radio-option-border-width - Border width of the radio control. Defaults to `--ha-border-width-md`.
|
||||
* @cssprop --ha-radio-option-border-color - Border color of the radio control. Defaults to `--ha-color-border-neutral-normal`.
|
||||
* @cssprop --ha-radio-option-border-color-hover - Border color of the radio control on hover. Defaults to `--ha-radio-option-border-color`, then `--ha-color-border-neutral-loud`.
|
||||
* @cssprop --ha-radio-option-background-color - Background color of the radio control. Defaults to `--wa-form-control-background-color`.
|
||||
* @cssprop --ha-radio-option-background-color-hover - Background color of the radio control on hover. Defaults to `--ha-color-fill-neutral-quiet-hover`.
|
||||
* @cssprop --ha-radio-option-checked-background-color - Background color of the radio control when checked. Defaults to `--ha-color-fill-primary-normal-resting`.
|
||||
* @cssprop --ha-radio-option-checked-icon-color - Color of the checked indicator dot. Defaults to `--ha-radio-option-active-color`.
|
||||
* @cssprop --ha-radio-option-checked-icon-scale - Size of the checked indicator relative to the toggle. Defaults to `0.7`.
|
||||
* @cssprop --ha-radio-option-control-margin - Margin around the radio toggle in `default` appearance. Defaults to `var(--ha-space-3) var(--ha-space-2) var(--ha-space-3) var(--ha-space-3)`.
|
||||
*
|
||||
* @attr {("default"|"button")} appearance - Sets the radio option's visual appearance.
|
||||
* @attr {("small"|"medium"|"large")} size - Sets the radio option's size. Overridden by the parent `ha-radio-group`.
|
||||
* @attr {boolean} checked - Draws the radio option in a checked state.
|
||||
* @attr {boolean} disabled - Disables the radio option.
|
||||
*/
|
||||
@customElement("ha-radio-option")
|
||||
export class HaRadioOption extends Radio {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
Radio.styles,
|
||||
css`
|
||||
:host {
|
||||
--wa-form-control-activated-color: var(
|
||||
--ha-radio-option-active-color,
|
||||
var(--ha-color-fill-primary-loud-resting)
|
||||
);
|
||||
--wa-form-control-height: var(--ha-radio-option-height, 40px);
|
||||
--wa-form-control-toggle-size: var(
|
||||
--ha-radio-option-toggle-size,
|
||||
20px
|
||||
);
|
||||
--wa-form-control-border-width: var(
|
||||
--ha-radio-option-border-width,
|
||||
var(--ha-border-width-md)
|
||||
);
|
||||
--wa-form-control-border-color: var(
|
||||
--ha-radio-option-border-color,
|
||||
var(--ha-color-border-neutral-normal)
|
||||
);
|
||||
--wa-form-control-background-color: var(
|
||||
--ha-radio-option-background-color,
|
||||
var(--wa-form-control-background-color)
|
||||
);
|
||||
--checked-icon-color: var(
|
||||
--ha-radio-option-checked-icon-color,
|
||||
var(--wa-form-control-activated-color)
|
||||
);
|
||||
--checked-icon-scale: var(--ha-radio-option-checked-icon-scale, 0.7);
|
||||
}
|
||||
|
||||
:host([appearance="default"]) .control {
|
||||
margin: var(
|
||||
--ha-radio-option-control-margin,
|
||||
var(--ha-space-3) var(--ha-space-2) var(--ha-space-3)
|
||||
var(--ha-space-3)
|
||||
);
|
||||
}
|
||||
|
||||
:host(:not([aria-checked="true"], [aria-disabled="true"]):hover)
|
||||
.control {
|
||||
border-color: var(
|
||||
--ha-radio-option-border-color-hover,
|
||||
var(
|
||||
--ha-radio-option-border-color,
|
||||
var(--ha-color-border-neutral-loud)
|
||||
)
|
||||
);
|
||||
background-color: var(
|
||||
--ha-radio-option-background-color-hover,
|
||||
var(--ha-color-fill-neutral-quiet-hover)
|
||||
);
|
||||
}
|
||||
|
||||
:host([aria-checked="true"]) .control {
|
||||
background-color: var(
|
||||
--ha-radio-option-checked-background-color,
|
||||
var(--ha-color-fill-primary-normal-resting)
|
||||
);
|
||||
}
|
||||
|
||||
[part~="label"] {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:host([disabled]) [part~="label"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-radio-option": HaRadioOption;
|
||||
}
|
||||
}
|
||||
@@ -322,7 +322,6 @@ export interface ShorthandNotCondition extends ShorthandBaseCondition {
|
||||
|
||||
export interface AutomationElementGroupCollection {
|
||||
titleKey?: LocalizeKeys;
|
||||
generic?: boolean;
|
||||
groups: AutomationElementGroup;
|
||||
}
|
||||
|
||||
|
||||
@@ -189,20 +189,6 @@ export const updateBackupConfig = (
|
||||
config: BackupMutableConfig
|
||||
) => hass.callWS({ type: "backup/config/update", ...config });
|
||||
|
||||
export const saveBackupConfig = (hass: HomeAssistant, config: BackupConfig) =>
|
||||
updateBackupConfig(hass, {
|
||||
create_backup: {
|
||||
agent_ids: config.create_backup.agent_ids,
|
||||
include_folders: config.create_backup.include_folders ?? [],
|
||||
include_database: config.create_backup.include_database,
|
||||
include_addons: config.create_backup.include_addons ?? [],
|
||||
include_all_addons: config.create_backup.include_all_addons,
|
||||
password: config.create_backup.password,
|
||||
},
|
||||
retention: config.retention,
|
||||
schedule: config.schedule,
|
||||
});
|
||||
|
||||
export const getBackupDownloadUrl = (
|
||||
id: string,
|
||||
agentId: string,
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeObjectId } from "../common/entity/compute_object_id";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { DEFAULT_SERVICE_ICON } from "./icons";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface ServiceInfo {
|
||||
label: string;
|
||||
icon?: string;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_SERVICE_INFO: ServiceInfo = {
|
||||
label: "",
|
||||
iconPath: DEFAULT_SERVICE_ICON,
|
||||
};
|
||||
|
||||
export const computeServiceLabel = (
|
||||
localize: LocalizeFunc,
|
||||
services: HomeAssistant["services"],
|
||||
service: string
|
||||
): string => {
|
||||
const domain = computeDomain(service);
|
||||
const serviceName = computeObjectId(service);
|
||||
const serviceDef = services[domain]?.[serviceName];
|
||||
return (
|
||||
localize(
|
||||
`component.${domain}.services.${serviceName}.name`,
|
||||
serviceDef?.description_placeholders
|
||||
) ||
|
||||
serviceDef?.name ||
|
||||
service
|
||||
);
|
||||
};
|
||||
@@ -8,33 +8,30 @@ import type { Selector, TargetSelector } from "./selector";
|
||||
export const CONDITION_COLLECTIONS: AutomationElementGroupCollection[] = [
|
||||
{
|
||||
groups: {
|
||||
device: {},
|
||||
dynamicGroups: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
time_location: {
|
||||
icon: mdiMapClock,
|
||||
members: { sun: {}, time: {}, zone: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.conditions.groups.helpers.label",
|
||||
groups: {
|
||||
helpers: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey: "ui.panel.config.automation.editor.conditions.groups.other.label",
|
||||
groups: {
|
||||
template: {},
|
||||
trigger: {},
|
||||
other: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.conditions.groups.generic.label",
|
||||
generic: true,
|
||||
groups: {
|
||||
device: {},
|
||||
entity: { icon: mdiShape, members: { state: {}, numeric_state: {} } },
|
||||
},
|
||||
},
|
||||
{
|
||||
titleKey:
|
||||
"ui.panel.config.automation.editor.conditions.groups.custom_integrations.label",
|
||||
groups: {
|
||||
customDynamicGroups: {},
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const CONDITION_BUILDING_BLOCKS_GROUP = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user