mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-09 02:43:05 +00:00
Compare commits
74 Commits
rc
...
e2e-playwr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d32f5b6a50 | ||
|
|
a21cf5d995 | ||
|
|
9aa6cd4154 | ||
|
|
2024ce0aef | ||
|
|
908a518f18 | ||
|
|
d169eb9c49 | ||
|
|
0e1aa400d7 | ||
|
|
00e57454ed | ||
|
|
0e6b342b3f | ||
|
|
7ad8c27aa3 | ||
|
|
f01c202bbd | ||
|
|
ac6439bb5b | ||
|
|
33d29e3abd | ||
|
|
ca4ff25073 | ||
|
|
a4b4e285d8 | ||
|
|
850b597e47 | ||
|
|
b2e07c3ba5 | ||
|
|
76c871b249 | ||
|
|
c15d514918 | ||
|
|
8a52fa5f7a | ||
|
|
22c89ceff9 | ||
|
|
764f99beb3 | ||
|
|
64b242e89c | ||
|
|
103861bf71 | ||
|
|
b0a885f504 | ||
|
|
d620919643 | ||
|
|
f190a4f75c | ||
|
|
9c0f4ef8eb | ||
|
|
f25692a6f3 | ||
|
|
8b0d193742 | ||
|
|
da8dedbdea | ||
|
|
405ea0d09d | ||
|
|
afce0703e3 | ||
|
|
be0abafdff | ||
|
|
4aa9b188a0 | ||
|
|
1312cdceda | ||
|
|
7dddcc0feb | ||
|
|
38a18e327c | ||
|
|
a288ad4ab6 | ||
|
|
89a85d6f04 | ||
|
|
6f1d644676 | ||
|
|
3edf8beb5a | ||
|
|
7b95baf36b | ||
|
|
b9c9008135 | ||
|
|
a8fb2e251e | ||
|
|
5c93e7adbc | ||
|
|
4745cb4103 | ||
|
|
0a27727b9f | ||
|
|
2644706d5a | ||
|
|
dd25b448cf | ||
|
|
884c110bcc | ||
|
|
c61ed9c56a | ||
|
|
b454a45ca3 | ||
|
|
3bc404bc01 | ||
|
|
f22fc0b68a | ||
|
|
c78cfb4012 | ||
|
|
09e993ffd6 | ||
|
|
f8f175426d | ||
|
|
89e3687f22 | ||
|
|
18a20576a9 | ||
|
|
8ee41e5d9b | ||
|
|
cac31ac55a | ||
|
|
8f002f2783 | ||
|
|
df754fcd0d | ||
|
|
bc4437b3b5 | ||
|
|
c99b43dcf3 | ||
|
|
8945b917b3 | ||
|
|
4d75ea5198 | ||
|
|
ba3a63f856 | ||
|
|
fd25d38be6 | ||
|
|
ac22374a00 | ||
|
|
de529cc26b | ||
|
|
126db3e8df | ||
|
|
ed6fd59968 |
296
.github/workflows/e2e.yaml
vendored
Normal file
296
.github/workflows/e2e.yaml
vendored
Normal file
@@ -0,0 +1,296 @@
|
||||
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,7 +54,14 @@ src/cast/dev_const.ts
|
||||
# test coverage
|
||||
test/coverage/
|
||||
|
||||
# Playwright e2e output
|
||||
test/e2e/reports/
|
||||
test/e2e/test-results/
|
||||
# E2E test app build output
|
||||
test/e2e/app/dist/
|
||||
|
||||
# AI tooling
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.serena
|
||||
|
||||
@@ -176,11 +176,14 @@ 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}/`)),
|
||||
},
|
||||
],
|
||||
@@ -317,4 +320,22 @@ module.exports.config = {
|
||||
isLandingPageBuild: true,
|
||||
};
|
||||
},
|
||||
|
||||
e2eTestApp({ isProdBuild, latestBuild, isStatsBuild }) {
|
||||
return {
|
||||
name: "e2e-test-app" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
main: path.resolve(paths.e2eTestApp_dir, "src/entrypoint.ts"),
|
||||
},
|
||||
outputPath: outputPath(paths.e2eTestApp_output_root, latestBuild),
|
||||
publicPath: publicPath(latestBuild),
|
||||
defineOverlay: {
|
||||
__VERSION__: JSON.stringify(`E2E-TEST-${env.version()}`),
|
||||
__DEMO__: true,
|
||||
},
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isStatsBuild,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
// @ts-check
|
||||
|
||||
import globals from "globals";
|
||||
import tseslint from "typescript-eslint";
|
||||
import rootConfig from "../eslint.config.mjs";
|
||||
|
||||
export default tseslint.config(...rootConfig, {
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
rules: {
|
||||
"no-console": "off",
|
||||
"import-x/no-extraneous-dependencies": "off",
|
||||
|
||||
11
build-scripts/get-built-in-node-module-shim.cjs
Normal file
11
build-scripts/get-built-in-node-module-shim.cjs
Normal file
@@ -0,0 +1,11 @@
|
||||
// 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,3 +45,10 @@ gulp.task(
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"clean-e2e-test-app",
|
||||
gulp.parallel("clean-translations", async () =>
|
||||
deleteSync([paths.e2eTestApp_output_root, paths.build_dir])
|
||||
)
|
||||
);
|
||||
|
||||
41
build-scripts/gulp/e2e-test-app.js
Normal file
41
build-scripts/gulp/e2e-test-app.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import gulp from "gulp";
|
||||
import "./clean.js";
|
||||
import "./entry-html.js";
|
||||
import "./gather-static.js";
|
||||
import "./gen-icons-json.js";
|
||||
import "./translations.js";
|
||||
import "./rspack.js";
|
||||
|
||||
gulp.task(
|
||||
"develop-e2e-test-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "development";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel(
|
||||
"gen-icons-json",
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
"build-translations",
|
||||
"build-locale-data"
|
||||
),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-dev-server-e2e-test-app"
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"build-e2e-test-app",
|
||||
gulp.series(
|
||||
async function setEnv() {
|
||||
process.env.NODE_ENV = "production";
|
||||
},
|
||||
"clean-e2e-test-app",
|
||||
"translations-enable-merge-backend",
|
||||
gulp.parallel("gen-icons-json", "build-translations", "build-locale-data"),
|
||||
"copy-static-e2e-test-app",
|
||||
"rspack-prod-e2e-test-app",
|
||||
"gen-pages-e2e-test-app-prod"
|
||||
)
|
||||
);
|
||||
@@ -266,3 +266,24 @@ gulp.task(
|
||||
paths.landingPage_output_es5
|
||||
)
|
||||
);
|
||||
|
||||
const E2E_TEST_APP_PAGE_ENTRIES = { "index.html": ["main"] };
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-dev",
|
||||
genPagesDevTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task(
|
||||
"gen-pages-e2e-test-app-prod",
|
||||
genPagesProdTask(
|
||||
E2E_TEST_APP_PAGE_ENTRIES,
|
||||
paths.e2eTestApp_dir,
|
||||
paths.e2eTestApp_output_root,
|
||||
paths.e2eTestApp_output_latest
|
||||
)
|
||||
);
|
||||
|
||||
@@ -201,3 +201,23 @@ gulp.task("copy-static-landing-page", async () => {
|
||||
copyFonts(paths.landingPage_output_static);
|
||||
copyTranslations(paths.landingPage_output_static);
|
||||
});
|
||||
|
||||
gulp.task("copy-static-e2e-test-app", async () => {
|
||||
// Copy app static files (icons, polyfills, etc.)
|
||||
fs.copySync(
|
||||
polyPath("public/static"),
|
||||
path.resolve(paths.e2eTestApp_output_root, "static")
|
||||
);
|
||||
// Copy e2e test app public files (manifest, sw stubs)
|
||||
const e2ePublic = path.resolve(paths.e2eTestApp_dir, "public");
|
||||
if (fs.existsSync(e2ePublic)) {
|
||||
fs.copySync(e2ePublic, paths.e2eTestApp_output_root);
|
||||
}
|
||||
|
||||
copyPolyfills(paths.e2eTestApp_output_static);
|
||||
copyMapPanel(paths.e2eTestApp_output_static);
|
||||
copyFonts(paths.e2eTestApp_output_static);
|
||||
copyTranslations(paths.e2eTestApp_output_static);
|
||||
copyLocaleData(paths.e2eTestApp_output_static);
|
||||
copyMdiIcons(paths.e2eTestApp_output_static);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import "./clean.js";
|
||||
import "./compress.js";
|
||||
import "./demo.js";
|
||||
import "./download-translations.js";
|
||||
import "./e2e-test-app.js";
|
||||
import "./entry-html.js";
|
||||
import "./fetch-nightly-translations.js";
|
||||
import "./gallery.js";
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
createDemoConfig,
|
||||
createGalleryConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
} from "../rspack.cjs";
|
||||
|
||||
const bothBuilds = (createConfigFunc, params) => [
|
||||
@@ -210,3 +211,22 @@ gulp.task("rspack-prod-landing-page", () =>
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
gulp.task("rspack-dev-server-e2e-test-app", () =>
|
||||
runDevServer({
|
||||
compiler: rspack(
|
||||
createE2eTestAppConfig({ isProdBuild: false, latestBuild: true })
|
||||
),
|
||||
contentBase: paths.e2eTestApp_output_root,
|
||||
port: 8095,
|
||||
})
|
||||
);
|
||||
|
||||
gulp.task("rspack-prod-e2e-test-app", () =>
|
||||
prodBuild(
|
||||
bothBuilds(createE2eTestAppConfig, {
|
||||
isProdBuild: true,
|
||||
isStatsBuild: env.isStatsBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -50,4 +50,15 @@ module.exports = {
|
||||
),
|
||||
|
||||
translations_src: path.resolve(__dirname, "../src/translations"),
|
||||
|
||||
e2eTestApp_dir: path.resolve(__dirname, "../test/e2e/app"),
|
||||
e2eTestApp_output_root: path.resolve(__dirname, "../test/e2e/app/dist"),
|
||||
e2eTestApp_output_static: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/static"
|
||||
),
|
||||
e2eTestApp_output_latest: path.resolve(
|
||||
__dirname,
|
||||
"../test/e2e/app/dist/frontend_latest"
|
||||
),
|
||||
};
|
||||
|
||||
@@ -173,6 +173,16 @@ 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({
|
||||
@@ -327,6 +337,11 @@ const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
const createE2eTestAppConfig = ({ isProdBuild, latestBuild, isStatsBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.e2eTestApp({ isProdBuild, latestBuild, isStatsBuild })
|
||||
);
|
||||
|
||||
module.exports = {
|
||||
createAppConfig,
|
||||
createDemoConfig,
|
||||
@@ -334,4 +349,5 @@ module.exports = {
|
||||
createGalleryConfig,
|
||||
createRspackConfig,
|
||||
createLandingPageConfig,
|
||||
createE2eTestAppConfig,
|
||||
};
|
||||
|
||||
21
demo/src/stubs/assist.ts
Normal file
21
demo/src/stubs/assist.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockAssist = (hass: MockHomeAssistant) => {
|
||||
// Stub for assist pipeline list — returns empty so developer tools assist
|
||||
// tab loads without errors.
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [],
|
||||
preferred_pipeline: null,
|
||||
}));
|
||||
|
||||
// Stub for assist pipeline run — immediately sends run-end event so
|
||||
// the UI does not hang waiting for a response.
|
||||
hass.mockWS("assist_pipeline/run", (_msg, _hass, onChange) => {
|
||||
if (onChange) {
|
||||
onChange({
|
||||
type: "run-end",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
});
|
||||
};
|
||||
21
demo/src/stubs/cloud.ts
Normal file
21
demo/src/stubs/cloud.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockCloud = (hass: MockHomeAssistant) => {
|
||||
// REST mock for cloud status — returns disconnected so config panel loads
|
||||
// without errors but without requiring cloud integration.
|
||||
hass.mockAPI("cloud/status", () => ({
|
||||
logged_in: false,
|
||||
cloud: "disconnected",
|
||||
prefs: {
|
||||
google_enabled: false,
|
||||
alexa_enabled: false,
|
||||
cloudhooks: {},
|
||||
remote_enabled: false,
|
||||
},
|
||||
google_registered: false,
|
||||
alexa_registered: false,
|
||||
remote_domain: null,
|
||||
remote_connected: false,
|
||||
remote_certificate: null,
|
||||
}));
|
||||
};
|
||||
5
demo/src/stubs/update.ts
Normal file
5
demo/src/stubs/update.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockUpdate = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("update/list", () => []);
|
||||
};
|
||||
@@ -228,6 +228,12 @@ export default tseslint.config(
|
||||
globals: globals.serviceworker,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["test/e2e/*.mjs"],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
html,
|
||||
|
||||
188
gallery/src/pages/components/ha-list.markdown
Normal file
188
gallery/src/pages/components/ha-list.markdown
Normal file
@@ -0,0 +1,188 @@
|
||||
---
|
||||
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"`).
|
||||
415
gallery/src/pages/components/ha-list.ts
Normal file
415
gallery/src/pages/components/ha-list.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
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,12 +43,22 @@ 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",
|
||||
@@ -60,6 +70,12 @@ 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")
|
||||
@@ -67,13 +83,14 @@ 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 } = select;
|
||||
const { id, label, options, maxColumns } = select;
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
@@ -81,6 +98,7 @@ export class DemoHaSelectBox extends LitElement {
|
||||
<ha-select-box
|
||||
.value=${this.value}
|
||||
.options=${options}
|
||||
.maxColumns=${maxColumns}
|
||||
@value-changed=${this.handleValueChanged}
|
||||
>
|
||||
</ha-select-box>
|
||||
|
||||
58
package.json
58
package.json
@@ -20,7 +20,20 @@
|
||||
"prepack": "pinst --disable",
|
||||
"postpack": "pinst --enable",
|
||||
"test": "vitest run --config test/vitest.config.ts",
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
|
||||
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
|
||||
"test:e2e": "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"
|
||||
},
|
||||
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
|
||||
"license": "Apache-2.0",
|
||||
@@ -33,27 +46,28 @@
|
||||
"@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.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",
|
||||
"@formatjs/intl-datetimeformat": "7.4.1",
|
||||
"@formatjs/intl-displaynames": "7.3.4",
|
||||
"@formatjs/intl-durationformat": "0.10.7",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.5",
|
||||
"@formatjs/intl-listformat": "8.3.4",
|
||||
"@formatjs/intl-locale": "5.3.4",
|
||||
"@formatjs/intl-numberformat": "9.3.4",
|
||||
"@formatjs/intl-pluralrules": "6.3.4",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.4",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
"@fullcalendar/list": "6.1.20",
|
||||
"@fullcalendar/luxon3": "6.1.20",
|
||||
"@fullcalendar/timegrid": "6.1.20",
|
||||
"@home-assistant/webawesome": "3.3.1-ha.1",
|
||||
"@home-assistant/webawesome": "3.3.1-ha.3",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -65,7 +79,6 @@
|
||||
"@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",
|
||||
@@ -99,7 +112,7 @@
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.2",
|
||||
"intl-messageformat": "11.2.3",
|
||||
"js-yaml": "4.1.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -107,7 +120,7 @@
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.2",
|
||||
"marked": "18.0.3",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -133,16 +146,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.2",
|
||||
"@babel/preset-env": "7.29.3",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.59.0",
|
||||
"@html-eslint/eslint-plugin": "0.60.0",
|
||||
"@lokalise/node-api": "15.7.1",
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "2.0.0",
|
||||
"@rspack/core": "2.0.1",
|
||||
"@rspack/dev-server": "2.0.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -166,7 +180,7 @@
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.4",
|
||||
"del": "8.0.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint": "10.3.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-import-resolver-webpack": "0.13.11",
|
||||
"eslint-plugin-import-x": "4.16.2",
|
||||
@@ -177,14 +191,14 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.5.0",
|
||||
"globals": "17.6.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.0.2",
|
||||
"jsdom": "29.1.1",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.4.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
@@ -200,7 +214,7 @@
|
||||
"terser-webpack-plugin": "5.5.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.59.0",
|
||||
"typescript-eslint": "8.59.1",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.5",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -213,7 +227,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"globals": "17.5.0",
|
||||
"globals": "17.6.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"
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 +268,7 @@ const computeStateToPartsFromEntityAttributes = (
|
||||
"datetime",
|
||||
].includes(domain) ||
|
||||
(domain === "sensor" &&
|
||||
(attributes.device_class === "timestamp" ||
|
||||
attributes.device_class === "uptime"))
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(attributes.device_class))
|
||||
) {
|
||||
try {
|
||||
return [
|
||||
|
||||
17
src/common/util/uuid.ts
Normal file
17
src/common/util/uuid.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
// 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,6 +10,17 @@ 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.
|
||||
*
|
||||
@@ -40,7 +51,8 @@ export const withViewTransition = (
|
||||
callbackInvoked = true;
|
||||
callback(true);
|
||||
});
|
||||
return transition.finished;
|
||||
transition.ready.catch(ignoreAbortError);
|
||||
return transition.finished.catch(ignoreAbortError);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
|
||||
@@ -11,7 +11,8 @@ import "../ha-icon-button";
|
||||
@customElement("ha-automation-row-event-chip")
|
||||
export class HaAutomationRowEventChip extends LitElement {
|
||||
@property({ reflect: true })
|
||||
public variant: "info" | "warning" | "success" | "danger" = "info";
|
||||
public variant: "info" | "warning" | "success" | "danger" | "neutral" =
|
||||
"info";
|
||||
|
||||
@property({ type: Boolean })
|
||||
public interactive = false;
|
||||
@@ -53,7 +54,7 @@ export class HaAutomationRowEventChip extends LitElement {
|
||||
return keyed(
|
||||
this._highlight,
|
||||
html`
|
||||
<wa-animation fill="both" .iterations=${1} name="tada" play
|
||||
<wa-animation fill="both" .iterations=${1} name="headShake" play
|
||||
>${base}</wa-animation
|
||||
>
|
||||
`
|
||||
@@ -91,6 +92,12 @@ export class HaAutomationRowEventChip extends LitElement {
|
||||
--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);
|
||||
|
||||
@@ -127,7 +127,7 @@ export class HaAutomationRow extends LitElement {
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
padding: 0 var(--ha-space-3);
|
||||
padding: 0 0 0 var(--ha-space-3);
|
||||
min-height: 48px;
|
||||
align-items: flex-start;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -1499,6 +1499,7 @@ export class HaChartBase extends LitElement {
|
||||
margin-inline-start: var(--ha-space-1);
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.chart-legend .legend-toggle {
|
||||
background: none;
|
||||
|
||||
@@ -60,6 +60,11 @@ 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;
|
||||
@@ -435,9 +440,11 @@ 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;
|
||||
|
||||
@@ -468,11 +475,11 @@ export class StateHistoryChartLine extends LitElement {
|
||||
const addDataSet = (
|
||||
id: string,
|
||||
nameY: string,
|
||||
color?: string,
|
||||
clr?: string,
|
||||
fill = false
|
||||
) => {
|
||||
if (!color) {
|
||||
color = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
if (!clr) {
|
||||
clr = getGraphColorByIndex(colorIndex, computedStyles);
|
||||
colorIndex++;
|
||||
}
|
||||
data.push({
|
||||
@@ -481,7 +488,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
type: "line",
|
||||
cursor: "default",
|
||||
name: nameY,
|
||||
color,
|
||||
color: clr,
|
||||
symbol: "circle",
|
||||
symbolSize: 1,
|
||||
step: "end",
|
||||
@@ -492,7 +499,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
areaStyle: fill
|
||||
? {
|
||||
color: color + "7F",
|
||||
color: clr + "7F",
|
||||
}
|
||||
: undefined,
|
||||
tooltip: {
|
||||
@@ -740,7 +747,7 @@ export class StateHistoryChartLine extends LitElement {
|
||||
pushData(new Date(entityState.last_changed), series);
|
||||
});
|
||||
} else {
|
||||
addDataSet(states.entity_id, name);
|
||||
addDataSet(states.entity_id, name, color);
|
||||
|
||||
let lastValue: number;
|
||||
let lastDate: Date;
|
||||
|
||||
@@ -52,6 +52,11 @@ 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;
|
||||
@@ -181,6 +186,7 @@ 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}
|
||||
@@ -399,12 +405,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 {
|
||||
|
||||
@@ -68,6 +68,11 @@ 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;
|
||||
@@ -485,6 +490,7 @@ 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];
|
||||
@@ -529,11 +535,14 @@ export class StatisticsChart extends LitElement {
|
||||
prevEndTime = end;
|
||||
};
|
||||
|
||||
const color = getGraphColorByIndex(
|
||||
colorIndex,
|
||||
this._computedStyle || getComputedStyle(this)
|
||||
);
|
||||
colorIndex++;
|
||||
let color = colors[statistic_id];
|
||||
if (color === undefined) {
|
||||
color = getGraphColorByIndex(
|
||||
colorIndex,
|
||||
this._computedStyle || getComputedStyle(this)
|
||||
);
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
const statTypes: this["statTypes"] = [];
|
||||
|
||||
|
||||
@@ -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-control-switch disabled></ha-control-switch> `;
|
||||
return html`<ha-switch disabled></ha-switch> `;
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -62,14 +62,14 @@ export class HaEntityToggle extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const switchTemplate = html`<ha-control-switch
|
||||
const switchTemplate = html`<ha-switch
|
||||
aria-label=${`Toggle ${computeStateName(this.stateObj)} ${
|
||||
this._isOn ? "off" : "on"
|
||||
}`}
|
||||
.checked=${this._isOn}
|
||||
.disabled=${this.stateObj.state === UNAVAILABLE}
|
||||
@change=${this._toggleChanged}
|
||||
></ha-control-switch>`;
|
||||
></ha-switch>`;
|
||||
|
||||
if (!this.label) {
|
||||
return switchTemplate;
|
||||
@@ -160,12 +160,14 @@ export class HaEntityToggle extends LitElement {
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
min-width: 38px;
|
||||
}
|
||||
ha-control-switch {
|
||||
--control-switch-thickness: 20px;
|
||||
--control-switch-off-color: var(--state-inactive-color);
|
||||
ha-switch {
|
||||
--ha-switch-width: 38px;
|
||||
--ha-switch-size: 20px;
|
||||
--ha-switch-thumb-size: 14px;
|
||||
}
|
||||
ha-icon-button {
|
||||
--ha-icon-button-size: 40px;
|
||||
|
||||
42
src/components/ha-code-editor-jinja-arg-hover.ts
Normal file
42
src/components/ha-code-editor-jinja-arg-hover.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
101
src/components/ha-code-editor-jinja-hover.ts
Normal file
101
src/components/ha-code-editor-jinja-hover.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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,9 +36,13 @@ 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 } from "../resources/jinja_ha_completions";
|
||||
import type {
|
||||
JinjaArgType,
|
||||
HassArgHoverContext,
|
||||
} 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";
|
||||
@@ -91,6 +95,8 @@ 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;
|
||||
|
||||
@@ -159,6 +165,40 @@ 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);
|
||||
@@ -216,17 +256,38 @@ 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)
|
||||
),
|
||||
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()]
|
||||
: []
|
||||
),
|
||||
});
|
||||
}
|
||||
if (changedProps.has("linewrap")) {
|
||||
transactions.push({
|
||||
effects: this._loadedCodeMirror!.linewrapCompartment!.reconfigure(
|
||||
@@ -308,6 +369,7 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
...this._loadedCodeMirror.searchKeymap,
|
||||
...this._loadedCodeMirror.historyKeymap,
|
||||
...this._loadedCodeMirror.tabKeyBindings,
|
||||
...this._loadedCodeMirror.lintKeymap,
|
||||
saveKeyBinding,
|
||||
]),
|
||||
this._loadedCodeMirror.search({ top: true }),
|
||||
@@ -322,10 +384,23 @@ 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)] : []),
|
||||
];
|
||||
|
||||
@@ -575,6 +650,48 @@ 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,7 +55,11 @@ export class HaConditionIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = conditionIcon(this.hass, this.condition).then((icn) => {
|
||||
const icon = conditionIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.condition
|
||||
).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ 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;
|
||||
@@ -219,21 +220,35 @@ 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);
|
||||
|
||||
@@ -13,14 +13,17 @@ 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 {
|
||||
@@ -75,27 +78,33 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
</div>
|
||||
${this._shouldRender
|
||||
? html`
|
||||
<ha-list class="ha-scrollbar">
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@ha-list-selected=${this._handleListChanged}
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.areas.caption"
|
||||
)}
|
||||
>
|
||||
${repeat(
|
||||
areas?.floors || [],
|
||||
(floor) => floor.floor_id,
|
||||
(floor) => html`
|
||||
<ha-check-list-item
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.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="graphic"
|
||||
slot="start"
|
||||
.floor=${floor}
|
||||
></ha-floor-icon>
|
||||
${floor.name}
|
||||
</ha-check-list-item>
|
||||
<span slot="headline">${floor.name} </span>
|
||||
</ha-list-item-option>
|
||||
${repeat(
|
||||
floor.areas,
|
||||
(area, index) =>
|
||||
@@ -110,7 +119,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
(area) => area.area_id,
|
||||
(area) => this._renderArea(area)
|
||||
)}
|
||||
</ha-list>
|
||||
</ha-list-selectable>
|
||||
`
|
||||
: nothing}
|
||||
</ha-expansion-panel>
|
||||
@@ -119,79 +128,83 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
private _renderArea(area, last = false) {
|
||||
const hasFloor = !!area.floor_id;
|
||||
|
||||
return html`
|
||||
<ha-check-list-item
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.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
|
||||
.end=${last}
|
||||
slot="graphic"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
? html`<ha-tree-indicator
|
||||
slot="start"
|
||||
.end=${last}
|
||||
></ha-tree-indicator>`
|
||||
: nothing}
|
||||
${area.icon
|
||||
? html`<ha-icon slot="graphic" .icon=${area.icon}></ha-icon>`
|
||||
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="graphic"
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
${area.name}
|
||||
</ha-check-list-item>
|
||||
<span slot="headline">${area.name}</span>
|
||||
</ha-list-item-option>
|
||||
`;
|
||||
}
|
||||
|
||||
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) {
|
||||
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
|
||||
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.value?.[type]?.includes(value)) {
|
||||
this.value = {
|
||||
...this.value,
|
||||
[type]: this.value[type].filter((val) => val !== value),
|
||||
};
|
||||
} else {
|
||||
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) {
|
||||
this.value = {};
|
||||
}
|
||||
this.value = {
|
||||
...this.value,
|
||||
[type]: [...(this.value[type] || []), 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
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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")!.style.height =
|
||||
this.renderRoot.querySelector("ha-list-selectable")!.style.height =
|
||||
`${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
@@ -317,11 +330,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-check-list-item {
|
||||
--mdc-list-item-graphic-margin: 16px;
|
||||
}
|
||||
.floor {
|
||||
padding-left: 48px;
|
||||
.floor::part(base) {
|
||||
padding-inline-start: 48px;
|
||||
padding-inline-end: 16px;
|
||||
}
|
||||
|
||||
@@ -37,10 +37,6 @@ 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,6 +469,8 @@ 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.climate.currently")}:
|
||||
${this.hass.localize("ui.card.humidifier.currently")}:
|
||||
<div class="unit">${currentStatus}</div>
|
||||
</div>`
|
||||
: ""}`;
|
||||
|
||||
@@ -53,7 +53,10 @@ export class HaIconButton extends LitElement {
|
||||
.download=${this.download}
|
||||
>
|
||||
${this.path
|
||||
? html`<ha-svg-icon .path=${this.path}></ha-svg-icon>`
|
||||
? html`<ha-svg-icon
|
||||
aria-hidden="true"
|
||||
.path=${this.path}
|
||||
></ha-svg-icon>`
|
||||
: html`<span><slot></slot></span>`}
|
||||
</ha-button>
|
||||
`;
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
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,13 +1,14 @@
|
||||
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 "./ha-radio";
|
||||
import type { HaRadio } from "./ha-radio";
|
||||
import "./radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "./radio/ha-radio-group";
|
||||
import "./radio/ha-radio-option";
|
||||
|
||||
interface SelectBoxOptionImage {
|
||||
src: string;
|
||||
@@ -44,9 +45,14 @@ export class HaSelectBox extends LitElement {
|
||||
const columns = Math.min(maxColumns, this.options.length);
|
||||
|
||||
return html`
|
||||
<div class="list" style=${styleMap({ "--columns": columns })}>
|
||||
<ha-radio-group
|
||||
class="list"
|
||||
style=${styleMap({ "--columns": columns })}
|
||||
.value=${this.value}
|
||||
@change=${this._radioChanged}
|
||||
>
|
||||
${this.options.map((option) => this._renderOption(option))}
|
||||
</div>
|
||||
</ha-radio-group>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -74,20 +80,24 @@ export class HaSelectBox extends LitElement {
|
||||
selected: selected,
|
||||
})}"
|
||||
?disabled=${disabled}
|
||||
@click=${this._labelClick}
|
||||
>
|
||||
<div class="content">
|
||||
<ha-radio
|
||||
.checked=${option.value === this.value}
|
||||
<ha-radio-option
|
||||
aria-describedby=${ifDefined(
|
||||
option.description ? `desc-${option.value}` : undefined
|
||||
)}
|
||||
aria-labelledby=${`label-${option.value}`}
|
||||
.value=${option.value}
|
||||
.disabled=${disabled}
|
||||
@change=${this._radioChanged}
|
||||
@click=${stopPropagation}
|
||||
></ha-radio>
|
||||
></ha-radio-option>
|
||||
<div class="text">
|
||||
<span class="label">${option.label}</span>
|
||||
<span id=${`label-${option.value}`} class="label"
|
||||
>${option.label}</span
|
||||
>
|
||||
${option.description
|
||||
? html`<span class="description">${option.description}</span>`
|
||||
? html`<span class="description" id="desc-${option.value}"
|
||||
>${option.description}</span
|
||||
>`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,14 +110,9 @@ 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 HaRadio;
|
||||
const radio = ev.currentTarget as HaRadioGroup;
|
||||
const value = radio.value;
|
||||
if (this.disabled || value === undefined || value === (this.value ?? "")) {
|
||||
return;
|
||||
@@ -118,7 +123,7 @@ export class HaSelectBox extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.list {
|
||||
.list::part(form-control-input) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--columns, 1), minmax(0, 1fr));
|
||||
gap: var(--ha-space-3);
|
||||
@@ -146,8 +151,9 @@ export class HaSelectBox extends LitElement {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.option .content ha-radio {
|
||||
margin: -12px;
|
||||
.option .content ha-radio-option {
|
||||
--ha-radio-option-control-margin: 0;
|
||||
margin: 0;
|
||||
flex: none;
|
||||
}
|
||||
.option .content .text {
|
||||
@@ -156,6 +162,7 @@ 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);
|
||||
|
||||
@@ -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 = String(value);
|
||||
const valueStr = value !== undefined ? String(value) : undefined;
|
||||
if (!options || !valueStr) {
|
||||
return valueStr;
|
||||
}
|
||||
|
||||
@@ -78,22 +78,28 @@ export class HaObjectSelector extends LitElement {
|
||||
};
|
||||
|
||||
private _renderItem(item: any, index: number) {
|
||||
const labelField =
|
||||
this.selector.object!.label_field ||
|
||||
Object.keys(this.selector.object!.fields!)[0];
|
||||
const fields = this.selector.object!.fields!;
|
||||
const preferredLabel = this.selector.object!.label_field;
|
||||
const hasValidLabelField = preferredLabel && preferredLabel in fields;
|
||||
|
||||
const labelSelector = this.selector.object!.fields![labelField].selector;
|
||||
|
||||
const label = labelSelector
|
||||
? formatSelectorValue(this.hass, item[labelField], labelSelector)
|
||||
: "";
|
||||
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(" · ");
|
||||
|
||||
let description = "";
|
||||
|
||||
const descriptionField = this.selector.object!.description_field;
|
||||
if (descriptionField) {
|
||||
const descriptionSelector =
|
||||
this.selector.object!.fields![descriptionField].selector;
|
||||
if (descriptionField && descriptionField in fields) {
|
||||
const descriptionSelector = fields[descriptionField]?.selector;
|
||||
|
||||
description = descriptionSelector
|
||||
? formatSelectorValue(
|
||||
|
||||
@@ -15,10 +15,11 @@ 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 {
|
||||
@@ -108,24 +109,23 @@ export class HaSelectSelector extends LitElement {
|
||||
) {
|
||||
if (!this.selector.select?.multiple) {
|
||||
return html`
|
||||
<div>
|
||||
${this.label}
|
||||
<ha-radio-group
|
||||
.label=${this.label}
|
||||
.disabled=${this.disabled}
|
||||
.value=${this.value}
|
||||
@change=${this._radioChanged}
|
||||
>
|
||||
${options.map(
|
||||
(item: SelectOption) => html`
|
||||
<ha-formfield
|
||||
.label=${item.label}
|
||||
.disabled=${item.disabled || this.disabled}
|
||||
<ha-radio-option
|
||||
.value=${item.value}
|
||||
.disabled=${!!item.disabled}
|
||||
>
|
||||
<ha-radio
|
||||
.checked=${item.value === this.value}
|
||||
.value=${item.value}
|
||||
.disabled=${item.disabled || this.disabled}
|
||||
@change=${this._radioChanged}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
${item.label}
|
||||
</ha-radio-option>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</ha-radio-group>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ export class HaServiceIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = serviceIcon(this.hass, this.service).then((icn) => {
|
||||
const icon = serviceIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
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);
|
||||
getServiceIcons(this.hass.connection, this.hass.config);
|
||||
}
|
||||
|
||||
private _rowRenderer: RenderItemFunction<ServiceComboBoxItem> = (
|
||||
|
||||
@@ -29,14 +29,17 @@ export class HaServiceSectionIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = serviceSectionIcon(this.hass, this.service, this.section).then(
|
||||
(icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
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>`;
|
||||
}
|
||||
);
|
||||
return this._renderFallback();
|
||||
});
|
||||
|
||||
return html`${until(icon)}`;
|
||||
}
|
||||
|
||||
@@ -36,14 +36,15 @@ 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-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 = {
|
||||
@@ -352,12 +353,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
|
||||
private _renderAllPanels(selectedPanel: string) {
|
||||
const renderList = (content, cls: string, scrollable: boolean) =>
|
||||
html`<ha-md-list
|
||||
html`<ha-list-nav
|
||||
class=${classMap({
|
||||
"ha-scrollbar": scrollable,
|
||||
[cls]: true,
|
||||
})}
|
||||
>${content}</ha-md-list
|
||||
>${content}</ha-list-nav
|
||||
>`;
|
||||
|
||||
if (!this._panelOrder || !this._hiddenPanels) {
|
||||
@@ -429,9 +430,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
const iconPath = getPanelIconPath(panel);
|
||||
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
<ha-list-item-button
|
||||
.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-md-list-item>
|
||||
</ha-list-item-button>
|
||||
${!this.alwaysExpand && title
|
||||
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
|
||||
: nothing}
|
||||
@@ -456,9 +456,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
}
|
||||
const isSelected = selectedPanel === "config";
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
<ha-list-item-button
|
||||
class="configuration ${classMap({ selected: isSelected })}"
|
||||
type="button"
|
||||
href="/config"
|
||||
id="sidebar-config"
|
||||
>
|
||||
@@ -480,7 +479,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
>
|
||||
`
|
||||
: nothing}
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-button>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-config",
|
||||
@@ -496,10 +495,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
: 0;
|
||||
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
<ha-list-item-button
|
||||
class="notifications"
|
||||
@click=${this._handleShowNotificationDrawer}
|
||||
type="button"
|
||||
id="sidebar-notifications"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
|
||||
@@ -514,7 +512,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
${notificationCount > 0
|
||||
? html`<span class="badge" slot="end">${notificationCount}</span>`
|
||||
: nothing}
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-button>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-notifications",
|
||||
@@ -529,9 +527,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
const isSelected = selectedPanel === "profile";
|
||||
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
<ha-list-item-button
|
||||
href="/profile"
|
||||
type="link"
|
||||
id="sidebar-profile"
|
||||
class=${classMap({
|
||||
user: true,
|
||||
@@ -547,7 +544,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.user ? this.hass.user.name : ""}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-button>
|
||||
${!this.alwaysExpand && this.hass.user
|
||||
? this._renderToolTip("sidebar-profile", this.hass.user.name)
|
||||
: nothing}
|
||||
@@ -559,16 +556,15 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
<ha-list-item-button
|
||||
@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-md-list-item>
|
||||
</ha-list-item-button>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-external-config",
|
||||
@@ -579,6 +575,10 @@ 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-md-list {
|
||||
ha-list-nav {
|
||||
overflow-x: hidden;
|
||||
background: none;
|
||||
margin-left: var(--safe-area-inset-left, 0px);
|
||||
margin-block: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@@ -726,42 +726,38 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
ha-md-list.before-spacer {
|
||||
ha-list-nav.before-spacer {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
ha-md-list.after-spacer {
|
||||
ha-list-nav.after-spacer {
|
||||
padding-top: 0;
|
||||
min-height: fit-content;
|
||||
}
|
||||
|
||||
ha-md-list-item {
|
||||
ha-list-item-button {
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
margin: var(--ha-space-1);
|
||||
margin: 0 var(--ha-space-1) var(--ha-space-1);
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
--md-list-item-one-line-container-height: var(--ha-space-10);
|
||||
--md-list-item-top-space: 0;
|
||||
--md-list-item-bottom-space: 0;
|
||||
--ha-row-item-min-height: var(--ha-space-10);
|
||||
--ha-row-item-padding-block: 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;
|
||||
}
|
||||
:host([expanded]) ha-md-list-item {
|
||||
ha-list-item-button::part(headline) {
|
||||
color: var(--sidebar-text-color);
|
||||
}
|
||||
:host([expanded]) ha-list-item-button {
|
||||
width: 248px;
|
||||
}
|
||||
:host([narrow][expanded]) ha-md-list-item {
|
||||
:host([narrow][expanded]) ha-list-item-button {
|
||||
width: calc(240px - var(--safe-area-inset-left, 0px));
|
||||
}
|
||||
|
||||
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::part(headline) {
|
||||
color: var(--sidebar-selected-icon-color);
|
||||
}
|
||||
ha-md-list-item.selected::before {
|
||||
ha-list-item-button.selected::before {
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -783,12 +779,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
color: var(--sidebar-icon-color);
|
||||
}
|
||||
|
||||
ha-md-list-item.selected ha-svg-icon[slot="start"],
|
||||
ha-md-list-item.selected ha-icon[slot="start"] {
|
||||
ha-list-item-button.selected ha-svg-icon[slot="start"],
|
||||
ha-list-item-button.selected ha-icon[slot="start"] {
|
||||
color: var(--sidebar-selected-icon-color);
|
||||
}
|
||||
|
||||
ha-md-list-item .item-text {
|
||||
ha-list-item-button .item-text {
|
||||
display: block;
|
||||
max-width: 0;
|
||||
opacity: 0;
|
||||
@@ -801,7 +797,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-md-list-item .item-text {
|
||||
:host([expanded]) ha-list-item-button .item-text {
|
||||
max-width: 100%;
|
||||
opacity: 1;
|
||||
transition-delay: 0ms, 80ms;
|
||||
@@ -843,13 +839,17 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
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-user-badge {
|
||||
width: var(--ha-space-10);
|
||||
height: var(--ha-space-10);
|
||||
}
|
||||
|
||||
ha-md-list-item.user.rtl {
|
||||
--md-list-item-leading-space: var(--ha-space-3);
|
||||
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-user-badge {
|
||||
@@ -869,8 +869,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.menu,
|
||||
ha-md-list-item,
|
||||
ha-md-list-item .item-text,
|
||||
ha-list-item-button,
|
||||
ha-list-item-button .item-text,
|
||||
.title {
|
||||
transition: 1ms;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ 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`.
|
||||
*
|
||||
@@ -89,8 +90,23 @@ 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(
|
||||
|
||||
@@ -69,7 +69,11 @@ export class HaTriggerIcon extends LitElement {
|
||||
return this._renderFallback();
|
||||
}
|
||||
|
||||
const icon = triggerIcon(this.hass, this.trigger).then((icn) => {
|
||||
const icon = triggerIcon(
|
||||
this.hass.connection,
|
||||
this.hass.config,
|
||||
this.trigger
|
||||
).then((icn) => {
|
||||
if (icn) {
|
||||
return html`<ha-icon .icon=${icn}></ha-icon>`;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ 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";
|
||||
@@ -58,15 +57,8 @@ 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 {
|
||||
@@ -126,16 +118,14 @@ 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}
|
||||
@blur=${this._onBlur}
|
||||
@editor-save=${this._onEditorSave}
|
||||
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">
|
||||
@@ -158,9 +148,13 @@ export class HaYamlEditor extends LitElement {
|
||||
private _onChange(ev: CustomEvent): void {
|
||||
ev.stopPropagation();
|
||||
this._yaml = ev.detail.value;
|
||||
let parsed;
|
||||
let parsed: unknown;
|
||||
let isValid = true;
|
||||
let errorMsg;
|
||||
let errorMsg: string | undefined;
|
||||
let yamlError: {
|
||||
mark?: { position: number; line: number; column: number };
|
||||
message?: string;
|
||||
} | null = null;
|
||||
|
||||
if (this._yaml) {
|
||||
try {
|
||||
@@ -168,15 +162,13 @@ 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._error = errorMsg ?? "";
|
||||
if (isValid) {
|
||||
this._showingError = false;
|
||||
}
|
||||
this._codeEditor?.setYamlError(yamlError);
|
||||
|
||||
this.value = parsed;
|
||||
this.isValid = isValid;
|
||||
@@ -188,16 +180,23 @@ 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,8 +1,6 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiMagnify } from "@mdi/js";
|
||||
import { css, html, type PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaInput } from "./ha-input";
|
||||
|
||||
/**
|
||||
@@ -17,10 +15,6 @@ 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;
|
||||
@@ -35,7 +29,7 @@ export class HaInputSearch extends HaInput {
|
||||
!this.placeholder &&
|
||||
(!this.hasUpdated || changedProps.has("_i18n"))
|
||||
) {
|
||||
this.placeholder = this._i18n.localize("ui.common.search");
|
||||
this.placeholder = this.i18n?.localize?.("ui.common.search") || "Search";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,19 +2,21 @@ 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 {
|
||||
LitElement,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } 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";
|
||||
@@ -125,6 +127,10 @@ 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",
|
||||
@@ -233,19 +239,22 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
${this.renderStartDefault()}
|
||||
</slot>
|
||||
<slot name="end" slot="end"> ${this.renderEndDefault()} </slot>
|
||||
<slot name="clear-icon" slot="clear-icon">
|
||||
<ha-icon-button .path=${mdiClose}></ha-icon-button>
|
||||
</slot>
|
||||
<slot name="show-password-icon" slot="show-password-icon">
|
||||
<slot name="clear-button" slot="clear-button">
|
||||
<ha-icon-button
|
||||
@keydown=${stopPropagation}
|
||||
.path=${mdiEye}
|
||||
@click=${this._handleClearClick}
|
||||
.label=${this.i18n?.localize?.("ui.components.input.clear") ||
|
||||
"Clear"}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
</slot>
|
||||
<slot name="hide-password-icon" slot="hide-password-icon">
|
||||
<slot name="password-toggle-button" slot="password-toggle-button">
|
||||
<ha-icon-button
|
||||
@keydown=${stopPropagation}
|
||||
.path=${mdiEyeOff}
|
||||
@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}
|
||||
></ha-icon-button>
|
||||
</slot>
|
||||
<div
|
||||
@@ -293,6 +302,14 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
}
|
||||
};
|
||||
|
||||
private _handleClearClick() {
|
||||
this._input?.clear();
|
||||
}
|
||||
|
||||
private _handlePasswordToggle() {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
waInputStyles,
|
||||
css`
|
||||
@@ -414,6 +431,12 @@ 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;
|
||||
|
||||
101
src/components/item/ha-list-item-base.ts
Normal file
101
src/components/item/ha-list-item-base.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
109
src/components/item/ha-list-item-button.ts
Normal file
109
src/components/item/ha-list-item-button.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
132
src/components/item/ha-list-item-option.ts
Normal file
132
src/components/item/ha-list-item-option.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
170
src/components/item/ha-row-item.ts
Normal file
170
src/components/item/ha-row-item.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
291
src/components/list/ha-list-base.ts
Normal file
291
src/components/list/ha-list-base.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
44
src/components/list/ha-list-nav.ts
Normal file
44
src/components/list/ha-list-nav.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
212
src/components/list/ha-list-selectable.ts
Normal file
212
src/components/list/ha-list-selectable.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
19
src/components/list/types.ts
Normal file
19
src/components/list/types.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
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, 16px);
|
||||
--track-height: var(--ha-progress-bar-track-height, 12px);
|
||||
--wa-transition-slow: var(--ha-animation-duration-slow);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
70
src/components/radio/ha-radio-group.ts
Normal file
70
src/components/radio/ha-radio-group.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
122
src/components/radio/ha-radio-option.ts
Normal file
122
src/components/radio/ha-radio-option.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,20 @@ 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,
|
||||
|
||||
34
src/data/compute-service-info.ts
Normal file
34
src/data/compute-service-info.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
interface ValidConfig {
|
||||
export interface ValidConfig {
|
||||
valid: true;
|
||||
error: null;
|
||||
}
|
||||
|
||||
interface InvalidConfig {
|
||||
export interface InvalidConfig {
|
||||
valid: false;
|
||||
error: string;
|
||||
}
|
||||
|
||||
@@ -323,7 +323,8 @@ export const getComponentIcons = async (
|
||||
export const getCategoryIcons = async <
|
||||
T extends Exclude<IconCategory, "entity" | "entity_component">,
|
||||
>(
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
category: T,
|
||||
domain?: string,
|
||||
force = false
|
||||
@@ -334,12 +335,10 @@ export const getCategoryIcons = async <
|
||||
Record<string, CategoryType[T]>
|
||||
>;
|
||||
}
|
||||
resources[category].all = getHassIcons(hass.connection, category).then(
|
||||
(res) => {
|
||||
resources[category].domains = res.resources as any;
|
||||
return res?.resources as Record<string, CategoryType[T]>;
|
||||
}
|
||||
) as any;
|
||||
resources[category].all = getHassIcons(connection, category).then((res) => {
|
||||
resources[category].domains = res.resources as any;
|
||||
return res?.resources as Record<string, CategoryType[T]>;
|
||||
}) as any;
|
||||
return resources[category].all as Promise<Record<string, CategoryType[T]>>;
|
||||
}
|
||||
if (!force && domain in resources[category].domains) {
|
||||
@@ -351,10 +350,10 @@ export const getCategoryIcons = async <
|
||||
return resources[category].domains[domain] as Promise<CategoryType[T]>;
|
||||
}
|
||||
}
|
||||
if (!isComponentLoaded(hass.config, domain)) {
|
||||
if (!isComponentLoaded(hassConfig, domain)) {
|
||||
return undefined;
|
||||
}
|
||||
const result = getHassIcons(hass.connection, category, domain);
|
||||
const result = getHassIcons(connection, category, domain);
|
||||
resources[category].domains[domain] = result.then(
|
||||
(res) => res?.resources[domain]
|
||||
) as any;
|
||||
@@ -362,25 +361,28 @@ export const getCategoryIcons = async <
|
||||
};
|
||||
|
||||
export const getServiceIcons = async (
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<ServiceIcons | Record<string, ServiceIcons> | undefined> =>
|
||||
getCategoryIcons(hass, "services", domain, force);
|
||||
getCategoryIcons(connection, hassConfig, "services", domain, force);
|
||||
|
||||
export const getTriggerIcons = async (
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<TriggerIcons | Record<string, TriggerIcons> | undefined> =>
|
||||
getCategoryIcons(hass, "triggers", domain, force);
|
||||
getCategoryIcons(connection, hassConfig, "triggers", domain, force);
|
||||
|
||||
export const getConditionIcons = async (
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
domain?: string,
|
||||
force = false
|
||||
): Promise<ConditionIcons | Record<string, ConditionIcons> | undefined> =>
|
||||
getCategoryIcons(hass, "conditions", domain, force);
|
||||
getCategoryIcons(connection, hassConfig, "conditions", domain, force);
|
||||
|
||||
// Cache for sorted range keys
|
||||
const sortedRangeCache = new WeakMap<Record<string, string>, number[]>();
|
||||
@@ -578,7 +580,8 @@ export const attributeIcon = async (
|
||||
};
|
||||
|
||||
export const triggerIcon = async (
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
trigger: string
|
||||
): Promise<string | undefined> => {
|
||||
let icon: string | undefined;
|
||||
@@ -586,62 +589,69 @@ export const triggerIcon = async (
|
||||
const domain = getTriggerDomain(trigger);
|
||||
const triggerName = getTriggerObjectId(trigger);
|
||||
|
||||
const triggerIcons = await getTriggerIcons(hass, domain);
|
||||
const triggerIcons = await getTriggerIcons(connection, hassConfig, domain);
|
||||
if (triggerIcons) {
|
||||
const trgrIcon = triggerIcons[triggerName] as TriggerIcons[string];
|
||||
icon = trgrIcon?.trigger;
|
||||
}
|
||||
if (!icon) {
|
||||
icon = await domainIcon(hass.connection, hass.config, domain);
|
||||
icon = await domainIcon(connection, hassConfig, domain);
|
||||
}
|
||||
return icon;
|
||||
};
|
||||
|
||||
export const conditionIcon = async (
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
condition: string
|
||||
): Promise<string | undefined> => {
|
||||
let icon: string | undefined;
|
||||
|
||||
const domain = getConditionDomain(condition);
|
||||
const conditionIcons = await getConditionIcons(hass, domain);
|
||||
const conditionIcons = await getConditionIcons(
|
||||
connection,
|
||||
hassConfig,
|
||||
domain
|
||||
);
|
||||
if (conditionIcons) {
|
||||
const conditionName = getConditionObjectId(condition);
|
||||
const condIcon = conditionIcons[conditionName] as ConditionIcons[string];
|
||||
icon = condIcon?.condition;
|
||||
}
|
||||
if (!icon) {
|
||||
icon = await domainIcon(hass.connection, hass.config, domain);
|
||||
icon = await domainIcon(connection, hassConfig, domain);
|
||||
}
|
||||
return icon;
|
||||
};
|
||||
|
||||
export const serviceIcon = async (
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
service: string
|
||||
): Promise<string | undefined> => {
|
||||
let icon: string | undefined;
|
||||
const domain = computeDomain(service);
|
||||
const serviceName = computeObjectId(service);
|
||||
const serviceIcons = await getServiceIcons(hass, domain);
|
||||
const serviceIcons = await getServiceIcons(connection, hassConfig, domain);
|
||||
if (serviceIcons) {
|
||||
const srvceIcon = serviceIcons[serviceName] as ServiceIcons[string];
|
||||
icon = srvceIcon?.service;
|
||||
}
|
||||
if (!icon) {
|
||||
icon = await domainIcon(hass.connection, hass.config, domain);
|
||||
icon = await domainIcon(connection, hassConfig, domain);
|
||||
}
|
||||
return icon;
|
||||
};
|
||||
|
||||
export const serviceSectionIcon = async (
|
||||
hass: HomeAssistant,
|
||||
connection: Connection,
|
||||
hassConfig: HomeAssistant["config"],
|
||||
service: string,
|
||||
section: string
|
||||
): Promise<string | undefined> => {
|
||||
const domain = computeDomain(service);
|
||||
const serviceName = computeObjectId(service);
|
||||
const serviceIcons = await getServiceIcons(hass, domain);
|
||||
const serviceIcons = await getServiceIcons(connection, hassConfig, domain);
|
||||
if (serviceIcons) {
|
||||
const srvceIcon = serviceIcons[serviceName] as ServiceIcons[string];
|
||||
return srvceIcon?.sections?.[section];
|
||||
|
||||
@@ -98,5 +98,30 @@ export const formatSelectorValue = (
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
return ensureArray(value).join(", ");
|
||||
if ("object" in selector) {
|
||||
const { fields } = selector.object ?? {};
|
||||
const items = ensureArray(value);
|
||||
return items
|
||||
.map((item) => {
|
||||
if (item == null || typeof item !== "object") {
|
||||
return String(item);
|
||||
}
|
||||
if (fields) {
|
||||
return Object.entries(fields)
|
||||
.filter(([key]) => key in item && item[key] != null)
|
||||
.map(([key, field]) =>
|
||||
formatSelectorValue(hass, item[key], field.selector)
|
||||
)
|
||||
.join(" = ");
|
||||
}
|
||||
return JSON.stringify(item);
|
||||
})
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
return ensureArray(value)
|
||||
.map((v) =>
|
||||
v != null && typeof v === "object" ? JSON.stringify(v) : String(v)
|
||||
)
|
||||
.join(", ");
|
||||
};
|
||||
|
||||
@@ -4,6 +4,12 @@ export const SENSOR_DEVICE_CLASS_BATTERY = "battery";
|
||||
export const SENSOR_DEVICE_CLASS_TIMESTAMP = "timestamp";
|
||||
export const SENSOR_DEVICE_CLASS_TEMPERATURE = "temperature";
|
||||
export const SENSOR_DEVICE_CLASS_HUMIDITY = "humidity";
|
||||
export const SENSOR_DEVICE_CLASS_UPTIME = "uptime";
|
||||
|
||||
export const SENSOR_TIMESTAMP_DEVICE_CLASSES: (string | undefined)[] = [
|
||||
"timestamp",
|
||||
"uptime",
|
||||
];
|
||||
|
||||
export interface SensorDeviceClassUnits {
|
||||
units: string[];
|
||||
|
||||
157
src/data/service-info-controller.ts
Normal file
157
src/data/service-info-controller.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { ContextConsumer, type Context } from "@lit/context";
|
||||
import type { Connection, HassConfig } from "home-assistant-js-websocket";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import {
|
||||
computeServiceLabel,
|
||||
DEFAULT_SERVICE_INFO,
|
||||
type ServiceInfo,
|
||||
} from "./compute-service-info";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
internationalizationContext,
|
||||
servicesContext,
|
||||
} from "./context";
|
||||
import {
|
||||
DEFAULT_SERVICE_ICON,
|
||||
FALLBACK_DOMAIN_ICONS,
|
||||
serviceIcon,
|
||||
} from "./icons";
|
||||
import type {
|
||||
HomeAssistant,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../types";
|
||||
|
||||
type ServiceInfoHost = ReactiveControllerHost & HTMLElement;
|
||||
|
||||
/**
|
||||
* Reactive controller that prepares display data for a service action
|
||||
* (e.g. `light.turn_on`): loads service translations, resolves the localized
|
||||
* service name, and resolves the service icon (with a synchronous domain
|
||||
* fallback that upgrades once the full icon is loaded).
|
||||
*
|
||||
* Pulls connection, config, services, and i18n from Lit context, so the
|
||||
* caller only needs to feed in the service ID via `updateService()`.
|
||||
*/
|
||||
export class ServiceInfoController implements ReactiveController {
|
||||
private _host: ServiceInfoHost;
|
||||
|
||||
private _connection?: Connection;
|
||||
|
||||
private _config?: HassConfig;
|
||||
|
||||
private _services?: HomeAssistant["services"];
|
||||
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
|
||||
private _service?: string;
|
||||
|
||||
private _resolvedService?: string;
|
||||
|
||||
private _resolvedLanguage?: string;
|
||||
|
||||
private _info: ServiceInfo = DEFAULT_SERVICE_INFO;
|
||||
|
||||
constructor(host: ServiceInfoHost) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
|
||||
this._consume(connectionContext, (value) => {
|
||||
this._connection = value.connection;
|
||||
});
|
||||
this._consume(configContext, (value) => {
|
||||
this._config = value.config;
|
||||
});
|
||||
this._consume(servicesContext, (value) => {
|
||||
this._services = value;
|
||||
});
|
||||
this._consume(internationalizationContext, (value) => {
|
||||
this._i18n = value;
|
||||
});
|
||||
}
|
||||
|
||||
private _consume<T>(
|
||||
context: Context<unknown, T>,
|
||||
assign: (value: T) => void
|
||||
): void {
|
||||
new ContextConsumer(this._host, {
|
||||
context,
|
||||
subscribe: true,
|
||||
callback: (value) => {
|
||||
assign(value);
|
||||
this._resolve();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
get info(): ServiceInfo {
|
||||
return this._info;
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
this._resolve();
|
||||
}
|
||||
|
||||
updateService(service: string | undefined): void {
|
||||
if (service === this._service) return;
|
||||
this._service = service;
|
||||
this._resolve();
|
||||
}
|
||||
|
||||
private _resolve(): void {
|
||||
if (!this._connection || !this._config || !this._services || !this._i18n) {
|
||||
return;
|
||||
}
|
||||
|
||||
const service = this._service;
|
||||
const language = this._i18n.language;
|
||||
|
||||
const serviceChanged = service !== this._resolvedService;
|
||||
const languageChanged = language !== this._resolvedLanguage;
|
||||
|
||||
if (!serviceChanged && !languageChanged) return;
|
||||
|
||||
this._resolvedService = service;
|
||||
this._resolvedLanguage = language;
|
||||
|
||||
if (!service) {
|
||||
this._info = DEFAULT_SERVICE_INFO;
|
||||
this._host.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
const domain = computeDomain(service);
|
||||
this._info = {
|
||||
label: computeServiceLabel(this._i18n.localize, this._services, service),
|
||||
iconPath: serviceChanged
|
||||
? FALLBACK_DOMAIN_ICONS[domain] || DEFAULT_SERVICE_ICON
|
||||
: this._info.iconPath,
|
||||
icon: serviceChanged ? undefined : this._info.icon,
|
||||
};
|
||||
this._host.requestUpdate();
|
||||
|
||||
this._i18n.loadBackendTranslation("services", domain).then((localize) => {
|
||||
if (
|
||||
this._resolvedService !== service ||
|
||||
this._resolvedLanguage !== language ||
|
||||
!this._services
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._info = {
|
||||
...this._info,
|
||||
label: computeServiceLabel(localize, this._services, service),
|
||||
};
|
||||
this._host.requestUpdate();
|
||||
});
|
||||
|
||||
if (serviceChanged) {
|
||||
serviceIcon(this._connection, this._config, service).then((icon) => {
|
||||
if (this._resolvedService !== service) return;
|
||||
this._info = { ...this._info, icon };
|
||||
this._host.requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,12 @@ import { isTiltOnly } from "../../../data/cover";
|
||||
import { isUnavailableState } from "../../../data/entity/entity";
|
||||
import type { ImageEntity } from "../../../data/image";
|
||||
import { computeImageUrl } from "../../../data/image";
|
||||
import { SENSOR_DEVICE_CLASS_TIMESTAMP } from "../../../data/sensor";
|
||||
import "../../../panels/lovelace/components/hui-timestamp-display";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import {
|
||||
SENSOR_DEVICE_CLASS_UPTIME,
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES,
|
||||
} from "../../../data/sensor";
|
||||
|
||||
@customElement("entity-preview-row")
|
||||
class EntityPreviewRow extends LitElement {
|
||||
@@ -312,14 +315,19 @@ class EntityPreviewRow extends LitElement {
|
||||
|
||||
if (domain === "sensor") {
|
||||
const showSensor =
|
||||
stateObj.attributes.device_class === SENSOR_DEVICE_CLASS_TIMESTAMP &&
|
||||
!isUnavailableState(stateObj.state);
|
||||
SENSOR_TIMESTAMP_DEVICE_CLASSES.includes(
|
||||
stateObj.attributes.device_class
|
||||
) && !isUnavailableState(stateObj.state);
|
||||
return html`
|
||||
${showSensor
|
||||
? html`
|
||||
<hui-timestamp-display
|
||||
.hass=${this.hass}
|
||||
.ts=${new Date(stateObj.state)}
|
||||
.format=${stateObj.attributes.device_class ===
|
||||
SENSOR_DEVICE_CLASS_UPTIME
|
||||
? "total"
|
||||
: undefined}
|
||||
capitalize
|
||||
></hui-timestamp-display>
|
||||
`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import deepClone from "deep-clone-simple";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../../components/ha-button";
|
||||
import "../../components/ha-form/ha-form";
|
||||
@@ -7,9 +9,15 @@ import "../../components/ha-dialog-footer";
|
||||
import "../../components/ha-dialog";
|
||||
import { haStyleDialog } from "../../resources/styles";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { HassDialog } from "../make-dialog-manager";
|
||||
import type { HassDialog, ShowDialogParams } from "../make-dialog-manager";
|
||||
import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
|
||||
|
||||
interface StackEntry {
|
||||
params: FormDialogParams;
|
||||
data: FormDialogData;
|
||||
nestedField?: string;
|
||||
}
|
||||
|
||||
@customElement("dialog-form")
|
||||
export class DialogForm
|
||||
extends LitElement
|
||||
@@ -25,6 +33,8 @@ export class DialogForm
|
||||
|
||||
@state() private _closeState?: "canceled" | "submitted";
|
||||
|
||||
@state() private _stack: StackEntry[] = [];
|
||||
|
||||
public async showDialog(params: FormDialogParams): Promise<void> {
|
||||
this._params = params;
|
||||
this._data = params.data || {};
|
||||
@@ -36,11 +46,41 @@ export class DialogForm
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleNestedShowDialog = (
|
||||
ev: HASSDomEvent<ShowDialogParams<unknown>>
|
||||
) => {
|
||||
if (ev.detail.dialogTag !== "dialog-form") {
|
||||
return;
|
||||
}
|
||||
ev.stopPropagation();
|
||||
|
||||
const origin = ev.composedPath()[0] as HTMLElement & { name?: string };
|
||||
this._stack = [
|
||||
...this._stack,
|
||||
{ params: this._params!, data: this._data, nestedField: origin?.name },
|
||||
];
|
||||
const nested = ev.detail.dialogParams as FormDialogParams;
|
||||
this._params = nested;
|
||||
this._data = nested?.data || {};
|
||||
};
|
||||
|
||||
private _popStack(): string | undefined {
|
||||
if (!this._stack.length) {
|
||||
return undefined;
|
||||
}
|
||||
const prev = this._stack[this._stack.length - 1];
|
||||
this._stack = this._stack.slice(0, -1);
|
||||
this._params = prev.params;
|
||||
this._data = prev.data;
|
||||
return prev.nestedField;
|
||||
}
|
||||
|
||||
private _dialogClosed(): void {
|
||||
if (!this._closeState) {
|
||||
this._params?.cancel?.();
|
||||
}
|
||||
this._closeState = undefined;
|
||||
this._stack = [];
|
||||
this._params = undefined;
|
||||
this._data = {};
|
||||
this._open = false;
|
||||
@@ -49,14 +89,44 @@ export class DialogForm
|
||||
|
||||
private _submit(): void {
|
||||
this._closeState = "submitted";
|
||||
this._params?.submit?.(this._data);
|
||||
this.closeDialog();
|
||||
const submit = this._params?.submit;
|
||||
const data = this._data;
|
||||
const nestedField = this._popStack();
|
||||
|
||||
submit?.(data);
|
||||
|
||||
if (!nestedField) {
|
||||
this.closeDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
const schemaField = this._params?.schema.find(
|
||||
(f) => "selector" in f && f.name === nestedField
|
||||
);
|
||||
const isMultiple =
|
||||
schemaField &&
|
||||
"selector" in schemaField &&
|
||||
"object" in schemaField.selector &&
|
||||
schemaField.selector.object?.multiple === true;
|
||||
|
||||
const current = this._data[nestedField];
|
||||
const newValue = isMultiple
|
||||
? [...(Array.isArray(current) ? current : []), data]
|
||||
: data;
|
||||
|
||||
this._data = deepClone({ ...this._data, [nestedField]: newValue });
|
||||
}
|
||||
|
||||
private _cancel(): void {
|
||||
this._closeState = "canceled";
|
||||
this._params?.cancel?.();
|
||||
this.closeDialog();
|
||||
const cancel = this._params?.cancel;
|
||||
const nestedField = this._popStack();
|
||||
|
||||
cancel?.();
|
||||
|
||||
if (!nestedField) {
|
||||
this.closeDialog();
|
||||
}
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent): void {
|
||||
@@ -84,6 +154,7 @@ export class DialogForm
|
||||
.data=${this._data}
|
||||
.schema=${this._params.schema}
|
||||
@value-changed=${this._valueChanged}
|
||||
@show-dialog=${this._handleNestedShowDialog}
|
||||
>
|
||||
</ha-form>
|
||||
<ha-dialog-footer slot="footer">
|
||||
|
||||
@@ -2,10 +2,17 @@ import { mdiCogOutline, mdiTextureBox } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import {
|
||||
getAreasFloorHierarchy,
|
||||
type AreasFloorHierarchy,
|
||||
} from "../../../../common/areas/areas-floor-hierarchy";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../../../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../../../../common/entity/compute_floor_name";
|
||||
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-floor-icon";
|
||||
import "../../../../components/ha-icon";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
@@ -17,13 +24,18 @@ import {
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showVacuumSegmentMappingView } from "./show-view-vacuum-segment-mapping";
|
||||
|
||||
interface MappedSection {
|
||||
floorId: string | null;
|
||||
areaIds: string[];
|
||||
}
|
||||
|
||||
@customElement("ha-more-info-view-vacuum-clean-areas")
|
||||
export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public params!: { entityId: string };
|
||||
|
||||
@state() private _mappedAreaIds?: string[];
|
||||
@state() private _mappedHierarchy?: AreasFloorHierarchy;
|
||||
|
||||
@state() private _selectedAreaIds: string[] = [];
|
||||
|
||||
@@ -47,8 +59,13 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
await getExtendedEntityRegistryEntry(this.hass, this.params.entityId);
|
||||
|
||||
const areaMapping = entry?.options?.vacuum?.area_mapping || {};
|
||||
this._mappedAreaIds = Object.keys(areaMapping).filter(
|
||||
(areaId) => this.hass.areas[areaId]
|
||||
const mappedAreaIds = new Set(Object.keys(areaMapping));
|
||||
const mappedAreas = Object.values(this.hass.areas).filter((area) =>
|
||||
mappedAreaIds.has(area.area_id)
|
||||
);
|
||||
this._mappedHierarchy = getAreasFloorHierarchy(
|
||||
Object.values(this.hass.floors),
|
||||
mappedAreas
|
||||
);
|
||||
} catch (err: any) {
|
||||
this._error = err.message || "Failed to load areas";
|
||||
@@ -95,6 +112,42 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
private _getMappedSections(): MappedSection[] {
|
||||
if (!this._mappedHierarchy) return [];
|
||||
const sections: MappedSection[] = this._mappedHierarchy.floors
|
||||
.filter((floor) => floor.areas.length > 0)
|
||||
.map((floor) => ({ floorId: floor.id, areaIds: floor.areas }));
|
||||
if (this._mappedHierarchy.areas.length > 0) {
|
||||
sections.push({ floorId: null, areaIds: this._mappedHierarchy.areas });
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
|
||||
private _renderSection(section: MappedSection, showLabel: boolean) {
|
||||
const floor = section.floorId ? this.hass.floors[section.floorId] : null;
|
||||
const label = showLabel
|
||||
? floor
|
||||
? computeFloorName(floor)
|
||||
: this.hass.localize("ui.dialogs.more_info_control.vacuum.other_areas")
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div class="section">
|
||||
${label
|
||||
? html`<div class="section-header">
|
||||
${floor
|
||||
? html`<ha-floor-icon .floor=${floor}></ha-floor-icon>`
|
||||
: nothing}
|
||||
<span class="section-name">${label}</span>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="area-grid">
|
||||
${section.areaIds.map((areaId) => this._renderAreaCard(areaId))}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderAreaCard(areaId: string) {
|
||||
const area: AreaRegistryEntry | undefined = this.hass.areas[areaId];
|
||||
if (!area) return nothing;
|
||||
@@ -116,7 +169,7 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
? html`<ha-icon .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
|
||||
</div>
|
||||
<div class="area-name">${area.name}</div>
|
||||
<div class="area-name">${computeAreaName(area)}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -138,7 +191,9 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._mappedAreaIds || this._mappedAreaIds.length === 0) {
|
||||
const sections = this._getMappedSections();
|
||||
|
||||
if (sections.length === 0) {
|
||||
return html`
|
||||
<div class="content empty-content">
|
||||
<div class="empty">
|
||||
@@ -177,11 +232,13 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
const showFloorLabels = sections.length > 1;
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="area-grid">
|
||||
${this._mappedAreaIds.map((areaId) => this._renderAreaCard(areaId))}
|
||||
</div>
|
||||
${sections.map((section) =>
|
||||
this._renderSection(section, showFloorLabels)
|
||||
)}
|
||||
<p class="hint">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.clean_areas_order_hint"
|
||||
@@ -223,6 +280,9 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--ha-space-4);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
@@ -257,6 +317,20 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
margin: 0 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.section-name {
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.area-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
@@ -343,7 +417,7 @@ export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: var(--ha-space-3) 0 0;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
@@ -29,7 +29,6 @@ import "../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
import type { HaIconButton } from "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-list-item";
|
||||
import "../../../components/ha-marquee-text";
|
||||
import "../../../components/ha-select";
|
||||
@@ -507,7 +506,7 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
<ha-icon-button
|
||||
.id=${`media-control-row-button-${idSuffix}`}
|
||||
hide-title
|
||||
.action=${action}
|
||||
action=${ifDefined(action)}
|
||||
@click=${clickHandler}
|
||||
.label=${title}
|
||||
.path=${icon}
|
||||
@@ -709,7 +708,7 @@ class MoreInfoMediaPlayer extends LitElement {
|
||||
handleMediaControlClick(
|
||||
this.hass!,
|
||||
this.stateObj!,
|
||||
(e.currentTarget as HaIconButton & { action: string }).action!
|
||||
(e.currentTarget as HTMLElement).getAttribute("action")!
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mdiDevices } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -149,6 +149,14 @@ export class QuickBar extends LitElement {
|
||||
this._loading = false;
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (changedProps.has("_loading") && !this._loading && this._opened) {
|
||||
requestAnimationFrame(() => {
|
||||
this._comboBox?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _dialogOpened = async () => {
|
||||
this._opened = true;
|
||||
requestAnimationFrame(() => {
|
||||
|
||||
@@ -412,6 +412,10 @@ export const provideHass = (
|
||||
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
},
|
||||
],
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
...overrideData,
|
||||
};
|
||||
|
||||
|
||||
@@ -374,6 +374,7 @@ export class HassTabsSubpage extends LitElement {
|
||||
}
|
||||
|
||||
.main-title {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-height: var(--header-height);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
|
||||
110
src/mixins/match-min-height-mixin.ts
Normal file
110
src/mixins/match-min-height-mixin.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { LitElement, PropertyValues } from "lit";
|
||||
import { state } from "lit/decorators";
|
||||
import type { StyleInfo } from "lit/directives/style-map";
|
||||
import type { Constructor } from "../types";
|
||||
|
||||
/**
|
||||
* Public interface added by {@link MatchMinHeightMixin}.
|
||||
*
|
||||
* Declared separately so consumers can reference the mixin's contributed
|
||||
* members in their own type annotations, per the Lit mixin authoring guide.
|
||||
*/
|
||||
export declare class MatchMinHeightMixinInterface {
|
||||
/** Most recently observed height of `matchMinHeightTarget`, in pixels. */
|
||||
protected _matchedMinHeight?: number;
|
||||
|
||||
/**
|
||||
* `StyleInfo` exposing the matched height as a `min-height` declaration.
|
||||
* Pass to `styleMap` to keep a layout at least as tall as the target
|
||||
* element. Empty until a height has been observed.
|
||||
*/
|
||||
protected get _matchMinHeightStyle(): StyleInfo;
|
||||
|
||||
/**
|
||||
* Element whose height should be matched as a `min-height` floor. Override
|
||||
* with a getter that returns a `@query` result. Return `null` to pause
|
||||
* observation (e.g. while the element is not rendered).
|
||||
*/
|
||||
protected get matchMinHeightTarget(): HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin that observes a target element's height and exposes it as a
|
||||
* `min-height` style. Useful for keeping a sibling layout (e.g. a YAML
|
||||
* editor) at least as tall as another (e.g. a UI form) to avoid content
|
||||
* shift when toggling between them.
|
||||
*
|
||||
* Subclasses override `matchMinHeightTarget` (typically returning a
|
||||
* `@query`-decorated element) to specify which element to observe.
|
||||
*/
|
||||
export const MatchMinHeightMixin = <T extends Constructor<LitElement>>(
|
||||
superClass: T
|
||||
) => {
|
||||
class MatchMinHeightMixinClass extends superClass {
|
||||
@state() protected _matchedMinHeight?: number;
|
||||
|
||||
private _matchTarget: HTMLElement | null = null;
|
||||
|
||||
private _matchResize = new ResizeController(this, {
|
||||
target: null,
|
||||
callback: (entries) => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
if (height) {
|
||||
this._matchedMinHeight = height;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
private static readonly DEFAULT_MATCH_TARGET: HTMLElement | null = null;
|
||||
|
||||
protected get matchMinHeightTarget(): HTMLElement | null {
|
||||
return MatchMinHeightMixinClass.DEFAULT_MATCH_TARGET;
|
||||
}
|
||||
|
||||
protected get _matchMinHeightStyle(): StyleInfo {
|
||||
return this._matchedMinHeight !== undefined
|
||||
? { "min-height": `${this._matchedMinHeight}px` }
|
||||
: {};
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>) {
|
||||
super.firstUpdated?.(changedProperties);
|
||||
this._attachMatchTarget();
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated?.(changedProperties);
|
||||
this._attachMatchTarget();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
this._detachMatchTarget();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
private _attachMatchTarget() {
|
||||
const element = this.matchMinHeightTarget;
|
||||
if (element === this._matchTarget) {
|
||||
return;
|
||||
}
|
||||
this._detachMatchTarget();
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
this._matchTarget = element;
|
||||
this._matchResize.observe(element);
|
||||
}
|
||||
|
||||
private _detachMatchTarget() {
|
||||
if (!this._matchTarget) {
|
||||
return;
|
||||
}
|
||||
this._matchResize.unobserve?.(this._matchTarget);
|
||||
this._matchTarget = null;
|
||||
}
|
||||
}
|
||||
|
||||
return MatchMinHeightMixinClass as unknown as Constructor<MatchMinHeightMixinInterface> &
|
||||
T;
|
||||
};
|
||||
@@ -14,7 +14,6 @@ import "../components/ha-alert";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-list";
|
||||
import "../components/ha-list-item";
|
||||
import "../components/ha-radio";
|
||||
import "../components/ha-spinner";
|
||||
import "../components/input/ha-input";
|
||||
import type { HaInput } from "../components/input/ha-input";
|
||||
|
||||
@@ -39,6 +39,7 @@ import { handleStructError } from "../../../../common/structs/handle-errors";
|
||||
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
|
||||
import "../../../../components/automation/ha-automation-row";
|
||||
import type { HaAutomationRow } from "../../../../components/automation/ha-automation-row";
|
||||
import "../../../../components/automation/ha-automation-row-event-chip";
|
||||
import "../../../../components/ha-card";
|
||||
import "../../../../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
|
||||
@@ -65,6 +66,7 @@ import {
|
||||
manifestsContext,
|
||||
} from "../../../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
|
||||
import type { DomainManifestLookup } from "../../../../data/integration";
|
||||
import type {
|
||||
Action,
|
||||
DeviceAction,
|
||||
@@ -73,7 +75,6 @@ import type {
|
||||
ServiceAction,
|
||||
} from "../../../../data/script";
|
||||
import { getActionType, isAction } from "../../../../data/script";
|
||||
import type { DomainManifestLookup } from "../../../../data/integration";
|
||||
import { describeAction } from "../../../../data/script_i18n";
|
||||
import { callExecuteScript } from "../../../../data/service";
|
||||
import {
|
||||
@@ -203,7 +204,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
@state() private _running = false;
|
||||
|
||||
@state() private _runResult?: {
|
||||
variant: "success" | "danger" | "info";
|
||||
variant: "success" | "danger" | "neutral";
|
||||
title: string;
|
||||
details?: string;
|
||||
};
|
||||
@@ -755,13 +756,8 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
this.scrollIntoView();
|
||||
});
|
||||
|
||||
this._runResult = {
|
||||
variant: "info",
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.run"
|
||||
),
|
||||
};
|
||||
this._running = true;
|
||||
this._running = false;
|
||||
this._runResult = undefined;
|
||||
|
||||
const validated = await validateConfig(this.hass, {
|
||||
actions: this.action,
|
||||
@@ -776,9 +772,22 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
details: validated.actions.error,
|
||||
};
|
||||
} else {
|
||||
const runTimeout = setTimeout(() => {
|
||||
this._runResult = {
|
||||
variant: "neutral",
|
||||
title: `${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.running_action"
|
||||
)}...`,
|
||||
};
|
||||
|
||||
this._running = true;
|
||||
}, 500);
|
||||
|
||||
try {
|
||||
await callExecuteScript(this.hass, this.action);
|
||||
clearTimeout(runTimeout);
|
||||
} catch (err: any) {
|
||||
clearTimeout(runTimeout);
|
||||
this._runResult = {
|
||||
variant: "danger",
|
||||
title: this.hass.localize(
|
||||
@@ -789,7 +798,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (this._runResult.variant === "info") {
|
||||
if (!this._runResult || this._runResult.variant === "neutral") {
|
||||
this._runResult = {
|
||||
variant: "success",
|
||||
title: this.hass.localize(
|
||||
@@ -798,6 +807,8 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
this._running = true;
|
||||
|
||||
this._runResultTimeout = window.setTimeout(() => {
|
||||
this._running = false;
|
||||
}, 2500);
|
||||
|
||||
@@ -327,14 +327,14 @@ class DialogAddAutomationElement
|
||||
|
||||
if (this._params?.type === "action") {
|
||||
this.hass.loadBackendTranslation("services");
|
||||
getServiceIcons(this.hass);
|
||||
getServiceIcons(this.hass.connection, this.hass.config);
|
||||
} else if (this._params?.type === "trigger") {
|
||||
this.hass.loadBackendTranslation("triggers");
|
||||
getTriggerIcons(this.hass);
|
||||
getTriggerIcons(this.hass.connection, this.hass.config);
|
||||
this._subscribeDescriptions();
|
||||
} else if (this._params?.type === "condition") {
|
||||
this.hass.loadBackendTranslation("conditions");
|
||||
getConditionIcons(this.hass);
|
||||
getConditionIcons(this.hass.connection, this.hass.config);
|
||||
this._subscribeDescriptions();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "@home-assistant/webawesome/dist/components/divider/divider";
|
||||
import { mdiHelpCircleOutline } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
@@ -9,7 +10,7 @@ import "../../../../components/ha-dialog-footer";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import "../../../../components/ha-md-list";
|
||||
import "../../../../components/ha-md-list-item";
|
||||
import "../../../../components/ha-radio";
|
||||
import "../../../../components/ha-select-box";
|
||||
import "../../../../components/input/ha-input";
|
||||
|
||||
import type { HaInput } from "../../../../components/input/ha-input";
|
||||
@@ -83,56 +84,26 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
></ha-icon-button>
|
||||
<ha-md-list
|
||||
role="listbox"
|
||||
tabindex="0"
|
||||
aria-activedescendant="option-${this._newMode}"
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.modes.label"
|
||||
)}
|
||||
>
|
||||
${MODES.map((mode) => {
|
||||
const label = this.hass.localize(
|
||||
<ha-select-box
|
||||
.options=${MODES.map((mode) => ({
|
||||
label: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.modes.${mode}`
|
||||
);
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
class="option"
|
||||
type="button"
|
||||
@click=${this._modeChanged}
|
||||
.value=${mode}
|
||||
id="option-${mode}"
|
||||
role="option"
|
||||
aria-label=${label}
|
||||
aria-selected=${this._newMode === mode}
|
||||
>
|
||||
<div slot="start">
|
||||
<ha-radio
|
||||
inert
|
||||
.checked=${this._newMode === mode}
|
||||
value=${mode}
|
||||
@change=${this._modeChanged}
|
||||
name="mode"
|
||||
></ha-radio>
|
||||
</div>
|
||||
<div slot="headline">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.modes.${mode}`
|
||||
)}
|
||||
</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.modes.${mode}_description`
|
||||
)}
|
||||
</div>
|
||||
</ha-md-list-item>
|
||||
`;
|
||||
})}
|
||||
</ha-md-list>
|
||||
),
|
||||
description: this.hass.localize(
|
||||
`ui.panel.config.automation.editor.modes.${mode}_description`
|
||||
),
|
||||
value: mode,
|
||||
}))}
|
||||
.value=${this._newMode}
|
||||
@value-changed=${this._modeChanged}
|
||||
.maxColumns=${1}
|
||||
.hass=${this.hass}
|
||||
></ha-select-box>
|
||||
|
||||
${isMaxMode(this._newMode)
|
||||
? html`
|
||||
<div class="options">
|
||||
<div class="max-value">
|
||||
<wa-divider></wa-divider>
|
||||
<ha-input
|
||||
.label=${this.hass.localize(
|
||||
`ui.panel.config.automation.editor.max.${this._newMode}`
|
||||
@@ -166,7 +137,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
}
|
||||
|
||||
private _modeChanged(ev) {
|
||||
const mode = ev.currentTarget.value;
|
||||
const mode = ev.detail.value;
|
||||
this._newMode = mode;
|
||||
if (!isMaxMode(mode)) {
|
||||
this._newMax = undefined;
|
||||
@@ -200,11 +171,8 @@ class DialogAutomationMode extends LitElement implements HassDialog {
|
||||
haStyle,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
}
|
||||
.options {
|
||||
padding: 0 24px 24px 24px;
|
||||
.max-value {
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
ha-wa-dialog ha-icon-button[slot="headerActionItems"] {
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
@@ -52,7 +52,11 @@ import { isCondition, testCondition } from "../../../../data/automation";
|
||||
import { describeCondition } from "../../../../data/automation_i18n";
|
||||
import type { ConditionDescriptions } from "../../../../data/condition";
|
||||
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
|
||||
import { validateConfig } from "../../../../data/config";
|
||||
import {
|
||||
validateConfig,
|
||||
type InvalidConfig,
|
||||
type ValidConfig,
|
||||
} from "../../../../data/config";
|
||||
import { fullEntitiesContext } from "../../../../data/context";
|
||||
import type { DeviceCondition } from "../../../../data/device/device_automation";
|
||||
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
|
||||
@@ -595,8 +599,6 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
clearTimeout(this._testingTimeout);
|
||||
}
|
||||
|
||||
this._testingResult = undefined;
|
||||
this._testing = true;
|
||||
const condition = this.condition;
|
||||
requestAnimationFrame(() => {
|
||||
// @ts-ignore is supported in all browsers except firefox
|
||||
@@ -608,53 +610,59 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
this.scrollIntoView();
|
||||
});
|
||||
|
||||
let validateResult: Record<"conditions", InvalidConfig | ValidConfig>;
|
||||
try {
|
||||
const validateResult = await validateConfig(this.hass, {
|
||||
validateResult = await validateConfig(this.hass, {
|
||||
conditions: condition,
|
||||
});
|
||||
|
||||
// Abort if condition changed.
|
||||
if (this.condition !== condition) {
|
||||
this._testing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateResult.conditions.valid) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.invalid_condition"
|
||||
),
|
||||
text: validateResult.conditions.error,
|
||||
});
|
||||
this._testing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
let result: { result: boolean };
|
||||
try {
|
||||
result = await testCondition(this.hass, condition);
|
||||
} catch (err: any) {
|
||||
if (this.condition !== condition) {
|
||||
this._testing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.test_failed"
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
this._testing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._testingResult = result.result;
|
||||
} finally {
|
||||
this._testingTimeout = window.setTimeout(() => {
|
||||
this._testing = false;
|
||||
}, 2500);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.validation_failed"
|
||||
),
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Error validating condition", err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort if condition changed.
|
||||
if (this.condition !== condition) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateResult.conditions.valid) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.invalid_condition"
|
||||
),
|
||||
text: validateResult.conditions.error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let result: { result: boolean };
|
||||
try {
|
||||
result = await testCondition(this.hass, condition);
|
||||
} catch (err: any) {
|
||||
if (this.condition !== condition) {
|
||||
return;
|
||||
}
|
||||
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.test_failed"
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this._testingResult = result.result;
|
||||
this._testing = true;
|
||||
this._testingTimeout = window.setTimeout(() => {
|
||||
this._testing = false;
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
private _renameCondition = async (): Promise<void> => {
|
||||
|
||||
@@ -546,7 +546,6 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
.readOnly=${this.readOnly}
|
||||
@value-changed=${this._yamlChanged}
|
||||
@editor-save=${this._handleSaveAutomation}
|
||||
.showErrors=${false}
|
||||
disable-fullscreen
|
||||
></ha-yaml-editor>
|
||||
<ha-button
|
||||
|
||||
@@ -29,6 +29,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { storage } from "../../../common/decorators/storage";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../../common/dom/stop_propagation";
|
||||
import { computeStateName } from "../../../common/entity/compute_state_name";
|
||||
import { navigate } from "../../../common/navigate";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
@@ -44,7 +45,6 @@ import type {
|
||||
SortingChangedEvent,
|
||||
} from "../../../components/data-table/ha-data-table";
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/entity/ha-entity-toggle";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-checkbox";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -63,6 +63,8 @@ import "../../../components/ha-filter-voice-assistants";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-sub-menu";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-switch";
|
||||
import type { HaSwitch } from "../../../components/ha-switch";
|
||||
import "../../../components/ha-tooltip";
|
||||
import { createAreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
import type { AutomationEntity } from "../../../data/automation";
|
||||
@@ -134,6 +136,7 @@ type AutomationItem = AutomationEntity & {
|
||||
formatted_state: string;
|
||||
category: string | undefined;
|
||||
label_entries: LabelRegistryEntry[];
|
||||
labels: string[]; // search only
|
||||
assistants: string[];
|
||||
assistants_sortable_key: string | undefined;
|
||||
};
|
||||
@@ -256,6 +259,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
const category = entityRegEntry?.categories.automation;
|
||||
const labels = labelReg && entityRegEntry?.labels;
|
||||
const label_entries = (labels || [])
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
|
||||
.filter(Boolean);
|
||||
const assistants = getEntityVoiceAssistantsIds(
|
||||
entityReg,
|
||||
automation.entity_id
|
||||
@@ -271,9 +277,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
category: category
|
||||
? categoryReg?.find((cat) => cat.category_id === category)?.name
|
||||
: undefined,
|
||||
label_entries: (labels || [])
|
||||
.map((lbl) => labelReg!.find((label) => label.label_id === lbl)!)
|
||||
.filter(Boolean),
|
||||
label_entries,
|
||||
labels: label_entries.map((lbl) => lbl.name),
|
||||
assistants,
|
||||
assistants_sortable_key: getAssistantsSortableKey(assistants),
|
||||
selectable: entityRegEntry !== undefined,
|
||||
@@ -336,10 +341,12 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
type: "overflow",
|
||||
title: this.hass.localize("ui.panel.config.automation.picker.state"),
|
||||
template: (automation) => html`
|
||||
<ha-entity-toggle
|
||||
.stateObj=${automation}
|
||||
.hass=${this.hass}
|
||||
></ha-entity-toggle>
|
||||
<ha-switch
|
||||
@click=${stopPropagation}
|
||||
@change=${this._handleSwitchToggle}
|
||||
.automation=${automation}
|
||||
.checked=${automation.state === "on"}
|
||||
></ha-switch>
|
||||
`,
|
||||
},
|
||||
actions: {
|
||||
@@ -974,6 +981,13 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
};
|
||||
|
||||
private _handleSwitchToggle = (ev: Event) => {
|
||||
const automation = (
|
||||
ev.currentTarget as HaSwitch & { automation: AutomationItem }
|
||||
).automation;
|
||||
this._toggle(automation);
|
||||
};
|
||||
|
||||
private _toggle = async (automation: AutomationItem): Promise<void> => {
|
||||
const service = automation.state === "off" ? "turn_on" : "turn_off";
|
||||
await this.hass.callService("automation", service, {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const rowStyles = css`
|
||||
gap: var(--ha-space-2);
|
||||
padding: var(--ha-space-2) 0;
|
||||
min-height: 32px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
ha-card {
|
||||
|
||||
@@ -213,6 +213,7 @@ export class HaAutomationRowTargets extends LitElement {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--ha-space-1);
|
||||
max-width: 100%;
|
||||
}
|
||||
.target {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import "../../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../../components/ha-formfield";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../../../../../common/entity/compute_state_domain";
|
||||
import { hasLocation } from "../../../../../common/entity/has_location";
|
||||
import "../../../../../components/entity/ha-entity-picker";
|
||||
import "../../../../../components/radio/ha-radio-group";
|
||||
import type { HaRadioGroup } from "../../../../../components/radio/ha-radio-group";
|
||||
import "../../../../../components/radio/ha-radio-option";
|
||||
import type { ZoneTrigger } from "../../../../../data/automation";
|
||||
import type { ValueChangedEvent, HomeAssistant } from "../../../../../types";
|
||||
import type { HaRadio } from "../../../../../components/ha-radio";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../../../../types";
|
||||
|
||||
function zoneAndLocationFilter(stateObj) {
|
||||
return hasLocation(stateObj) && computeStateDomain(stateObj) !== "zone";
|
||||
@@ -56,39 +57,27 @@ export class HaZoneTrigger extends LitElement {
|
||||
.includeDomains=${includeDomains}
|
||||
></ha-entity-picker>
|
||||
|
||||
<label>
|
||||
${this.hass.localize(
|
||||
<ha-radio-group
|
||||
orientation="horizontal"
|
||||
.label=${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.zone.event"
|
||||
)}
|
||||
<ha-formfield
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.hass.localize(
|
||||
.value=${event}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._radioGroupPicked}
|
||||
name="event"
|
||||
>
|
||||
<ha-radio-option value="enter">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.zone.enter"
|
||||
)}
|
||||
>
|
||||
<ha-radio
|
||||
name="event"
|
||||
value="enter"
|
||||
.disabled=${this.disabled}
|
||||
.checked=${event === "enter"}
|
||||
@change=${this._radioGroupPicked}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
<ha-formfield
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.hass.localize(
|
||||
</ha-radio-option>
|
||||
<ha-radio-option value="leave">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.triggers.type.zone.leave"
|
||||
)}
|
||||
>
|
||||
<ha-radio
|
||||
name="event"
|
||||
value="leave"
|
||||
.disabled=${this.disabled}
|
||||
.checked=${event === "leave"}
|
||||
@change=${this._radioGroupPicked}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
</label>
|
||||
</ha-radio-option>
|
||||
</ha-radio-group>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -106,21 +95,17 @@ export class HaZoneTrigger extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _radioGroupPicked(ev: CustomEvent) {
|
||||
private _radioGroupPicked(ev: Event) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...this.trigger,
|
||||
event: (ev.target as HaRadio).value,
|
||||
event: (ev.currentTarget as HaRadioGroup).value,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
ha-entity-picker {
|
||||
display: block;
|
||||
margin-bottom: 24px;
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { mdiPuzzle } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../../components/ha-card";
|
||||
import "../../../../../components/ha-icon-next";
|
||||
import "../../../../../components/ha-md-list";
|
||||
import "../../../../../components/ha-md-list-item";
|
||||
import "../../../../../components/ha-svg-icon";
|
||||
import {
|
||||
getSupervisorUpdateConfig,
|
||||
type SupervisorUpdateConfig,
|
||||
} from "../../../../../data/supervisor/update";
|
||||
import { haStyle } from "../../../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../../../types";
|
||||
|
||||
@customElement("ha-backup-overview-app-update-backup")
|
||||
class HaBackupOverviewAppUpdateBackup extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@state() private _supervisorUpdateConfig?: SupervisorUpdateConfig;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._fetchSupervisorUpdateConfig();
|
||||
}
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._fetchSupervisorUpdateConfig();
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchSupervisorUpdateConfig() {
|
||||
try {
|
||||
this._supervisorUpdateConfig = await getSupervisorUpdateConfig(this.hass);
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
private _appUpdateBackupDescription() {
|
||||
if (!this._supervisorUpdateConfig) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.local_only"
|
||||
);
|
||||
}
|
||||
|
||||
if (!this._supervisorUpdateConfig.add_on_backup_before_update) {
|
||||
return this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.update_preference.skip_backups"
|
||||
);
|
||||
}
|
||||
|
||||
const copies =
|
||||
this._supervisorUpdateConfig.add_on_backup_retain_copies || 1;
|
||||
|
||||
return `${this.hass.localize(
|
||||
"ui.panel.config.backup.schedule.update_preference.backup_before_update"
|
||||
)} ${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.settings.schedule_copies_backups",
|
||||
{ count: copies }
|
||||
)}`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-card>
|
||||
<div class="card-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.app_update_backup.title"
|
||||
)}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<ha-md-list>
|
||||
<ha-md-list-item
|
||||
type="link"
|
||||
href="/config/backup/app-update-backups"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiPuzzle}></ha-svg-icon>
|
||||
<div slot="headline">${this._appUpdateBackupDescription()}</div>
|
||||
<div slot="supporting-text">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.overview.app_update_backup.description"
|
||||
)}
|
||||
</div>
|
||||
<ha-icon-next slot="end"></ha-icon-next>
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
</ha-card>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
.card-header {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
ha-md-list {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-backup-overview-app-update-backup": HaBackupOverviewAppUpdateBackup;
|
||||
}
|
||||
}
|
||||
154
src/panels/config/backup/ha-config-backup-app-update-backups.ts
Normal file
154
src/panels/config/backup/ha-config-backup-app-update-backups.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-card";
|
||||
import {
|
||||
getSupervisorUpdateConfig,
|
||||
updateSupervisorUpdateConfig,
|
||||
type SupervisorUpdateConfig,
|
||||
} from "../../../data/supervisor/update";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./components/config/ha-backup-config-addon";
|
||||
|
||||
@customElement("ha-config-backup-app-update-backups")
|
||||
class HaConfigBackupAppUpdateBackups extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _supervisorUpdateConfig?: SupervisorUpdateConfig;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if (
|
||||
!this.hasUpdated &&
|
||||
this.hass &&
|
||||
isComponentLoaded(this.hass.config, "hassio")
|
||||
) {
|
||||
this._getSupervisorUpdateConfig();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hass-subpage
|
||||
back-path="/config/backup/overview"
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize(
|
||||
"ui.panel.config.backup.app_update_backups.header"
|
||||
)}
|
||||
>
|
||||
<div class="content">
|
||||
<ha-card>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.description"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.local_only"
|
||||
)}
|
||||
</p>
|
||||
${this._error
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing}
|
||||
<ha-backup-config-addon
|
||||
.hass=${this.hass}
|
||||
.supervisorUpdateConfig=${this._supervisorUpdateConfig}
|
||||
@update-config-changed=${this._supervisorUpdateConfigChanged}
|
||||
></ha-backup-config-addon>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _getSupervisorUpdateConfig() {
|
||||
try {
|
||||
this._supervisorUpdateConfig = await getSupervisorUpdateConfig(this.hass);
|
||||
this._error = undefined;
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.error_load",
|
||||
{
|
||||
error: err?.message || err,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async _supervisorUpdateConfigChanged(ev) {
|
||||
const config = ev.detail.value as SupervisorUpdateConfig;
|
||||
this._supervisorUpdateConfig = {
|
||||
...this._supervisorUpdateConfig,
|
||||
...config,
|
||||
} as SupervisorUpdateConfig;
|
||||
this._debounceSaveSupervisorUpdateConfig();
|
||||
}
|
||||
|
||||
private _debounceSaveSupervisorUpdateConfig = debounce(
|
||||
() => this._saveSupervisorUpdateConfig(),
|
||||
500
|
||||
);
|
||||
|
||||
private async _saveSupervisorUpdateConfig() {
|
||||
if (!this._supervisorUpdateConfig) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateSupervisorUpdateConfig(
|
||||
this.hass,
|
||||
this._supervisorUpdateConfig
|
||||
);
|
||||
this._error = undefined;
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
this._error = this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.error_save",
|
||||
{
|
||||
error: err?.message || err?.toString(),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
p {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px 20px 0;
|
||||
max-width: 690px;
|
||||
margin: 0 auto;
|
||||
gap: var(--ha-space-6);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-config-backup-app-update-backups": HaConfigBackupAppUpdateBackups;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { mdiDotsVertical, mdiPlus, mdiUpload } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import { debounce } from "../../../common/util/debounce";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-dropdown";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
computeBackupAgentName,
|
||||
generateBackup,
|
||||
generateBackupWithAutomaticSettings,
|
||||
saveBackupConfig,
|
||||
} from "../../../data/backup";
|
||||
import type { ManagerStateEvent } from "../../../data/backup_manager";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
@@ -32,10 +34,12 @@ import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { showAlertDialog } from "../../lovelace/custom-card-helpers";
|
||||
import "./components/overview/ha-backup-overview-backups";
|
||||
import "./components/overview/ha-backup-overview-app-update-backup";
|
||||
import "./components/overview/ha-backup-overview-onboarding";
|
||||
import "./components/overview/ha-backup-overview-progress";
|
||||
import "./components/overview/ha-backup-overview-settings";
|
||||
import "./components/overview/ha-backup-overview-summary";
|
||||
import "./components/config/ha-backup-config-encryption-key";
|
||||
import { showBackupOnboardingDialog } from "./dialogs/show-dialog-backup_onboarding";
|
||||
import { showGenerateBackupDialog } from "./dialogs/show-dialog-generate-backup";
|
||||
import { showNewBackupDialog } from "./dialogs/show-dialog-new-backup";
|
||||
@@ -68,10 +72,54 @@ class HaConfigBackupOverview extends LitElement {
|
||||
{ uploaded_bytes: number; total_bytes: number }
|
||||
> = {};
|
||||
|
||||
@state() private _config?: BackupConfig;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProperties);
|
||||
if (changedProperties.has("config") && !this._config) {
|
||||
this._config = this.config;
|
||||
}
|
||||
}
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
// Update config when the page is displayed (e.g. when coming back from a settings page)
|
||||
this._config = this.config;
|
||||
}
|
||||
|
||||
private _uploadBackup = async () => {
|
||||
await showUploadBackupDialog(this, {});
|
||||
};
|
||||
|
||||
private _encryptionKeyChanged(ev) {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
const password = ev.detail.value as string;
|
||||
this._config = {
|
||||
...this._config,
|
||||
create_backup: {
|
||||
...this._config.create_backup,
|
||||
password,
|
||||
},
|
||||
};
|
||||
|
||||
this._debounceSaveConfig();
|
||||
}
|
||||
|
||||
private _debounceSaveConfig = debounce(() => this._saveConfig(), 500);
|
||||
|
||||
private async _saveConfig() {
|
||||
if (!this._config) {
|
||||
return;
|
||||
}
|
||||
|
||||
await saveBackupConfig(this.hass, this._config);
|
||||
|
||||
fireEvent(this, "ha-refresh-backup-config");
|
||||
}
|
||||
|
||||
private _handleOnboardingButtonClick(ev) {
|
||||
ev.stopPropagation();
|
||||
this._setupAutomaticBackup(true);
|
||||
@@ -234,13 +282,41 @@ class HaConfigBackupOverview extends LitElement {
|
||||
.backups=${this.backups}
|
||||
></ha-backup-overview-backups>
|
||||
|
||||
${!this._needsOnboarding && this.config
|
||||
${!this._needsOnboarding && this._config
|
||||
? html`
|
||||
<ha-card>
|
||||
<div class="card-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.encryption_key.title"
|
||||
)}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.encryption_key.description"
|
||||
)}
|
||||
</p>
|
||||
<ha-backup-config-encryption-key
|
||||
.hass=${this.hass}
|
||||
.value=${this._config.create_backup.password}
|
||||
@value-changed=${this._encryptionKeyChanged}
|
||||
></ha-backup-config-encryption-key>
|
||||
</div>
|
||||
</ha-card>
|
||||
|
||||
<ha-backup-overview-settings
|
||||
.hass=${this.hass}
|
||||
.config=${this.config!}
|
||||
.config=${this._config}
|
||||
.agents=${this.agents}
|
||||
></ha-backup-overview-settings>
|
||||
|
||||
${this.hass.config.components.includes("hassio")
|
||||
? html`
|
||||
<ha-backup-overview-app-update-backup
|
||||
.hass=${this.hass}
|
||||
></ha-backup-overview-app-update-backup>
|
||||
`
|
||||
: nothing}
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -270,6 +346,10 @@ class HaConfigBackupOverview extends LitElement {
|
||||
return [
|
||||
haStyle,
|
||||
css`
|
||||
p {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 28px 20px 0;
|
||||
max-width: 690px;
|
||||
@@ -283,10 +363,6 @@ class HaConfigBackupOverview extends LitElement {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.card-content {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { BackupAgent, BackupConfig } from "../../../data/backup";
|
||||
import { updateBackupConfig } from "../../../data/backup";
|
||||
import { saveBackupConfig } from "../../../data/backup";
|
||||
import type { CloudStatus } from "../../../data/cloud";
|
||||
import {
|
||||
getSupervisorUpdateConfig,
|
||||
@@ -27,11 +27,9 @@ import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import "./components/config/ha-backup-config-addon";
|
||||
import "./components/config/ha-backup-config-agents";
|
||||
import "./components/config/ha-backup-config-data";
|
||||
import type { BackupConfigData } from "./components/config/ha-backup-config-data";
|
||||
import "./components/config/ha-backup-config-encryption-key";
|
||||
import "./components/config/ha-backup-config-schedule";
|
||||
import type { BackupConfigSchedule } from "./components/config/ha-backup-config-schedule";
|
||||
import { showLocalBackupLocationDialog } from "./dialogs/show-dialog-local-backup-location";
|
||||
@@ -79,7 +77,7 @@ class HaConfigBackupSettings extends LitElement {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
this._supervisorUpdateConfigError = this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.error_load",
|
||||
"ui.panel.config.backup.settings.schedule.error_load",
|
||||
{
|
||||
error: err?.message || err,
|
||||
}
|
||||
@@ -315,57 +313,6 @@ class HaConfigBackupSettings extends LitElement {
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-card>
|
||||
${supervisor
|
||||
? html`<ha-card>
|
||||
<div class="card-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.title"
|
||||
)}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.description"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.local_only"
|
||||
)}
|
||||
</p>
|
||||
${this._supervisorUpdateConfigError
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this._supervisorUpdateConfigError}
|
||||
</ha-alert>`
|
||||
: nothing}
|
||||
<ha-backup-config-addon
|
||||
.hass=${this.hass}
|
||||
.supervisorUpdateConfig=${this._supervisorUpdateConfig}
|
||||
@update-config-changed=${this
|
||||
._supervisorUpdateConfigChanged}
|
||||
></ha-backup-config-addon>
|
||||
</div>
|
||||
</ha-card>`
|
||||
: nothing}
|
||||
<ha-card>
|
||||
<div class="card-header">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.encryption_key.title"
|
||||
)}
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.backup.settings.encryption_key.description"
|
||||
)}
|
||||
</p>
|
||||
<ha-backup-config-encryption-key
|
||||
.hass=${this.hass}
|
||||
.value=${this._config.create_backup.password}
|
||||
@value-changed=${this._encryptionKeyChanged}
|
||||
></ha-backup-config-encryption-key>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
</hass-subpage>
|
||||
`;
|
||||
@@ -438,18 +385,6 @@ class HaConfigBackupSettings extends LitElement {
|
||||
this._debounceSave();
|
||||
}
|
||||
|
||||
private _encryptionKeyChanged(ev) {
|
||||
const password = ev.detail.value as string;
|
||||
this._config = {
|
||||
...this._config!,
|
||||
create_backup: {
|
||||
...this._config!.create_backup,
|
||||
password: password,
|
||||
},
|
||||
};
|
||||
this._debounceSave();
|
||||
}
|
||||
|
||||
private _debounceSaveSupervisorUpdateConfig = debounce(
|
||||
() => this._saveSupervisorUpdateConfig(),
|
||||
500
|
||||
@@ -468,7 +403,7 @@ class HaConfigBackupSettings extends LitElement {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
this._supervisorUpdateConfigError = this.hass.localize(
|
||||
"ui.panel.config.backup.settings.app_update_backup.error_save",
|
||||
"ui.panel.config.backup.settings.schedule.error_save",
|
||||
{
|
||||
error: err?.message || err?.toString(),
|
||||
}
|
||||
@@ -479,18 +414,7 @@ class HaConfigBackupSettings extends LitElement {
|
||||
private _debounceSave = debounce(() => this._save(), 500);
|
||||
|
||||
private async _save() {
|
||||
await updateBackupConfig(this.hass, {
|
||||
create_backup: {
|
||||
agent_ids: this._config!.create_backup.agent_ids,
|
||||
include_folders: this._config!.create_backup.include_folders ?? [],
|
||||
include_database: this._config!.create_backup.include_database,
|
||||
include_addons: this._config!.create_backup.include_addons ?? [],
|
||||
include_all_addons: this._config!.create_backup.include_all_addons,
|
||||
password: this._config!.create_backup.password,
|
||||
},
|
||||
retention: this._config!.retention,
|
||||
schedule: this._config!.schedule,
|
||||
});
|
||||
await saveBackupConfig(this.hass, this._config!);
|
||||
fireEvent(this, "ha-refresh-backup-config");
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,11 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
|
||||
load: () => import("./ha-config-backup-settings"),
|
||||
cache: true,
|
||||
},
|
||||
"app-update-backups": {
|
||||
tag: "ha-config-backup-app-update-backups",
|
||||
load: () => import("./ha-config-backup-app-update-backups"),
|
||||
cache: true,
|
||||
},
|
||||
location: {
|
||||
tag: "ha-config-backup-location",
|
||||
load: () => import("./ha-config-backup-location"),
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
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";
|
||||
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";
|
||||
|
||||
@customElement("ha-navigation-list")
|
||||
class HaNavigationList extends LitElement {
|
||||
@customElement("ha-config-navigation-list")
|
||||
class HaConfigNavigationList extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -24,16 +23,11 @@ class HaNavigationList extends LitElement {
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<ha-md-list
|
||||
innerRole="menu"
|
||||
itemRoles="menuitem"
|
||||
innerAriaLabel=${ifDefined(this.label)}
|
||||
>
|
||||
<ha-list-nav .ariaLabel=${this.label}>
|
||||
${this.pages.map((page) => {
|
||||
const externalApp = page.path.endsWith("#external-app-configuration");
|
||||
return html`
|
||||
<ha-md-list-item
|
||||
.type=${externalApp ? "button" : "link"}
|
||||
<ha-list-item-button
|
||||
.href=${externalApp ? undefined : page.path}
|
||||
@click=${externalApp ? this._handleExternalApp : undefined}
|
||||
>
|
||||
@@ -55,10 +49,10 @@ class HaNavigationList extends LitElement {
|
||||
${!this.narrow
|
||||
? html`<ha-icon-next slot="end"></ha-icon-next>`
|
||||
: ""}
|
||||
</ha-md-list-item>
|
||||
</ha-list-item-button>
|
||||
`;
|
||||
})}
|
||||
</ha-md-list>
|
||||
</ha-list-nav>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -83,14 +77,11 @@ class HaNavigationList 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-navigation-list": HaNavigationList;
|
||||
"ha-config-navigation-list": HaConfigNavigationList;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { mdiDownload } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
import { extractSearchParam } from "../../../common/url/search-params";
|
||||
import "../../../components/ha-analytics";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-md-list";
|
||||
import "../../../components/ha-md-list-item";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-switch";
|
||||
import { getSignedPath } from "../../../data/auth";
|
||||
import type { HaSwitch } from "../../../components/ha-switch";
|
||||
import type { Analytics } from "../../../data/analytics";
|
||||
import {
|
||||
@@ -26,6 +30,7 @@ import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { documentationUrl } from "../../../util/documentation-url";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
|
||||
@customElement("ha-config-analytics")
|
||||
class ConfigAnalytics extends SubscribeMixin(LitElement) {
|
||||
@@ -119,6 +124,18 @@ class ConfigAnalytics extends SubscribeMixin(LitElement) {
|
||||
</ha-md-list-item>
|
||||
</ha-md-list>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<ha-button
|
||||
size="small"
|
||||
appearance="plain"
|
||||
@click=${this._downloadDeviceInfo}
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiDownload}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.analytics.download_device_info"
|
||||
)}
|
||||
</ha-button>
|
||||
</div>
|
||||
</ha-card>`
|
||||
: nothing}
|
||||
${this._zwaveEntryId !== undefined
|
||||
@@ -290,6 +307,11 @@ class ConfigAnalytics extends SubscribeMixin(LitElement) {
|
||||
this._save();
|
||||
}
|
||||
|
||||
private async _downloadDeviceInfo(): Promise<void> {
|
||||
const signedPath = await getSignedPath(this.hass, "/api/analytics/devices");
|
||||
fileDownload(signedPath.path);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyle,
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { mdiDotsVertical, mdiDownload } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-dropdown-item";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { getSignedPath } from "../../../data/auth";
|
||||
import "../../../layouts/hass-subpage";
|
||||
import type { HomeAssistant, Route } from "../../../types";
|
||||
import { fileDownload } from "../../../util/file_download";
|
||||
import "./ha-config-analytics";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
|
||||
@customElement("ha-config-section-analytics")
|
||||
class HaConfigSectionAnalytics extends LitElement {
|
||||
@@ -29,19 +21,6 @@ class HaConfigSectionAnalytics extends LitElement {
|
||||
.narrow=${this.narrow}
|
||||
.header=${this.hass.localize("ui.panel.config.analytics.caption")}
|
||||
>
|
||||
<ha-dropdown
|
||||
@wa-select=${this._handleOverflowAction}
|
||||
slot="toolbar-icon"
|
||||
>
|
||||
<ha-icon-button slot="trigger" .path=${mdiDotsVertical}>
|
||||
</ha-icon-button>
|
||||
<ha-dropdown-item .value=${"download_device_info"}>
|
||||
<ha-svg-icon slot="icon" .path=${mdiDownload}></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.analytics.download_device_info"
|
||||
)}
|
||||
</ha-dropdown-item>
|
||||
</ha-dropdown>
|
||||
<div class="content">
|
||||
<ha-config-analytics .hass=${this.hass}></ha-config-analytics>
|
||||
</div>
|
||||
@@ -49,18 +28,6 @@ class HaConfigSectionAnalytics extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private async _handleOverflowAction(
|
||||
ev: HaDropdownSelectEvent
|
||||
): Promise<void> {
|
||||
if (ev.detail.item.value === "download_device_info") {
|
||||
const signedPath = await getSignedPath(
|
||||
this.hass,
|
||||
"/api/analytics/devices"
|
||||
);
|
||||
fileDownload(signedPath.path);
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.content {
|
||||
padding: 28px 20px 0;
|
||||
|
||||
@@ -13,10 +13,8 @@ import "../../../components/ha-checkbox";
|
||||
import type { HaCheckbox } from "../../../components/ha-checkbox";
|
||||
import "../../../components/ha-country-picker";
|
||||
import "../../../components/ha-currency-picker";
|
||||
import "../../../components/ha-formfield";
|
||||
import "../../../components/ha-language-picker";
|
||||
import "../../../components/ha-radio";
|
||||
import type { HaRadio } from "../../../components/ha-radio";
|
||||
import "../../../components/ha-select-box";
|
||||
import "../../../components/ha-timezone-picker";
|
||||
import "../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../components/input/ha-input";
|
||||
@@ -210,75 +208,61 @@ class HaConfigSectionGeneral extends LitElement {
|
||||
"ui.panel.config.core.section.core.core_config.unit_system"
|
||||
)}
|
||||
</div>
|
||||
<ha-formfield
|
||||
.label=${html`
|
||||
<span style="font-size: 14px">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.metric_example"
|
||||
)}
|
||||
</span>
|
||||
<div style="color: var(--secondary-text-color)">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.unit_system_metric"
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
>
|
||||
<ha-radio
|
||||
<div class="unit-system-options">
|
||||
<ha-select-box
|
||||
name="unit_system"
|
||||
value="metric"
|
||||
.checked=${this._unitSystem === "metric"}
|
||||
@change=${this._unitSystemChanged}
|
||||
.hass=${this.hass}
|
||||
.value=${this._unitSystem}
|
||||
.disabled=${disabled}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
<ha-formfield
|
||||
.label=${html`
|
||||
<span style="font-size: 14px">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.us_customary_example"
|
||||
)}
|
||||
</span>
|
||||
<div style="color: var(--secondary-text-color)">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.unit_system_us_customary"
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
>
|
||||
<ha-radio
|
||||
name="unit_system"
|
||||
value="us_customary"
|
||||
.checked=${this._unitSystem === "us_customary"}
|
||||
@change=${this._unitSystemChanged}
|
||||
.disabled=${disabled}
|
||||
></ha-radio>
|
||||
</ha-formfield>
|
||||
${this._unitSystem !== this._configuredUnitSystem()
|
||||
? html`
|
||||
<ha-checkbox
|
||||
.checked=${this._updateUnits}
|
||||
.disabled=${this._submittingRegional}
|
||||
@change=${this._updateUnitsChanged}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.update_units_label"
|
||||
)}
|
||||
<div slot="hint">
|
||||
@value-changed=${this._unitSystemChanged}
|
||||
.options=${[
|
||||
{
|
||||
value: "metric",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.metric_example"
|
||||
),
|
||||
description: this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.unit_system_metric"
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "us_customary",
|
||||
label: this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.us_customary_example"
|
||||
),
|
||||
description: this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.unit_system_us_customary"
|
||||
),
|
||||
},
|
||||
]}
|
||||
>
|
||||
</ha-select-box>
|
||||
${this._unitSystem !== this._configuredUnitSystem()
|
||||
? html`
|
||||
<ha-checkbox
|
||||
.checked=${this._updateUnits}
|
||||
.disabled=${this._submittingRegional}
|
||||
@change=${this._updateUnitsChanged}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.update_units_text_1"
|
||||
"ui.panel.config.core.section.core.core_config.update_units_label"
|
||||
)}
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.update_units_text_2"
|
||||
)}
|
||||
<br /><br />
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.update_units_text_3"
|
||||
)}
|
||||
</div>
|
||||
</ha-checkbox>
|
||||
`
|
||||
: ""}
|
||||
<div slot="hint">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.update_units_text_1"
|
||||
)}
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.update_units_text_2"
|
||||
)}
|
||||
<br /><br />
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.core.section.core.core_config.update_units_text_3"
|
||||
)}
|
||||
</div>
|
||||
</ha-checkbox>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ha-currency-picker
|
||||
@@ -365,10 +349,8 @@ class HaConfigSectionGeneral extends LitElement {
|
||||
this[`_${target.name}`] = target.value;
|
||||
}
|
||||
|
||||
private _unitSystemChanged(ev: CustomEvent) {
|
||||
this._unitSystem = (ev.target as HaRadio).value as
|
||||
| "metric"
|
||||
| "us_customary";
|
||||
private _unitSystemChanged(ev: ValueChangedEvent<string>) {
|
||||
this._unitSystem = ev.detail.value as "metric" | "us_customary";
|
||||
}
|
||||
|
||||
private _updateUnitsChanged(ev: CustomEvent) {
|
||||
@@ -505,6 +487,15 @@ class HaConfigSectionGeneral extends LitElement {
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
.unit-system-options {
|
||||
padding-top: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.unit-system-options ha-checkbox {
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
margin-inline-start: var(--ha-space-3);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user