mirror of
https://github.com/home-assistant/frontend.git
synced 2026-08-19 21:18:31 +00:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b51c0bf086 | ||
|
|
82ed13b7fc | ||
|
|
089592e7e3 | ||
|
|
4bc9b2be7b | ||
|
|
1778b7abff | ||
|
|
90567a491a | ||
|
|
409e2b315f | ||
|
|
eb1f8c1fab | ||
|
|
29cf30fc1a | ||
|
|
f88535c446 | ||
|
|
b9fd639bb7 | ||
|
|
2925dfc14e | ||
|
|
bb7ec34532 | ||
|
|
89015bc135 | ||
|
|
343f1e840a | ||
|
|
1369f80eb0 | ||
|
|
cd52f7b193 | ||
|
|
86dea86b6c | ||
|
|
04035016b0 | ||
|
|
52b0b46991 | ||
|
|
d5389b5bef | ||
|
|
f828c636a2 | ||
|
|
e91337b320 | ||
|
|
8ab7c118a9 | ||
|
|
0eafb1f4b4 | ||
|
|
9a7722a02e | ||
|
|
3e184eeb39 | ||
|
|
61be4e2952 | ||
|
|
315cf97e56 | ||
|
|
771a8aa4c9 | ||
|
|
d9aa649854 | ||
|
|
7a087cd6d0 | ||
|
|
32a30468bb | ||
|
|
0ace3cedfb | ||
|
|
aba7478e26 | ||
|
|
c8b2e5160a | ||
|
|
4c46ca0918 | ||
|
|
0483106f61 | ||
|
|
7e0c572f69 | ||
|
|
e4438a20cb | ||
|
|
ec418498a7 | ||
|
|
8122baca00 | ||
|
|
79738dd257 | ||
|
|
f23c8f0e23 | ||
|
|
f578cb0526 | ||
|
|
78878897cc | ||
|
|
9472d8ad12 | ||
|
|
94cbe4467b | ||
|
|
043abb69af | ||
|
|
b6b8fe641f | ||
|
|
70bb08b101 | ||
|
|
4259ddee24 | ||
|
|
6c5ac1ea84 | ||
|
|
5224fa2048 | ||
|
|
22ca986cce | ||
|
|
7f8bf69424 | ||
|
|
92224411e1 | ||
|
|
b52d58eccb | ||
|
|
a9cc47888a | ||
|
|
ea0ceecbfc | ||
|
|
5f007a1575 | ||
|
|
f360a22927 | ||
|
|
a67111e41f | ||
|
|
4b7d3a7e4f | ||
|
|
88be7adafa | ||
|
|
91a6d737b3 | ||
|
|
22c3a6fe67 | ||
|
|
bcc799970a | ||
|
|
31d4a37c15 |
@@ -11,6 +11,9 @@ inputs:
|
||||
is-test:
|
||||
description: Set IS_TEST for the build (skips source maps and compression)
|
||||
default: "false"
|
||||
rspack-cache:
|
||||
description: rspack persistent cache mode ("readwrite", "readonly", or "" to disable)
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -21,3 +24,4 @@ runs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.github-token }}
|
||||
IS_TEST: ${{ inputs.is-test }}
|
||||
RSPACK_CACHE: ${{ inputs.rspack-cache }}
|
||||
|
||||
@@ -97,10 +97,12 @@ jobs:
|
||||
run: yarn run test
|
||||
build:
|
||||
name: Build frontend
|
||||
needs:
|
||||
- prepare-dependencies
|
||||
- lint
|
||||
- test
|
||||
# Runs alongside lint and test rather than after them: the build only needs
|
||||
# the dependency tree, and with the rspack cache it is no longer expensive
|
||||
# enough to be worth serialising behind the other checks. The
|
||||
# cancel-on-failure job below stops the run as soon as a check fails, so a
|
||||
# broken pull request does not finish building.
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out files from GitHub
|
||||
@@ -111,12 +113,26 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-modules-cache: true
|
||||
# Read-only reuse of the rspack cache written by the nightly (see
|
||||
# nightly.yaml). rspack itself decides what is still valid (version +
|
||||
# buildDependencies + node_modules snapshot), so the GHA key just restores
|
||||
# the latest nightly cache; no fingerprint, and no save step (CI never
|
||||
# writes the shared cache).
|
||||
- name: Restore rspack cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
- name: Build Application
|
||||
uses: ./.github/actions/build
|
||||
with:
|
||||
target: build-app
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
rspack-cache: readonly
|
||||
- name: Upload bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -132,3 +148,54 @@ jobs:
|
||||
path: hass_frontend/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Now that the checks run in parallel, a failing lint or test no longer stops
|
||||
# the build from finishing on its own, so this watches them and cancels the
|
||||
# whole run on the first failure.
|
||||
#
|
||||
# It is a separate job on purpose. Cancelling needs `actions: write`, and the
|
||||
# other jobs check out the pull request and run its build scripts — handing
|
||||
# them that scope would give PR-controlled code (or a compromised dependency)
|
||||
# write access to Actions. This job never checks out the repository, so the
|
||||
# elevated token stays away from PR code. It also cannot be a job that
|
||||
# `needs` the checks: that would only start once they have all finished, which
|
||||
# is exactly too late to cancel anything.
|
||||
cancel-on-failure:
|
||||
name: Cancel run on failure
|
||||
needs: prepare-dependencies
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Cancel the run when a check fails
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
run: |
|
||||
watched='^(Lint and check format|Run tests|Build frontend)$'
|
||||
while :; do
|
||||
jobs=$(gh api "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
|
||||
--paginate --jq '.jobs[] | [.name, .status, (.conclusion // "")] | @tsv' \
|
||||
2>/dev/null || true)
|
||||
|
||||
failed=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && ($3 == "failure" || $3 == "timed_out") { print $1 }')
|
||||
if [ -n "$failed" ]; then
|
||||
echo "Cancelling the run, these checks failed:"
|
||||
printf '%s\n' "$failed"
|
||||
gh run cancel "$RUN_ID" --repo "$REPO" || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
found=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" '$1 ~ w' | wc -l)
|
||||
running=$(printf '%s\n' "$jobs" | awk -F'\t' -v w="$watched" \
|
||||
'$1 ~ w && $2 != "completed" { print $1 }')
|
||||
if [ "$found" -ge 3 ] && [ -z "$running" ]; then
|
||||
echo "All checks finished without failure"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sleep 15
|
||||
done
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
with:
|
||||
target: build-gallery
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
is-test: true
|
||||
|
||||
- name: Upload gallery build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -58,9 +58,24 @@ jobs:
|
||||
restore-keys: |
|
||||
compress-cache-${{ runner.os }}-
|
||||
|
||||
# The nightly writes the rspack persistent cache; CI reads it read-only
|
||||
# (see ci.yaml). rspack invalidates internally (version + buildDependencies
|
||||
# + node_modules snapshot), so the cache rolls forward daily and a single
|
||||
# dependency bump keeps most of it warm instead of dropping the lineage.
|
||||
- name: Restore rspack cache
|
||||
id: rspack-cache
|
||||
continue-on-error: true
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
restore-keys: |
|
||||
rspack-cache-${{ runner.os }}-
|
||||
|
||||
- name: Build nightly Python wheels
|
||||
env:
|
||||
COMPRESS_CACHE_DIR: ${{ github.workspace }}/.compress-cache
|
||||
RSPACK_CACHE: readwrite
|
||||
run: |
|
||||
pip install build
|
||||
yarn install
|
||||
@@ -69,7 +84,7 @@ jobs:
|
||||
rm -rf dist home_assistant_frontend.egg-info
|
||||
python3 -m build
|
||||
|
||||
# Not gated on the restore step: a transient restore failure (it is
|
||||
# Not gated on the restore steps: a transient restore failure (they are
|
||||
# continue-on-error) must not stop us persisting a freshly built cache.
|
||||
- name: Save compression cache
|
||||
if: success()
|
||||
@@ -79,6 +94,14 @@ jobs:
|
||||
path: .compress-cache
|
||||
key: compress-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Save rspack cache
|
||||
if: success()
|
||||
continue-on-error: true
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .rspack-cache
|
||||
key: rspack-cache-${{ runner.os }}-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -7,6 +7,7 @@ dist/
|
||||
/hass_frontend/
|
||||
/translations/
|
||||
/.compress-cache/
|
||||
/.rspack-cache/
|
||||
# Composite action source, not build output
|
||||
!/.github/actions/build/
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
|
||||
"frontend-modern": {
|
||||
"app": 595204,
|
||||
"core": 57741,
|
||||
"authorize": 576928,
|
||||
"onboarding": 685964
|
||||
"app": 585518,
|
||||
"core": 57048,
|
||||
"authorize": 552423,
|
||||
"onboarding": 666818
|
||||
},
|
||||
"frontend-legacy": {
|
||||
"app": 861452,
|
||||
"core": 258557,
|
||||
"authorize": 834356,
|
||||
"onboarding": 1001360
|
||||
"app": 887384,
|
||||
"core": 244482,
|
||||
"authorize": 844995,
|
||||
"onboarding": 1008816
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ module.exports.config = {
|
||||
};
|
||||
},
|
||||
|
||||
gallery({ isProdBuild, latestBuild }) {
|
||||
gallery({ isProdBuild, latestBuild, isTestBuild }) {
|
||||
return {
|
||||
name: "gallery" + nameSuffix(latestBuild),
|
||||
entry: {
|
||||
@@ -287,6 +287,7 @@ module.exports.config = {
|
||||
publicPath: publicPath(latestBuild),
|
||||
isProdBuild,
|
||||
latestBuild,
|
||||
isTestBuild,
|
||||
defineOverlay: {
|
||||
__DEMO__: true,
|
||||
},
|
||||
|
||||
@@ -252,6 +252,7 @@ gulp.task("rspack-prod-gallery", () =>
|
||||
createGalleryConfig({
|
||||
isProdBuild: true,
|
||||
latestBuild: true,
|
||||
isTestBuild: env.isTestBuild(),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
const { existsSync } = require("fs");
|
||||
const fs = require("fs");
|
||||
|
||||
const { existsSync } = fs;
|
||||
const path = require("path");
|
||||
const rspack = require("@rspack/core");
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
@@ -16,6 +18,61 @@ const SafeWebpackBar = require("./safe-webpackbar.cjs");
|
||||
const paths = require("./paths.cjs");
|
||||
const bundle = require("./bundle.cjs");
|
||||
|
||||
// Build-toolchain packages whose version changes the emitted bytes but which
|
||||
// are loader/compiler machinery, not modules in the build graph — so rspack's
|
||||
// node_modules snapshot cannot see them. Their versions are folded into the
|
||||
// persistent cache `version` so a toolchain upgrade invalidates the cache,
|
||||
// while ordinary runtime-dependency bumps (handled by the snapshot) do not.
|
||||
const TOOLCHAIN_PACKAGES = [
|
||||
"@rspack/core",
|
||||
"@babel/core",
|
||||
"@babel/preset-env",
|
||||
"babel-plugin-polyfill-corejs3",
|
||||
"@babel/plugin-transform-runtime",
|
||||
"@babel/plugin-transform-class-properties",
|
||||
"@babel/plugin-transform-private-methods",
|
||||
"@babel/runtime",
|
||||
"babel-loader",
|
||||
"core-js",
|
||||
"terser",
|
||||
"terser-webpack-plugin",
|
||||
"browserslist",
|
||||
"caniuse-lite",
|
||||
];
|
||||
|
||||
// Our own build logic — the config, loaders and babel plugins. Their contents
|
||||
// (not their paths) go into the cache version, so a change invalidates the
|
||||
// cache the same way `buildDependencies` would, but without tying validity to
|
||||
// absolute paths — rspack compares buildDependencies by path, which breaks a
|
||||
// cache reused on another machine/checkout (a different workspace path).
|
||||
const CONFIG_FILES = [
|
||||
__filename,
|
||||
path.join(__dirname, "bundle.cjs"),
|
||||
path.join(__dirname, "minify-template-literals-loader.cjs"),
|
||||
path.join(__dirname, "lit-disable-dev-mode-loader.cjs"),
|
||||
path.join(__dirname, "babel-plugins", "custom-polyfill-plugin.js"),
|
||||
path.join(__dirname, "babel-plugins", "inline-constants-plugin.cjs"),
|
||||
];
|
||||
|
||||
// Content hash of the toolchain versions and our own build files, used as the
|
||||
// persistent cache `version`. Everything here is path-independent so the cache
|
||||
// stays valid when reused on a different machine or checkout path.
|
||||
const cacheVersion = () => {
|
||||
const parts = [
|
||||
...TOOLCHAIN_PACKAGES.map(
|
||||
(pkg) => `${pkg}@${require(`${pkg}/package.json`).version}`
|
||||
),
|
||||
...CONFIG_FILES.map(
|
||||
(file) => `${path.basename(file)}:${fs.readFileSync(file, "utf8")}`
|
||||
),
|
||||
];
|
||||
return require("crypto")
|
||||
.createHash("sha256")
|
||||
.update(parts.join("\n"))
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
};
|
||||
|
||||
class LogStartCompilePlugin {
|
||||
ignoredFirst = false;
|
||||
|
||||
@@ -376,6 +433,33 @@ const createRspackConfig = ({
|
||||
])
|
||||
),
|
||||
},
|
||||
// Persistent filesystem cache for production builds, opt-in per environment
|
||||
// via RSPACK_CACHE ("readwrite" writes it, "readonly" only reads a warm
|
||||
// cache — e.g. CI reusing the nightly-written one). Unset (releases, local,
|
||||
// tests) = no cache.
|
||||
...(isProdBuild && process.env.RSPACK_CACHE
|
||||
? {
|
||||
cache: {
|
||||
type: "persistent",
|
||||
// `name` is already unique per variant (frontend-modern/-legacy).
|
||||
name,
|
||||
// Content-based version (node major + toolchain versions + our own
|
||||
// build files). Everything is path-independent, so the cache stays
|
||||
// valid when reused on another machine/checkout. Runtime deps are
|
||||
// deliberately absent — rspack's node_modules snapshot invalidates
|
||||
// their modules per-package, so a single unrelated bump keeps the
|
||||
// rest warm. buildDependencies is intentionally not used: rspack
|
||||
// compares it by absolute path, which breaks cross-machine reuse.
|
||||
version: `node${process.versions.node.split(".")[0]}-${cacheVersion()}`,
|
||||
storage: {
|
||||
type: "filesystem",
|
||||
directory: path.resolve(paths.root_dir, ".rspack-cache"),
|
||||
},
|
||||
// CI reads the nightly-written cache but must not modify it.
|
||||
readonly: process.env.RSPACK_CACHE === "readonly",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
experiments: {
|
||||
outputModule: true,
|
||||
},
|
||||
@@ -405,8 +489,10 @@ const createDemoConfig = ({
|
||||
const createCastConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.cast({ isProdBuild, latestBuild }));
|
||||
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.gallery({ isProdBuild, latestBuild }));
|
||||
const createGalleryConfig = ({ isProdBuild, latestBuild, isTestBuild }) =>
|
||||
createRspackConfig(
|
||||
bundle.config.gallery({ isProdBuild, latestBuild, isTestBuild })
|
||||
);
|
||||
|
||||
const createLandingPageConfig = ({ isProdBuild, latestBuild }) =>
|
||||
createRspackConfig(bundle.config.landingPage({ isProdBuild, latestBuild }));
|
||||
|
||||
@@ -14,6 +14,7 @@ const baseDevice = {
|
||||
name_by_user: null,
|
||||
disabled_by: null,
|
||||
configuration_url: null,
|
||||
parent_device_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
};
|
||||
@@ -50,4 +51,39 @@ export const demoDevices: DeviceRegistryEntry[] = [
|
||||
primary_config_entry: "mock-sonos",
|
||||
entry_type: null,
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip",
|
||||
name: "Power strip",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
},
|
||||
// Child devices (logical parts of the power strip). They carry the parent's
|
||||
// inherited hardware fields, mirroring how resolveChildDevices fills them in
|
||||
// from the WebSocket, and reference the parent via parent_device_id.
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-1",
|
||||
name: "Outlet 1",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
{
|
||||
...baseDevice,
|
||||
id: "power-strip-outlet-2",
|
||||
name: "Outlet 2",
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
config_entries: ["mock-hue"],
|
||||
primary_config_entry: "mock-hue",
|
||||
entry_type: null,
|
||||
parent_device_id: "power-strip",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -102,6 +102,11 @@ export const mockEnergy = (hass: MockHomeAssistant) => {
|
||||
cost_sensors: {},
|
||||
solar_forecast_domains: [],
|
||||
}));
|
||||
hass.mockWS("energy/validate", () => ({
|
||||
energy_sources: Array.from({ length: 6 }, () => []),
|
||||
device_consumption: Array.from({ length: 6 }, () => []),
|
||||
device_consumption_water: Array.from({ length: 2 }, () => []),
|
||||
}));
|
||||
hass.mockWS(
|
||||
"energy/fossil_energy_consumption",
|
||||
({ period }): FossilEnergyConsumption => ({
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
export const mockSensor = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS(
|
||||
"sensor/device_class_convertible_units",
|
||||
({ device_class }: { device_class: string }) => ({
|
||||
units: device_class === "energy" ? ["kWh"] : ["W"],
|
||||
})
|
||||
);
|
||||
hass.mockWS("sensor/numeric_device_classes", () => ({
|
||||
numeric_device_classes: [
|
||||
"volume_storage",
|
||||
|
||||
@@ -87,6 +87,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -111,6 +112,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
@@ -135,6 +137,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -29,6 +29,50 @@ const positions: Position[] = ["start", "end"];
|
||||
const selectedStates = [false, true];
|
||||
const disabledStates = [false, true];
|
||||
|
||||
interface TreeChild {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface TreeGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
children: TreeChild[];
|
||||
}
|
||||
|
||||
const treeGroups: TreeGroup[] = [
|
||||
{
|
||||
key: "binary_sensor",
|
||||
label: "Binary sensor",
|
||||
children: [
|
||||
{ key: "door", label: "Door" },
|
||||
{ key: "motion", label: "Motion" },
|
||||
{ key: "window", label: "Window" },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "cover",
|
||||
label: "Cover",
|
||||
children: [
|
||||
{ key: "garage", label: "Garage" },
|
||||
{ key: "shutter", label: "Shutter" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface TreeRow {
|
||||
group: TreeGroup;
|
||||
child?: TreeChild;
|
||||
}
|
||||
|
||||
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
|
||||
{ group },
|
||||
...group.children.map((child) => ({ group, child })),
|
||||
]);
|
||||
|
||||
const treeKey = (group: TreeGroup, child: TreeChild) =>
|
||||
`${group.key}/${child.key}`;
|
||||
|
||||
@customElement("demo-components-ha-list")
|
||||
export class DemoHaList extends LitElement {
|
||||
@state() private _buttonClicks = 0;
|
||||
@@ -41,6 +85,8 @@ export class DemoHaList extends LitElement {
|
||||
|
||||
@state() private _multiCheckEnd: number | Set<number> = new Set();
|
||||
|
||||
@state() private _tree = new Set<string>();
|
||||
|
||||
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
|
||||
|
||||
protected render(): TemplateResult {
|
||||
@@ -274,6 +320,45 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
|
||||
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Controlled selection with indeterminate groups">
|
||||
<ha-list-selectable
|
||||
multi
|
||||
controlled
|
||||
aria-label="Controlled tree"
|
||||
@ha-list-item-selected=${this._onTreeToggle}
|
||||
@ha-list-item-deselected=${this._onTreeToggle}
|
||||
>
|
||||
${treeGroups.map((group) => {
|
||||
const groupState = this._groupState(group);
|
||||
return html`
|
||||
<ha-list-item-option
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${group.key}
|
||||
?selected=${groupState === "all"}
|
||||
?indeterminate=${groupState === "some"}
|
||||
>
|
||||
<span slot="headline">${group.label}</span>
|
||||
</ha-list-item-option>
|
||||
${group.children.map(
|
||||
(child) => html`
|
||||
<ha-list-item-option
|
||||
class="child"
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${treeKey(group, child)}
|
||||
?selected=${this._tree.has(treeKey(group, child))}
|
||||
>
|
||||
<span slot="headline">${child.label}</span>
|
||||
</ha-list-item-option>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
})}
|
||||
</ha-list-selectable>
|
||||
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
|
||||
</ha-card>
|
||||
|
||||
<ha-card header="Option: all combinations">
|
||||
<div class="grid">
|
||||
${appearances.map((appearance) =>
|
||||
@@ -361,6 +446,43 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
return next;
|
||||
}
|
||||
|
||||
private _groupState(group: TreeGroup): "none" | "some" | "all" {
|
||||
const selected = group.children.filter((child) =>
|
||||
this._tree.has(treeKey(group, child))
|
||||
).length;
|
||||
if (selected === 0) {
|
||||
return "none";
|
||||
}
|
||||
return selected === group.children.length ? "all" : "some";
|
||||
}
|
||||
|
||||
private _onTreeToggle = (ev: CustomEvent<number>) => {
|
||||
const row = treeRows[ev.detail];
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const next = new Set(this._tree);
|
||||
if (row.child) {
|
||||
const key = treeKey(row.group, row.child);
|
||||
if (next.has(key)) {
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
} else {
|
||||
const select = this._groupState(row.group) !== "all";
|
||||
row.group.children.forEach((child) => {
|
||||
const key = treeKey(row.group, child);
|
||||
if (select) {
|
||||
next.add(key);
|
||||
} else {
|
||||
next.delete(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
this._tree = next;
|
||||
};
|
||||
|
||||
private _onSingle = (ev: CustomEvent<number>) => {
|
||||
this._single = ev.detail;
|
||||
};
|
||||
@@ -443,6 +565,9 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
.child::part(base) {
|
||||
padding-inline-start: var(--ha-space-12);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
@@ -9,7 +8,6 @@ import { mockHassioSupervisor } from "../../../../demo/src/stubs/hassio_supervis
|
||||
import type { HASSDomEvent } from "../../../../src/common/dom/fire_event";
|
||||
import "../../../../src/components/ha-selector/ha-selector";
|
||||
import "../../../../src/components/ha-settings-row";
|
||||
import "../../../../src/components/ha-target-picker";
|
||||
import type { AreaRegistryEntry } from "../../../../src/data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../../src/data/entity/entity_registry";
|
||||
@@ -50,6 +48,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -74,6 +73,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -153,9 +153,6 @@ interface Sample {
|
||||
description: string;
|
||||
selector: Selector;
|
||||
value: unknown;
|
||||
// Render ha-target-picker directly in compact (chip) mode instead of the
|
||||
// ha-selector, which does not expose the compact option.
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const SAMPLES: Sample[] = [
|
||||
@@ -166,14 +163,6 @@ const SAMPLES: Sample[] = [
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
},
|
||||
{
|
||||
name: "Target (compact)",
|
||||
description:
|
||||
"In compact mode the replaced reference is shown as a warning chip.",
|
||||
selector: { target: {} },
|
||||
value: { device_id: ["old_composite"] },
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
name: "Device (unfiltered, multiple matches)",
|
||||
description:
|
||||
@@ -272,23 +261,13 @@ class DemoHaSelectorReplacedDevice
|
||||
<ha-settings-row narrow slot=${slot}>
|
||||
<span slot="heading">${sample.name}</span>
|
||||
<span slot="description">${sample.description}</span>
|
||||
${
|
||||
sample.compact
|
||||
? html`<ha-target-picker
|
||||
compact
|
||||
.hass=${this.hass}
|
||||
.value=${this._values[idx] as HassServiceTarget}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-target-picker>`
|
||||
: html`<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>`
|
||||
}
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${sample.selector}
|
||||
.value=${this._values[idx]}
|
||||
.sampleIdx=${idx}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
></ha-selector>
|
||||
</ha-settings-row>
|
||||
`
|
||||
)}
|
||||
|
||||
@@ -100,6 +100,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "backyard",
|
||||
@@ -124,6 +125,7 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
@@ -148,6 +150,85 @@ const DEVICES: DeviceRegistryEntry[] = [
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
{
|
||||
area_id: "livingroom",
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_power_strip",
|
||||
identifiers: [["demo", "strip1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Power strip",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
// Child devices of the power strip. They have no area of their own and
|
||||
// inherit the parent's area ("Livingroom"); the picker renders them indented
|
||||
// under the parent with a tree connector.
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_1",
|
||||
identifiers: [["demo", "outlet1"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 1",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
{
|
||||
area_id: null,
|
||||
configuration_url: null,
|
||||
config_entries: ["config_entry_1"],
|
||||
config_entries_subentries: {},
|
||||
connections: [],
|
||||
disabled_by: null,
|
||||
entry_type: null,
|
||||
id: "device_outlet_2",
|
||||
identifiers: [["demo", "outlet2"] as [string, string]],
|
||||
manufacturer: "Acme",
|
||||
model: "Smart Power Strip",
|
||||
model_id: null,
|
||||
name_by_user: null,
|
||||
name: "Outlet 2",
|
||||
sw_version: null,
|
||||
hw_version: null,
|
||||
via_device_id: null,
|
||||
serial_number: null,
|
||||
labels: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: "device_power_strip",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -132,12 +132,12 @@ const ENTITIES = [
|
||||
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
|
||||
preset_modes: ["home", "eco", "away"],
|
||||
swing_modes: ["auto", "1", "2", "3", "off"],
|
||||
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
current_temperature: 23,
|
||||
target_temp_high: 24,
|
||||
target_temp_low: 21,
|
||||
fan_mode: "auto_low",
|
||||
preset_mode: "home",
|
||||
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
|
||||
swing_mode: "auto",
|
||||
swing_horizontal_mode: "off",
|
||||
supported_features:
|
||||
@@ -340,6 +340,84 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-oscillate" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: one feature",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: two features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: three features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: four features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Inline features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "inline",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Bottom features: five features",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "climate.dual_thermostat",
|
||||
features_position: "bottom",
|
||||
features: [
|
||||
{ type: "climate-hvac-modes", style: "dropdown" },
|
||||
{ type: "climate-preset-modes", style: "dropdown" },
|
||||
{ type: "climate-fan-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-modes", style: "dropdown" },
|
||||
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
|
||||
@@ -238,6 +238,7 @@ const createDeviceRegistryEntries = (
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
primary_config_entry: null,
|
||||
parent_device_id: null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ const ALL_FEATURES =
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
@@ -28,8 +27,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
@@ -41,8 +38,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
@@ -58,10 +53,7 @@ const ENTITIES = [
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
@@ -96,8 +88,6 @@ const ENTITIES = [
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
|
||||
+7
-8
@@ -89,7 +89,7 @@
|
||||
"@vvo/tzdb": "6.198.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"barcode-detector": "3.2.1",
|
||||
"barcode-detector": "3.2.2",
|
||||
"cally": "0.9.2",
|
||||
"color-name": "2.1.1",
|
||||
"comlink": "4.4.2",
|
||||
@@ -103,11 +103,11 @@
|
||||
"echarts": "6.1.0",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.6.17",
|
||||
"hls.js": "1.7.0",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.13",
|
||||
"js-yaml": "5.2.3",
|
||||
"js-yaml": "5.3.0",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -151,8 +151,8 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.1",
|
||||
"@rspack/core": "2.1.8",
|
||||
"@rsdoctor/rspack-plugin": "1.6.2",
|
||||
"@rspack/core": "2.1.10",
|
||||
"@rspack/dev-server": "2.2.0",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -186,7 +186,7 @@
|
||||
"fs-extra": "11.4.0",
|
||||
"generate-license-file": "4.2.1",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.9.0",
|
||||
"globals": "17.11.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.1.0",
|
||||
@@ -210,7 +210,7 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.66.0",
|
||||
"typescript-eslint": "8.67.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.10",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -223,7 +223,6 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"globals": "17.9.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"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import type {
|
||||
ReactiveController,
|
||||
ReactiveControllerHost,
|
||||
} from "@lit/reactive-element/reactive-controller";
|
||||
import { css, type LitElement } from "lit";
|
||||
import type { Ref } from "lit/directives/ref";
|
||||
import { parseAnimationDuration } from "../util/parse-animation-duration";
|
||||
|
||||
type FilterPanelHost = ReactiveControllerHost &
|
||||
LitElement & { expanded: boolean };
|
||||
|
||||
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
|
||||
|
||||
/**
|
||||
* Layout the controller relies on: the filter is a flex column made of its
|
||||
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
|
||||
* tall as its header; expanded, it fills what is left of the pane and hands
|
||||
* that space down to the list through the wrapper.
|
||||
*/
|
||||
export const filterPanelStyles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
flex: none;
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
ha-expansion-panel::part(summary) {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const panels = new Set<FilterPanelController>();
|
||||
|
||||
let pending: Set<FilterPanelController> | undefined;
|
||||
|
||||
const flush = () => {
|
||||
const batch = [...pending!].filter((panel) => panel.host.isConnected);
|
||||
pending = undefined;
|
||||
batch.forEach((panel) => panel.prepare());
|
||||
batch.forEach((panel) => panel.measure());
|
||||
batch.forEach((panel) => panel.play());
|
||||
};
|
||||
|
||||
// Filters that change in the same frame (one closing while another opens) are
|
||||
// animated as one batch: every filter is measured before any of them has
|
||||
// changed the DOM, and the closing ones are parked at their final height so
|
||||
// the opening one reads its own final height from the real layout.
|
||||
const enqueue = (panel: FilterPanelController) => {
|
||||
if (!pending) {
|
||||
pending = new Set();
|
||||
panels.forEach((other) => other.snapshot());
|
||||
requestAnimationFrame(flush);
|
||||
}
|
||||
pending.add(panel);
|
||||
};
|
||||
|
||||
const clearInlineStyles = (element?: HTMLElement) => {
|
||||
element?.style.removeProperty("height");
|
||||
element?.style.removeProperty("flex");
|
||||
element?.style.removeProperty("overflow");
|
||||
};
|
||||
|
||||
/**
|
||||
* Animates a filter of the filter pane between its collapsed and expanded
|
||||
* heights whenever `expanded` changes, and tells the host when to render its
|
||||
* content: from the moment it expands until its collapse animation has ended.
|
||||
*
|
||||
* During the animation the content keeps its final size and the host clips
|
||||
* it, so the list is revealed rather than resized.
|
||||
*/
|
||||
export class FilterPanelController implements ReactiveController {
|
||||
public showContent = false;
|
||||
|
||||
public host: FilterPanelHost;
|
||||
|
||||
private _content: Ref<HTMLElement>;
|
||||
|
||||
private _expanded?: boolean;
|
||||
|
||||
private _first = 0;
|
||||
|
||||
private _last = 0;
|
||||
|
||||
private _contentHeight = 0;
|
||||
|
||||
private _animation?: Animation;
|
||||
|
||||
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
|
||||
this.host = host;
|
||||
this._content = content;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
public hostConnected() {
|
||||
panels.add(this);
|
||||
}
|
||||
|
||||
public hostDisconnected() {
|
||||
panels.delete(this);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
}
|
||||
|
||||
public hostUpdate() {
|
||||
const expanded = this.host.expanded;
|
||||
if (this._expanded === undefined) {
|
||||
this._expanded = expanded;
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded === this._expanded) {
|
||||
return;
|
||||
}
|
||||
this._expanded = expanded;
|
||||
if (!this.host.isConnected) {
|
||||
this.showContent = expanded;
|
||||
return;
|
||||
}
|
||||
if (expanded) {
|
||||
this.showContent = true;
|
||||
}
|
||||
enqueue(this);
|
||||
}
|
||||
|
||||
public snapshot() {
|
||||
this._first = this.host.getBoundingClientRect().height;
|
||||
}
|
||||
|
||||
public prepare() {
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
const host = this.host;
|
||||
const content = this._content.value;
|
||||
clearInlineStyles(host);
|
||||
clearInlineStyles(content);
|
||||
if (host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last =
|
||||
host.getBoundingClientRect().height -
|
||||
(content?.getBoundingClientRect().height ?? 0);
|
||||
this._contentHeight = this._first - this._last;
|
||||
host.style.flex = "none";
|
||||
host.style.height = `${this._last}px`;
|
||||
}
|
||||
|
||||
public measure() {
|
||||
if (!this.host.expanded) {
|
||||
return;
|
||||
}
|
||||
this._last = this.host.getBoundingClientRect().height;
|
||||
this._contentHeight =
|
||||
this._content.value?.getBoundingClientRect().height ?? 0;
|
||||
}
|
||||
|
||||
public play() {
|
||||
const host = this.host;
|
||||
if (this._first === this._last) {
|
||||
this._finish();
|
||||
return;
|
||||
}
|
||||
const content = this._content.value;
|
||||
host.style.flex = "none";
|
||||
host.style.overflow = "hidden";
|
||||
if (content) {
|
||||
content.style.flex = "none";
|
||||
content.style.height = `${this._contentHeight}px`;
|
||||
}
|
||||
const animation = host.animate(
|
||||
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
|
||||
{
|
||||
duration:
|
||||
parseAnimationDuration(
|
||||
getComputedStyle(host).getPropertyValue(
|
||||
"--ha-animation-duration-normal"
|
||||
)
|
||||
) || 250,
|
||||
easing: EASING,
|
||||
fill: "forwards",
|
||||
}
|
||||
);
|
||||
animation.onfinish = () => this._finish(animation);
|
||||
this._animation = animation;
|
||||
}
|
||||
|
||||
private async _finish(animation?: Animation) {
|
||||
if (!this.host.expanded) {
|
||||
this.showContent = false;
|
||||
this.host.requestUpdate();
|
||||
await this.host.updateComplete;
|
||||
}
|
||||
if (this._animation !== animation) {
|
||||
return;
|
||||
}
|
||||
clearInlineStyles(this.host);
|
||||
clearInlineStyles(this._content.value);
|
||||
this._animation?.cancel();
|
||||
this._animation = undefined;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,25 @@ const formatTimeWithSecondsMem = memoizeOne(
|
||||
})
|
||||
);
|
||||
|
||||
// 9:15:24.123 PM || 21:15:24,123
|
||||
export const formatTimeWithMilliseconds = (
|
||||
dateObj: Date,
|
||||
locale: FrontendLocaleData,
|
||||
config: HassConfig
|
||||
) => formatTimeWithMillisecondsMem(locale, config.time_zone).format(dateObj);
|
||||
|
||||
const formatTimeWithMillisecondsMem = memoizeOne(
|
||||
(locale: FrontendLocaleData, serverTimeZone: string) =>
|
||||
new Intl.DateTimeFormat(locale.language, {
|
||||
hour: useAmPm(locale) ? "numeric" : "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
fractionalSecondDigits: 3,
|
||||
hourCycle: useAmPm(locale) ? "h12" : "h23",
|
||||
timeZone: resolveTimeZone(locale.time_zone, serverTimeZone),
|
||||
})
|
||||
);
|
||||
|
||||
// Tuesday 7:00 PM || Tuesday 19:00
|
||||
export const formatTimeWeekday = (
|
||||
dateObj: Date,
|
||||
|
||||
@@ -2,10 +2,31 @@ import type { AreaRegistryEntry } from "../../../data/area/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device/device_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
/**
|
||||
* Return the effective area id of a device: a child device without an area of
|
||||
* its own inherits its parent's area (mirrors core's
|
||||
* async_get_effective_area_id). Nesting is single-level, so no recursion.
|
||||
*/
|
||||
export const getDeviceAreaId = (
|
||||
device: DeviceRegistryEntry,
|
||||
devices: HomeAssistant["devices"]
|
||||
): string | undefined => {
|
||||
if (device.area_id) {
|
||||
return device.area_id;
|
||||
}
|
||||
if (device.parent_device_id) {
|
||||
return devices[device.parent_device_id]?.area_id ?? undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const getDeviceArea = (
|
||||
device: DeviceRegistryEntry,
|
||||
areas: HomeAssistant["areas"]
|
||||
areas: HomeAssistant["areas"],
|
||||
// Required so every caller resolves a child device's effective area
|
||||
// consistently, see getDeviceAreaId.
|
||||
devices: HomeAssistant["devices"]
|
||||
): AreaRegistryEntry | undefined => {
|
||||
const areaId = device.area_id;
|
||||
const areaId = getDeviceAreaId(device, devices);
|
||||
return areaId ? areas[areaId] : undefined;
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import type { FloorRegistryEntry } from "../../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { getDeviceAreaId } from "./get_device_context";
|
||||
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
@@ -46,7 +47,11 @@ export const getEntityAreaId = (
|
||||
if (!entry) return undefined;
|
||||
const deviceId = entry.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
return entry.area_id || device?.area_id || undefined;
|
||||
return (
|
||||
entry.area_id ||
|
||||
(device ? getDeviceAreaId(device, devices) : undefined) ||
|
||||
undefined
|
||||
);
|
||||
};
|
||||
|
||||
export const getEntityEntryContext = (
|
||||
@@ -60,7 +65,8 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const areaId = entry?.area_id || device?.area_id;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? floors[floorId] : undefined;
|
||||
|
||||
@@ -25,6 +25,7 @@ export type LocalizeKeys =
|
||||
| `ui.dialogs.unsupported.reasons.${string}`
|
||||
| `ui.panel.config.${string}.${"caption" | "description"}`
|
||||
| `ui.panel.config.dashboard.${string}`
|
||||
| `ui.panel.config.mqtt.${string}`
|
||||
| `ui.panel.config.storage.segments.${string}`
|
||||
| `ui.panel.config.zha.${string}`
|
||||
| `ui.panel.config.zwave_js.${string}`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { deepEqual } from "../util/deep-equal";
|
||||
import {
|
||||
createQueryString,
|
||||
decodeQueryParams,
|
||||
@@ -35,6 +36,15 @@ export const historyLogbookTargetFromQueryParams = (
|
||||
): HassServiceTarget | undefined =>
|
||||
serviceTargetFromQueryParams(params, historyLogbookTargetParamKeys);
|
||||
|
||||
export const historyLogbookTargetsEqual = (
|
||||
a: HassServiceTarget,
|
||||
b: HassServiceTarget
|
||||
): boolean =>
|
||||
deepEqual(
|
||||
queryParamsFromServiceTarget(a, historyLogbookTargetParamKeys),
|
||||
queryParamsFromServiceTarget(b, historyLogbookTargetParamKeys)
|
||||
);
|
||||
|
||||
export const createHistoryLogbookUrl = (
|
||||
path: string,
|
||||
target: HassServiceTarget,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
isMoreInfoView,
|
||||
type MoreInfoView,
|
||||
} from "../../dialogs/more-info/more-info-view";
|
||||
import type { SearchParamsSource } from "./query-params";
|
||||
|
||||
const ENTITY_ID_PARAM = "more-info-entity-id";
|
||||
const VIEW_PARAM = "more-info-view";
|
||||
|
||||
export interface MoreInfoUrlData {
|
||||
entityId?: string;
|
||||
view?: MoreInfoView;
|
||||
hash: URLSearchParams;
|
||||
}
|
||||
|
||||
export interface CreateMoreInfoUrlData {
|
||||
entityId: string;
|
||||
view: MoreInfoView;
|
||||
hash?: URLSearchParams;
|
||||
}
|
||||
|
||||
export const decodeMoreInfoUrl = (
|
||||
search: SearchParamsSource,
|
||||
hash = ""
|
||||
): MoreInfoUrlData => {
|
||||
const params =
|
||||
typeof search === "string"
|
||||
? new URLSearchParams(search)
|
||||
: search instanceof URLSearchParams
|
||||
? search
|
||||
: new URLSearchParams(search);
|
||||
const entityId = params.get(ENTITY_ID_PARAM) || undefined;
|
||||
const view = params.get(VIEW_PARAM) || undefined;
|
||||
|
||||
return {
|
||||
entityId,
|
||||
view: isMoreInfoView(view) ? view : undefined,
|
||||
hash: new URLSearchParams(
|
||||
__DEMO__ ? "" : hash.startsWith("#") ? hash.substring(1) : hash
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const createMoreInfoUrl = (
|
||||
base: string,
|
||||
data: CreateMoreInfoUrlData
|
||||
): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.set(ENTITY_ID_PARAM, data.entityId);
|
||||
url.searchParams.set(VIEW_PARAM, data.view);
|
||||
if (!__DEMO__ && data.hash !== undefined) {
|
||||
url.hash = data.hash.toString();
|
||||
}
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
|
||||
export const removeMoreInfoUrl = (base: string): string => {
|
||||
const url = new URL(base, window.location.origin);
|
||||
url.searchParams.delete(ENTITY_ID_PARAM);
|
||||
url.searchParams.delete(VIEW_PARAM);
|
||||
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ResizeController } from "@lit-labs/observers/resize-controller";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -22,6 +23,10 @@ import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
export class StateHistoryChartTimeline extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -38,6 +43,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw each row's name above its bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -60,6 +69,13 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
|
||||
@state() private _yWidth = 0;
|
||||
|
||||
private _width = 0;
|
||||
|
||||
private _resize = new ResizeController(this, {
|
||||
skipInitial: true,
|
||||
callback: (entries) => entries[0]?.contentRect.width,
|
||||
});
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
protected render() {
|
||||
@@ -67,7 +83,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${this.data.length * 30 + 30}px`}
|
||||
.height=${`${this.data.length * (this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) + GRID_BOTTOM}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -177,13 +193,19 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
this._generateData();
|
||||
}
|
||||
|
||||
const width = this.insideLabels ? Math.round(this._resize.value ?? 0) : 0;
|
||||
const widthChanged = width !== this._width;
|
||||
this._width = width;
|
||||
|
||||
if (
|
||||
!this.hasUpdated ||
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
changedProps.has("_yWidth") ||
|
||||
widthChanged
|
||||
) {
|
||||
this._createOptions();
|
||||
}
|
||||
@@ -193,14 +215,22 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
// Keeps the plot aligned with the line charts sharing the y-axis padding.
|
||||
const plotPadding = insideLabels ? this.paddingYAxis : labelWidth;
|
||||
// A zero width hides the labels instead of truncating them.
|
||||
const insideLabelWidth = this._width
|
||||
? Math.max(0, this._width - plotPadding - labelMargin)
|
||||
: undefined;
|
||||
this._chartOptions = {
|
||||
xAxis: {
|
||||
type: "time",
|
||||
@@ -224,37 +254,52 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [0, rtl ? 2 : 0, 14, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "bottom",
|
||||
width: insideLabelWidth,
|
||||
overflow: "truncate",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
bottom: 30,
|
||||
left: rtl ? 1 : labelWidth,
|
||||
right: rtl ? labelWidth : 1,
|
||||
top: insideLabels ? 20 : 10,
|
||||
bottom: GRID_BOTTOM,
|
||||
left: rtl ? 1 : plotPadding,
|
||||
right: rtl ? plotPadding : 1,
|
||||
},
|
||||
tooltip: {
|
||||
renderMode: "html",
|
||||
@@ -398,6 +443,10 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
/** Draw timeline row names above their bar instead of in a label column. */
|
||||
@property({ attribute: "inside-labels", type: Boolean, reflect: true })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -227,6 +231,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -424,6 +429,12 @@ export class StateHistoryCharts extends LitElement {
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
/* Names inside the plot sit close to the chart above them, so the groups
|
||||
need more room between them to stay apart. */
|
||||
:host([inside-labels]) .entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.entry-container:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export class HaDataTable extends LitElement {
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
@property({ type: Object }) public columns: DataTableColumnContainer = {};
|
||||
|
||||
@@ -1158,6 +1158,11 @@ export class HaDataTable extends LitElement {
|
||||
/* default mdc styles, colors changed, without checkbox styles */
|
||||
:host {
|
||||
height: 100%;
|
||||
--_cell-padding-inline: 16px;
|
||||
}
|
||||
|
||||
:host([narrow]) {
|
||||
--_cell-padding-inline: 8px;
|
||||
}
|
||||
.mdc-data-table__content {
|
||||
font-family: var(--ha-font-family-body);
|
||||
@@ -1238,8 +1243,7 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__cell,
|
||||
.mdc-data-table__header-cell {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
padding-inline: var(--_cell-padding-inline);
|
||||
min-width: 150px;
|
||||
align-self: center;
|
||||
overflow: hidden;
|
||||
@@ -1259,14 +1263,8 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
.mdc-data-table__header-cell--checkbox,
|
||||
.mdc-data-table__cell--checkbox {
|
||||
/* @noflip */
|
||||
padding-left: 16px;
|
||||
/* @noflip */
|
||||
padding-right: 0;
|
||||
/* @noflip */
|
||||
padding-inline-start: 16px;
|
||||
/* @noflip */
|
||||
padding-inline-end: initial;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: 0;
|
||||
width: 60px;
|
||||
min-width: 60px;
|
||||
}
|
||||
@@ -1379,8 +1377,7 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:first-child,
|
||||
.mdc-data-table__header-cell--icon-button:first-child,
|
||||
.mdc-data-table__cell--icon-button:first-child {
|
||||
padding-left: 16px;
|
||||
padding-inline-start: 16px;
|
||||
padding-inline-start: var(--_cell-padding-inline);
|
||||
padding-inline-end: initial;
|
||||
}
|
||||
|
||||
@@ -1388,8 +1385,7 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__header-cell--overflow-menu:last-child,
|
||||
.mdc-data-table__header-cell--icon-button:last-child,
|
||||
.mdc-data-table__cell--icon-button:last-child {
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
padding-inline-end: var(--_cell-padding-inline);
|
||||
padding-inline-start: initial;
|
||||
}
|
||||
.mdc-data-table__cell--overflow-menu,
|
||||
@@ -1516,11 +1512,17 @@ export class HaDataTable extends LitElement {
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
.primary {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.secondary {
|
||||
color: var(--secondary-text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.scroller {
|
||||
height: calc(100% - 57px);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import { css, html } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../chips/ha-assist-chip";
|
||||
import "../ha-icon-button-next";
|
||||
import "../ha-icon-button-prev";
|
||||
import "../ha-svg-icon";
|
||||
import {
|
||||
haDateRangePickerStyles,
|
||||
HaDateRangePicker,
|
||||
} from "./ha-date-range-picker";
|
||||
|
||||
/**
|
||||
* Date range picker as a single pill that also steps through ranges: a
|
||||
* previous button, the selected range and a next button. Meant for a toolbar,
|
||||
* next to other chips.
|
||||
*/
|
||||
@customElement("ha-date-range-nav")
|
||||
export class HaDateRangeNav extends HaDateRangePicker {
|
||||
protected override _renderField() {
|
||||
return html`
|
||||
<ha-icon-button-prev
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handlePrev}
|
||||
></ha-icon-button-prev>
|
||||
<ha-assist-chip
|
||||
id="field"
|
||||
class="range"
|
||||
.label=${this._formatRange(" – ")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiCalendar}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
<ha-icon-button-next
|
||||
class="step"
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._handleNext}
|
||||
></ha-icon-button-next>
|
||||
`;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
haDateRangePickerStyles,
|
||||
css`
|
||||
/* The three controls read as one pill, with the range chip's borders as
|
||||
the dividers between them. */
|
||||
.date-range-inputs {
|
||||
gap: 0;
|
||||
border: 1px solid var(--outline-color);
|
||||
border-radius: var(--ha-assist-chip-container-shape, 10px);
|
||||
background: var(--ha-assist-chip-container-color, transparent);
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.step {
|
||||
--ha-icon-button-size: 32px;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
|
||||
.range {
|
||||
--md-assist-chip-outline-color: transparent;
|
||||
--ha-assist-chip-container-shape: 0;
|
||||
--ha-assist-chip-container-color: transparent;
|
||||
border-inline: 1px solid var(--divider-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-date-range-nav": HaDateRangeNav;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiCalendar } from "@mdi/js";
|
||||
import "cally";
|
||||
import { isThisYear } from "date-fns";
|
||||
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
@@ -11,10 +10,7 @@ import { tinykeys } from "tinykeys";
|
||||
import { shiftDateRange } from "../../common/datetime/calc_date";
|
||||
import type { DateRange } from "../../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../../common/datetime/calc_date_range";
|
||||
import {
|
||||
formatShortDateTime,
|
||||
formatShortDateTimeWithYear,
|
||||
} from "../../common/datetime/format_date_time";
|
||||
import { formatShortDateTimeWithConditionalYear } from "../../common/datetime/format_date_time";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { configContext, internationalizationContext } from "../../data/context";
|
||||
@@ -42,18 +38,67 @@ const EXTENDED_RANGE_KEYS: DateRange[] = [
|
||||
"now-30d",
|
||||
];
|
||||
|
||||
export const haDateRangePickerStyles = css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(--safe-area-inset-right) - var(
|
||||
--ha-space-8
|
||||
)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`;
|
||||
|
||||
@customElement("ha-date-range-picker")
|
||||
export class HaDateRangePicker extends LitElement {
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
protected _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
@transform<HomeAssistantConfig, HassConfig>({
|
||||
transformer: ({ config }) => config,
|
||||
})
|
||||
private _hassConfig!: HassConfig;
|
||||
protected _hassConfig!: HassConfig;
|
||||
|
||||
@property({ attribute: false }) public startDate!: Date;
|
||||
|
||||
@@ -143,73 +188,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="date-range-inputs">
|
||||
${
|
||||
!this.minimal
|
||||
? html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${
|
||||
(isThisYear(this.startDate)
|
||||
? formatShortDateTime(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.startDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)) +
|
||||
(window.innerWidth >= 459 ? " - " : " - \n") +
|
||||
(isThisYear(this.endDate)
|
||||
? formatShortDateTime(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
)
|
||||
: formatShortDateTimeWithYear(
|
||||
this.endDate,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
))
|
||||
}
|
||||
.label=${
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.start_date"
|
||||
) +
|
||||
" - " +
|
||||
this._i18n.localize(
|
||||
"ui.components.date-range-picker.end_date"
|
||||
)
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`
|
||||
: html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</div>
|
||||
<div class="date-range-inputs">${this._renderField()}</div>
|
||||
${
|
||||
this._pickerWrapperOpen || this._opened
|
||||
? this._openedNarrow
|
||||
@@ -248,6 +227,60 @@ export class HaDateRangePicker extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that opens the picker. It has to carry `id="field"`, which the
|
||||
* popover anchors to.
|
||||
*/
|
||||
protected _renderField() {
|
||||
if (this.minimal) {
|
||||
return html`<ha-icon-button
|
||||
@click=${this._openPicker}
|
||||
.disabled=${this.disabled}
|
||||
id="field"
|
||||
.label=${this._i18n.localize(
|
||||
"ui.components.date-range-picker.select_date_range"
|
||||
)}
|
||||
.path=${mdiCalendar}
|
||||
></ha-icon-button>`;
|
||||
}
|
||||
|
||||
return html`<ha-textarea
|
||||
id="field"
|
||||
rows="1"
|
||||
resize="auto"
|
||||
@click=${this._openPicker}
|
||||
@keydown=${this._handleKeydown}
|
||||
.value=${this._formatRange(window.innerWidth >= 459 ? " - " : " - \n")}
|
||||
.label=${
|
||||
this._i18n.localize("ui.components.date-range-picker.start_date") +
|
||||
" - " +
|
||||
this._i18n.localize("ui.components.date-range-picker.end_date")
|
||||
}
|
||||
.disabled=${this.disabled}
|
||||
readonly
|
||||
></ha-textarea>
|
||||
<ha-icon-button-prev
|
||||
.label=${this._i18n.localize("ui.common.previous")}
|
||||
@click=${this._handlePrev}
|
||||
>
|
||||
</ha-icon-button-prev>
|
||||
<ha-icon-button-next
|
||||
.label=${this._i18n.localize("ui.common.next")}
|
||||
@click=${this._handleNext}
|
||||
>
|
||||
</ha-icon-button-next>`;
|
||||
}
|
||||
|
||||
protected _formatRange(separator: string): string {
|
||||
const format = (date: Date) =>
|
||||
formatShortDateTimeWithConditionalYear(
|
||||
date,
|
||||
this._i18n.locale,
|
||||
this._hassConfig
|
||||
);
|
||||
return format(this.startDate) + separator + format(this.endDate);
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
if (!this._opened) {
|
||||
return nothing;
|
||||
@@ -303,12 +336,12 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._opened = false;
|
||||
};
|
||||
|
||||
private _handleNext(ev: MouseEvent): void {
|
||||
protected _handleNext(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(true);
|
||||
}
|
||||
|
||||
private _handlePrev(ev: MouseEvent): void {
|
||||
protected _handlePrev(ev: MouseEvent): void {
|
||||
if (ev && ev.stopPropagation) ev.stopPropagation();
|
||||
this._shift(false);
|
||||
}
|
||||
@@ -336,7 +369,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
this._pickerWrapperOpen = false;
|
||||
}
|
||||
|
||||
private _openPicker(ev?: Event) {
|
||||
protected _openPicker(ev?: Event) {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
@@ -352,7 +385,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleKeydown(ev: KeyboardEvent) {
|
||||
protected _handleKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._openPicker(ev);
|
||||
@@ -369,56 +402,7 @@ export class HaDateRangePicker extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
static styles = [
|
||||
css`
|
||||
ha-icon-button {
|
||||
direction: var(--direction);
|
||||
}
|
||||
|
||||
.date-range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
ha-textarea {
|
||||
display: inline-block;
|
||||
width: 340px;
|
||||
}
|
||||
@media only screen and (max-width: 460px) {
|
||||
ha-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
opacity: 0;
|
||||
transition: opacity var(--ha-animation-duration-normal) ease-out;
|
||||
}
|
||||
|
||||
wa-popover.open::part(dialog)::backdrop {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
:host(:not([backdrop])) wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
min-width: max(var(--body-width), 250px);
|
||||
max-width: calc(
|
||||
100vw - var(--safe-area-inset-left) - var(
|
||||
--safe-area-inset-right
|
||||
) - var(--ha-space-8)
|
||||
);
|
||||
overflow: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static styles = [haDateRangePickerStyles];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -8,6 +8,7 @@ export const datePickerStyles = css`
|
||||
}
|
||||
calendar-date::part(button),
|
||||
calendar-range::part(button) {
|
||||
color: var(--primary-text-color);
|
||||
border: none;
|
||||
background-color: unset;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
|
||||
@@ -73,7 +73,7 @@ export class DialogDeviceReplaced
|
||||
) =>
|
||||
candidates.map((deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
const area = device ? getDeviceArea(device, areas) : undefined;
|
||||
const area = device ? getDeviceArea(device, areas, devices) : undefined;
|
||||
const configEntry = device?.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
@@ -3,11 +3,13 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeDeviceName } from "../../common/entity/compute_device_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import {
|
||||
deviceComboBoxKeys,
|
||||
@@ -26,7 +28,9 @@ import "../ha-alert";
|
||||
import "../ha-button";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tree-indicator";
|
||||
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
@@ -128,6 +132,7 @@ export class HaDevicePicker extends LitElement {
|
||||
entityFilter,
|
||||
excludeDevices,
|
||||
value,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -216,6 +221,34 @@ export class HaDevicePicker extends LitElement {
|
||||
this.value
|
||||
);
|
||||
|
||||
// The fuzzy search ranks matches by relevance, which would pull a child device
|
||||
// above its parent (the parent often only matches through the lower-weighted
|
||||
// child names). Restore the nested order from the full item list and recompute
|
||||
// which child is last, so the tree connectors stay correct while searching.
|
||||
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
|
||||
_search,
|
||||
filteredItems,
|
||||
allItems
|
||||
) => {
|
||||
const matchedIds = new Set(filteredItems.map((item) => item.id));
|
||||
const ordered = allItems.filter((item) => matchedIds.has(item.id));
|
||||
// Keep any items the search added that are not part of the nested list
|
||||
// (for example the "no items available" placeholder or additional items).
|
||||
const orderedIds = new Set(ordered.map((item) => item.id));
|
||||
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
|
||||
|
||||
return [
|
||||
...ordered.map((item, index) => {
|
||||
if (!item.is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = ordered[index + 1];
|
||||
return { ...item, last: !nextItem || !nextItem.is_child };
|
||||
}),
|
||||
...extras,
|
||||
];
|
||||
};
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(
|
||||
configEntriesLookup: Record<string, ConfigEntry>,
|
||||
@@ -242,7 +275,7 @@ export class HaDevicePicker extends LitElement {
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
}
|
||||
|
||||
const area = getDeviceArea(device, this.hass.areas);
|
||||
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
@@ -279,46 +312,76 @@ export class HaDevicePicker extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
style=${
|
||||
item.is_child
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
item.is_child
|
||||
? html`<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${item.last}
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
></ha-tree-indicator>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
/>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${
|
||||
item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
@@ -375,6 +438,8 @@ export class HaDevicePicker extends LitElement {
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.searchFn=${this._searchFn}
|
||||
no-sort
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.searchKeys=${deviceComboBoxKeys}
|
||||
|
||||
@@ -34,6 +34,8 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
@property({ type: Boolean, reflect: true, attribute: "full-width" })
|
||||
public fullWidth = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public variant:
|
||||
"brand" | "neutral" | "success" | "warning" | "danger" = "brand";
|
||||
|
||||
@@ -57,6 +59,7 @@ export class HaButtonToggleGroup extends LitElement {
|
||||
.value=${button.value}
|
||||
@click=${this._handleClick}
|
||||
.title=${button.label}
|
||||
.disabled=${this.disabled}
|
||||
.appearance=${this.active === button.value ? "accent" : "filled"}
|
||||
>
|
||||
${
|
||||
|
||||
@@ -33,6 +33,7 @@ import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { getEntityContext } from "../common/entity/context/get_entity_context";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import { computeEntityName } from "../common/entity/compute_entity_name";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
@@ -752,6 +753,19 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._states![key]
|
||||
);
|
||||
|
||||
const entityName = computeEntityName(
|
||||
this._states![key],
|
||||
this._registries!.entities,
|
||||
this._registries!.devices
|
||||
);
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
: undefined;
|
||||
|
||||
const completionItems: CompletionItem[] = [
|
||||
{
|
||||
label: this._i18n!.localize(
|
||||
@@ -759,31 +773,39 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
),
|
||||
value: formattedState,
|
||||
subValue:
|
||||
// If the state exactly matches the formatted state, don't show the raw state
|
||||
this._states![key].state === formattedState
|
||||
? undefined
|
||||
: this._states![key].state,
|
||||
},
|
||||
];
|
||||
|
||||
if (context.device && context.device.name) {
|
||||
if (entityName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.entity.entity-picker.entity"
|
||||
),
|
||||
value: entityName,
|
||||
});
|
||||
}
|
||||
|
||||
if (deviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.device-picker.device"),
|
||||
value: context.device.name,
|
||||
value: deviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.area && context.area.name) {
|
||||
if (areaName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.area-picker.area"),
|
||||
value: context.area.name,
|
||||
value: areaName,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.floor && context.floor.name) {
|
||||
if (floorName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.floor-picker.floor"),
|
||||
value: context.floor.name,
|
||||
value: floorName,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@ export class HaControlSelect extends LitElement {
|
||||
? repeat(
|
||||
this.options,
|
||||
(option) => option.value,
|
||||
(option) => this._renderOption(option)
|
||||
(option, index) =>
|
||||
this._renderOption(option, index === this._tabbableIndex)
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
@@ -151,7 +152,14 @@ export class HaControlSelect extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption) {
|
||||
/* a radio group with no selection puts its first option in the tab sequence */
|
||||
private get _tabbableIndex() {
|
||||
const selectedIndex =
|
||||
this.options?.findIndex((option) => option.value === this.value) ?? -1;
|
||||
return selectedIndex === -1 ? 0 : selectedIndex;
|
||||
}
|
||||
|
||||
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
|
||||
const isSelected = this.value === option.value;
|
||||
|
||||
return html`
|
||||
@@ -162,7 +170,7 @@ export class HaControlSelect extends LitElement {
|
||||
selected: isSelected,
|
||||
})}
|
||||
role="radio"
|
||||
tabindex=${isSelected ? "0" : "-1"}
|
||||
tabindex=${tabbable ? "0" : "-1"}
|
||||
.value=${option.value}
|
||||
aria-checked=${isSelected ? "true" : "false"}
|
||||
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
|
||||
|
||||
@@ -68,6 +68,7 @@ export class HaDurationInput extends LitElement {
|
||||
{ label: "-", iconPath: mdiMinusThick, value: "-" },
|
||||
]}
|
||||
.active=${this._negative ? "-" : "+"}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._negativeChanged}
|
||||
></ha-button-toggle-group>
|
||||
`
|
||||
@@ -235,8 +236,8 @@ export class HaDurationInput extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
const value = this.data;
|
||||
if (value) {
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Centered placeholder for a surface that has nothing to show, with an icon, a
|
||||
* heading, an optional description and optional actions.
|
||||
*
|
||||
* @slot - Actions that help the user fill the surface, e.g. a button.
|
||||
*/
|
||||
@customElement("ha-empty-state")
|
||||
export class HaEmptyState extends LitElement {
|
||||
/** SVG path of the icon shown above the heading. */
|
||||
@property() public icon?: string;
|
||||
|
||||
@property() public heading?: string;
|
||||
|
||||
@property() public description?: string;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div class="content">
|
||||
${
|
||||
this.icon
|
||||
? html`<ha-svg-icon .path=${this.icon}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
${this.heading ? html`<h2>${this.heading}</h2>` : nothing}
|
||||
${this.description ? html`<p>${this.description}</p>` : nothing}
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
max-width: 500px;
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ha-svg-icon {
|
||||
--mdc-icon-size: var(--ha-empty-state-icon-size, 64px);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-empty-state": HaEmptyState;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -34,11 +39,11 @@ export class HaFilterBlueprints extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _blueprints?: Blueprints;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -56,7 +61,6 @@ export class HaFilterBlueprints extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -71,29 +75,38 @@ export class HaFilterBlueprints extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._blueprints && this._shouldRender
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._blueprintsSelected}
|
||||
multi
|
||||
class="ha-scrollbar"
|
||||
>
|
||||
${Object.entries(this._blueprints).map(([id, blueprint]) =>
|
||||
"error" in blueprint
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
.value=${id}
|
||||
.selected=${(this.value || []).includes(id)}
|
||||
>
|
||||
${blueprint.metadata.name || id}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
this._blueprints
|
||||
? html`
|
||||
<ha-list
|
||||
@selected=${this._blueprintsSelected}
|
||||
multi
|
||||
class="ha-scrollbar"
|
||||
>
|
||||
${Object.entries(this._blueprints).map(
|
||||
([id, blueprint]) =>
|
||||
"error" in blueprint
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
.value=${id}
|
||||
.selected=${(this.value || []).includes(id)}
|
||||
>
|
||||
${blueprint.metadata.name || id}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -104,19 +117,6 @@ export class HaFilterBlueprints extends LitElement {
|
||||
this._blueprints = await fetchBlueprints(this.hass, this.type);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (this.narrow || !this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -191,17 +191,11 @@ export class HaFilterBlueprints extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -8,9 +8,14 @@ import {
|
||||
mdiTag,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import type { CategoryRegistryEntry } from "../data/category_registry";
|
||||
@@ -47,9 +52,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _categories: CategoryRegistryEntry[] = [];
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected hassSubscribeRequiredHostProps = ["scope"];
|
||||
|
||||
@@ -70,7 +75,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -85,9 +89,11 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
@selected=${this._categorySelected}
|
||||
class="ha-scrollbar"
|
||||
@@ -158,34 +164,17 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
</ha-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this.expanded
|
||||
? html`<ha-list-item
|
||||
graphic="icon"
|
||||
@click=${this._addCategory}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</ha-list-item>`
|
||||
<ha-list-item graphic="icon" @click=${this._addCategory}>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
|
||||
${this.hass.localize("ui.panel.config.category.editor.add")}
|
||||
</ha-list-item>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleAction(ev: HaDropdownSelectEvent) {
|
||||
const categoryId = (ev.currentTarget as any).categoryId;
|
||||
const action = ev.detail.item.value;
|
||||
@@ -244,10 +233,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -287,19 +272,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
position: relative;
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -325,6 +299,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--mdc-list-item-meta-size: auto;
|
||||
--mdc-list-side-padding-right: var(--ha-space-1);
|
||||
--mdc-list-side-padding-left: var(--ha-space-4);
|
||||
@@ -339,12 +315,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.add {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { internationalizationContext, statesContext } from "../data/context";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-domain-icon";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-list";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
interface DeviceClassItem {
|
||||
deviceClass: string;
|
||||
domain: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-device-classes")
|
||||
export class HaFilterDeviceClasses extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
@state()
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@state()
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@property({ attribute: false }) public value?: string[];
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
${this._localize("ui.components.filter-device-classes.caption")}
|
||||
${
|
||||
this.value?.length
|
||||
? html`<div class="badge">${this.value?.length}</div>
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
></ha-icon-button>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
),
|
||||
(item) => item.deviceClass,
|
||||
(item) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${item.deviceClass}
|
||||
.selected=${(this.value || []).includes(item.deviceClass)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${item.domain}
|
||||
.deviceClass=${item.deviceClass}
|
||||
.state=${item.domain === "binary_sensor" ? "on" : undefined}
|
||||
></ha-domain-icon>
|
||||
${item.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceClasses = memoizeOne(
|
||||
(
|
||||
states: ContextType<typeof statesContext>,
|
||||
localize: LocalizeFunc,
|
||||
language: string | undefined,
|
||||
filter: string | undefined
|
||||
): DeviceClassItem[] =>
|
||||
this._deviceClassItems(this._deviceClassDomains(states), localize)
|
||||
.filter(
|
||||
(item) =>
|
||||
!filter ||
|
||||
item.deviceClass.toLowerCase().includes(filter) ||
|
||||
item.name.toLowerCase().includes(filter)
|
||||
)
|
||||
.sort((a, b) => stringCompare(a.name, b.name, language))
|
||||
);
|
||||
|
||||
private _deviceClassDomains = memoizeOne(
|
||||
(states: ContextType<typeof statesContext>): Map<string, string[]> => {
|
||||
const domains = new Map<string, string[]>();
|
||||
Object.values(states).forEach((stateObj) => {
|
||||
const deviceClass = stateObj.attributes.device_class;
|
||||
if (!deviceClass) {
|
||||
return;
|
||||
}
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const known = domains.get(deviceClass);
|
||||
if (!known) {
|
||||
domains.set(deviceClass, [domain]);
|
||||
} else if (!known.includes(domain)) {
|
||||
known.push(domain);
|
||||
}
|
||||
});
|
||||
return domains;
|
||||
}
|
||||
);
|
||||
|
||||
private _deviceClassItems = memoizeOne(
|
||||
(
|
||||
deviceClassDomains: Map<string, string[]>,
|
||||
localize: LocalizeFunc
|
||||
): DeviceClassItem[] =>
|
||||
[...deviceClassDomains].map(([deviceClass, domains]) => {
|
||||
for (const domain of domains) {
|
||||
const name = localize(
|
||||
`component.${domain}.entity_component.${deviceClass}.name`
|
||||
);
|
||||
if (name) {
|
||||
return { deviceClass, domain, name };
|
||||
}
|
||||
}
|
||||
return { deviceClass, domain: domains[0], name: deviceClass };
|
||||
}),
|
||||
([domainsA, localizeA], [domainsB, localizeB]) =>
|
||||
localizeA === localizeB &&
|
||||
domainsA.size === domainsB.size &&
|
||||
[...domainsA].every(
|
||||
([deviceClass, domains]) =>
|
||||
domainsB.get(deviceClass)?.join() === domains.join()
|
||||
)
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev: HASSDomEvent<{ expanded: boolean }>) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev: HASSDomEvent<{ expanded: boolean }>) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemSelected(ev: CustomEvent<SelectedDetail<Set<number>>>) {
|
||||
const deviceClasses = this._deviceClasses(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter
|
||||
);
|
||||
|
||||
const visible = new Set(deviceClasses.map((item) => item.deviceClass));
|
||||
const preserved = (this.value || []).filter((d) => !visible.has(d));
|
||||
const selected = [...ev.detail.index]
|
||||
.map((i) => deviceClasses[i]?.deviceClass)
|
||||
.filter((d): d is string => !!d);
|
||||
|
||||
this.value = [...preserved, ...selected];
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value.length ? this.value : undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _clearFilter(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-device-classes": HaFilterDeviceClasses;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
|
||||
@@ -59,13 +64,15 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list-selectable-virtualized")
|
||||
private _listElement?: HaListSelectableVirtualized;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -77,26 +84,11 @@ export class HaFilterDevices extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded || !this._listElement) {
|
||||
return;
|
||||
}
|
||||
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 38px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -112,31 +104,33 @@ export class HaFilterDevices extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(
|
||||
this._devicesReg,
|
||||
this._filter || "",
|
||||
this._localize,
|
||||
this._states,
|
||||
this._i18n.locale.language
|
||||
)}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(
|
||||
this._devicesReg,
|
||||
this._filter || "",
|
||||
this._localize,
|
||||
this._states,
|
||||
this._i18n.locale.language
|
||||
)}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -177,10 +171,6 @@ export class HaFilterDevices extends LitElement {
|
||||
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -271,58 +261,43 @@ export class HaFilterDevices extends LitElement {
|
||||
this._listElement?.clearSelection();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
:host([expanded]) ha-expansion-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`;
|
||||
static styles = [
|
||||
filterPanelStyles,
|
||||
css`
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
@@ -41,18 +46,17 @@ export class HaFilterDomains extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -67,46 +71,48 @@ export class HaFilterDomains extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._domains(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter,
|
||||
this.value
|
||||
),
|
||||
(i) => i,
|
||||
(domain) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${domain}
|
||||
.selected=${(this.value || []).includes(domain)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${domainToName(this._localize, domain)}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._domains(
|
||||
this._states,
|
||||
this._localize,
|
||||
this._i18n.locale.language,
|
||||
this._filter,
|
||||
this.value
|
||||
),
|
||||
(i) => i,
|
||||
(domain) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${domain}
|
||||
.selected=${(this.value || []).includes(domain)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${domainToName(this._localize, domain)}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -139,22 +145,6 @@ export class HaFilterDomains extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -199,24 +189,18 @@ export class HaFilterDomains extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: initial;
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
ha-check-list-item {
|
||||
|
||||
@@ -2,8 +2,13 @@ import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeStateDomain } from "../common/entity/compute_state_domain";
|
||||
@@ -52,11 +57,11 @@ export class HaFilterEntities extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -78,7 +83,6 @@ export class HaFilterEntities extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -93,9 +97,11 @@ export class HaFilterEntities extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@@ -118,25 +124,13 @@ export class HaFilterEntities extends LitElement {
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _keyFunction = (entity) => entity?.entity_id;
|
||||
|
||||
private _renderItem = (entity) =>
|
||||
@@ -173,10 +167,6 @@ export class HaFilterEntities extends LitElement {
|
||||
listItem.selected = this.value?.includes(value);
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -255,17 +245,11 @@ export class HaFilterEntities extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -4,8 +4,13 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
@@ -64,10 +69,12 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
@@ -86,7 +93,6 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -107,9 +113,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@@ -154,10 +162,10 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
(area) => this._renderArea(area)
|
||||
)}
|
||||
</ha-list-selectable>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -243,19 +251,6 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -347,17 +342,11 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list-selectable {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
@@ -44,11 +49,11 @@ export class HaFilterIntegrations extends LitElement {
|
||||
Object.values(manifests)
|
||||
);
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
const manifests = this._manifests
|
||||
@@ -59,7 +64,6 @@ export class HaFilterIntegrations extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -74,67 +78,57 @@ export class HaFilterIntegrations extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
manifests && this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._itemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._integrations(
|
||||
this._localize,
|
||||
manifests,
|
||||
this._filter,
|
||||
this.value,
|
||||
this._i18n.locale.language
|
||||
),
|
||||
(i) => i.domain,
|
||||
(integration) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${integration.domain}
|
||||
.selected=${(this.value || []).includes(
|
||||
integration.domain
|
||||
)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${integration.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${integration.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
${
|
||||
manifests
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@selected=${this._itemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._integrations(
|
||||
this._localize,
|
||||
manifests,
|
||||
this._filter,
|
||||
this.value,
|
||||
this._i18n.locale.language
|
||||
),
|
||||
(i) => i.domain,
|
||||
(integration) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${integration.domain}
|
||||
.selected=${(this.value || []).includes(
|
||||
integration.domain
|
||||
)}
|
||||
graphic="icon"
|
||||
>
|
||||
<ha-domain-icon
|
||||
slot="graphic"
|
||||
.domain=${integration.domain}
|
||||
brand-fallback
|
||||
></ha-domain-icon>
|
||||
${integration.name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -213,17 +207,11 @@ export class HaFilterIntegrations extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { navigate } from "../common/navigate";
|
||||
@@ -44,11 +49,11 @@ export class HaFilterLabels extends LitElement {
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
private _filteredLabels = memoizeOne(
|
||||
// `_value` used to recalculate the memoization when the selection changes
|
||||
@@ -75,7 +80,6 @@ export class HaFilterLabels extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -90,89 +94,66 @@ export class HaFilterLabels extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
@selected=${this._labelSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._filteredLabels(
|
||||
this._labels || [],
|
||||
this._filter,
|
||||
this._i18n.locale.language,
|
||||
this.value
|
||||
),
|
||||
(label) => label.label_id,
|
||||
(label) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${label.label_id}
|
||||
.selected=${(this.value || []).includes(label.label_id)}
|
||||
hasMeta
|
||||
>
|
||||
<ha-label
|
||||
.color=${label.color}
|
||||
.description=${label.description}
|
||||
>
|
||||
${
|
||||
label.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this.expanded
|
||||
? html`<ha-list-item
|
||||
graphic="icon"
|
||||
@click=${this._manageLabels}
|
||||
class="add"
|
||||
>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.labels.manage_labels")}
|
||||
</ha-list-item>`
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
@selected=${this._labelSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
this._filteredLabels(
|
||||
this._labels || [],
|
||||
this._filter,
|
||||
this._i18n.locale.language,
|
||||
this.value
|
||||
),
|
||||
(label) => label.label_id,
|
||||
(label) =>
|
||||
html`<ha-check-list-item
|
||||
.value=${label.label_id}
|
||||
.selected=${(this.value || []).includes(label.label_id)}
|
||||
hasMeta
|
||||
>
|
||||
<ha-label
|
||||
.color=${label.color}
|
||||
.description=${label.description}
|
||||
>
|
||||
${
|
||||
label.icon
|
||||
? html`<ha-icon
|
||||
slot="icon"
|
||||
.icon=${label.icon}
|
||||
></ha-icon>`
|
||||
: nothing
|
||||
}
|
||||
${label.name}
|
||||
</ha-label>
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
<ha-list-item graphic="icon" @click=${this._manageLabels}>
|
||||
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
|
||||
${this._localize("ui.panel.config.labels.manage_labels")}
|
||||
</ha-list-item>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
// 48px - height of ha-list-item
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _manageLabels() {
|
||||
navigate("/config/labels");
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -227,18 +208,11 @@ export class HaFilterLabels extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
@@ -267,12 +241,6 @@ export class HaFilterLabels extends LitElement {
|
||||
.warning {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.add {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import "./chips/ha-assist-chip";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
/**
|
||||
* Chip that opens a filter pane, with a badge showing how many filters are
|
||||
* active.
|
||||
*/
|
||||
@customElement("ha-filter-pane-chip")
|
||||
export class HaFilterPaneChip extends LitElement {
|
||||
@property() public label = "";
|
||||
|
||||
/** SVG path of the leading icon. */
|
||||
@property() public path?: string;
|
||||
|
||||
/** Number of active filters, shown as a badge when there is at least one. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
@property({ type: Boolean }) public active = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-assist-chip
|
||||
.label=${this.label}
|
||||
.active=${this.active}
|
||||
.disabled=${this.disabled}
|
||||
>
|
||||
${
|
||||
this.path
|
||||
? html`<ha-svg-icon slot="icon" .path=${this.path}></ha-svg-icon>`
|
||||
: nothing
|
||||
}
|
||||
</ha-assist-chip>
|
||||
${this.count ? html`<div class="badge">${this.count}</div>` : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0 2px;
|
||||
color: var(--text-primary-color);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane-chip": HaFilterPaneChip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { mdiFilterVariant, mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-adaptive-dialog";
|
||||
import "./ha-button";
|
||||
import "./ha-dialog-footer";
|
||||
import "./ha-filter-pane-chip";
|
||||
import "./ha-icon-button";
|
||||
|
||||
/**
|
||||
* Filter pane for a filtered page: a column next to the content on wide
|
||||
* screens, a bottom sheet on narrow ones. Mirrors the filter pane of
|
||||
* `hass-tabs-subpage-data-table` for pages that are not a data table.
|
||||
*
|
||||
* The page keeps ownership of whether the pane is shown, so that it can also
|
||||
* open it from elsewhere (e.g. an empty state) and hide its own toolbar chip
|
||||
* while it is open.
|
||||
*
|
||||
* @slot - Filter panels, e.g. `ha-filter-domains`.
|
||||
*/
|
||||
@customElement("ha-filter-pane")
|
||||
export class HaFilterPane extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public narrow = false;
|
||||
|
||||
/** Header label, defaults to "Filters". */
|
||||
@property() public label?: string;
|
||||
|
||||
/** SVG path of the header chip icon. */
|
||||
@property() public path = mdiFilterVariant;
|
||||
|
||||
/** Number of active filters, shows the clear button when above zero. */
|
||||
@property({ type: Number }) public count = 0;
|
||||
|
||||
/**
|
||||
* Number of results the current filters resolve to, shown on the narrow
|
||||
* confirm button. Leave undefined when the page shows everything.
|
||||
*/
|
||||
@property({ attribute: false }) public resultCount?: number;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
const label =
|
||||
this.label ?? this._localize("ui.components.subpage-data-table.filters");
|
||||
|
||||
if (this.narrow) {
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
open
|
||||
flexcontent
|
||||
.headerTitle=${label}
|
||||
@closed=${this._close}
|
||||
>
|
||||
${this._renderClearButton("headerActionItems")}
|
||||
<div class="sheet-content">
|
||||
<slot></slot>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" data-dialog="close">
|
||||
${
|
||||
this.resultCount === undefined
|
||||
? this._localize("ui.common.close")
|
||||
: this._localize(
|
||||
"ui.components.subpage-data-table.show_results",
|
||||
{ number: this.resultCount }
|
||||
)
|
||||
}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-adaptive-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
<ha-filter-pane-chip
|
||||
active
|
||||
.label=${label}
|
||||
.path=${this.path}
|
||||
.disabled=${this.disabled}
|
||||
@click=${this._close}
|
||||
></ha-filter-pane-chip>
|
||||
${this._renderClearButton()}
|
||||
</div>
|
||||
<div class="content ha-scrollbar">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderClearButton(slot?: string) {
|
||||
if (!this.count) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-icon-button
|
||||
slot=${ifDefined(slot)}
|
||||
.path=${mdiFilterVariantRemove}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this._localize("ui.components.subpage-data-table.clear_filter")}
|
||||
@click=${this._clear}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _close() {
|
||||
fireEvent(this, "close-filter-pane");
|
||||
}
|
||||
|
||||
private _clear() {
|
||||
fireEvent(this, "clear-filter");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 0 0 var(--ha-filter-pane-width, 320px);
|
||||
width: var(--ha-filter-pane-width, 320px);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
border-inline-end: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* The bottom sheet positions itself, so the pane takes no space. */
|
||||
:host([narrow]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-4);
|
||||
box-sizing: border-box;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
background: var(--primary-background-color);
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.content,
|
||||
.sheet-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
/* Fixed height so the sheet does not resize while filtering. */
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-filter-pane": HaFilterPane;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"close-filter-pane": undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import type { List, SelectedDetail } from "@material/mwc-list";
|
||||
import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import "./ha-check-list-item";
|
||||
@@ -27,9 +32,9 @@ export class HaFilterStates extends LitElement {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
@query("ha-list") private _list!: List;
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
if (!this.states) {
|
||||
@@ -40,7 +45,6 @@ export class HaFilterStates extends LitElement {
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -55,9 +59,11 @@ export class HaFilterStates extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`
|
||||
<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
@selected=${this._statesSelected}
|
||||
multi
|
||||
@@ -82,36 +88,13 @@ export class HaFilterStates extends LitElement {
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</div>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
protected willUpdate(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
this._shouldRender = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
|
||||
setTimeout(async () => {
|
||||
if (!this.expanded) return;
|
||||
const list = this._list;
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
list.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -152,17 +135,11 @@ export class HaFilterStates extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -2,8 +2,13 @@ import type { SelectedDetail } from "@material/mwc-list";
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { createRef, ref } from "lit/directives/ref";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import {
|
||||
FilterPanelController,
|
||||
filterPanelStyles,
|
||||
} from "../common/controllers/filter-panel-controller";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -34,16 +39,15 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
|
||||
@state() private _voiceAssistantOptions: string[] = [];
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
private _content = createRef<HTMLElement>();
|
||||
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
private _panel = new FilterPanelController(this, this._content);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
left-chevron
|
||||
.expanded=${this.expanded}
|
||||
@expanded-will-change=${this._expandedWillChange}
|
||||
@expanded-changed=${this._expandedChanged}
|
||||
>
|
||||
<div slot="header" class="header">
|
||||
@@ -58,9 +62,11 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this._shouldRender
|
||||
? html`<ha-list
|
||||
</ha-expansion-panel>
|
||||
${
|
||||
this._panel.showContent
|
||||
? html`<div class="content" ${ref(this._content)}>
|
||||
<ha-list
|
||||
@selected=${this._assistantsSelected}
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@@ -83,10 +89,10 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
${voiceAssistants[voiceAssistantId].name}
|
||||
</ha-check-list-item>`
|
||||
)}
|
||||
</ha-list> `
|
||||
: nothing
|
||||
}
|
||||
</ha-expansion-panel>
|
||||
</ha-list>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -95,19 +101,6 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
this._voiceAssistantOptions = Object.keys(voiceAssistants);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49}px`;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
this._shouldRender = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _expandedChanged(ev) {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
@@ -148,18 +141,11 @@ export class HaFilterVoiceAssistants extends LitElement {
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
filterPanelStyles,
|
||||
css`
|
||||
:host {
|
||||
position: relative;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
ha-list {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
|
||||
import type { Selector } from "../../data/selector";
|
||||
|
||||
/**
|
||||
* Value a selector already displays when no field value is set.
|
||||
* Used when enabling an optional service/trigger/condition field.
|
||||
*/
|
||||
export const getSelectorFallbackValue = (selector: Selector): unknown => {
|
||||
if ("constant" in selector) {
|
||||
return selector.constant?.value;
|
||||
}
|
||||
if ("boolean" in selector) {
|
||||
return false;
|
||||
}
|
||||
if ("number" in selector) {
|
||||
return selector.number?.min ?? 0;
|
||||
}
|
||||
if ("color_temp" in selector) {
|
||||
if (selector.color_temp?.unit === "kelvin") {
|
||||
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
|
||||
}
|
||||
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
@@ -43,6 +43,8 @@ const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
|
||||
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
|
||||
matter: () =>
|
||||
import("../resources/matter-logo-svg").then((mod) => mod.mdiMatterLogo),
|
||||
mqtt: () =>
|
||||
import("../resources/mqtt-logo-svg").then((mod) => mod.mdiMqttLogo),
|
||||
};
|
||||
|
||||
@customElement("ha-icon")
|
||||
|
||||
@@ -472,10 +472,9 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
const newValue = value?.trim();
|
||||
const newTab = ev.ctrlKey || ev.metaKey;
|
||||
|
||||
this._fireSelectedEvents(newValue, index, newTab);
|
||||
this._fireSelectedEvents(value, index, newTab);
|
||||
};
|
||||
|
||||
private _fireSelectedEvents(value: string, index: number, newTab = false) {
|
||||
|
||||
@@ -81,6 +81,7 @@ export class HaChooseSelector extends LitElement {
|
||||
.required=${this.required}
|
||||
@value-changed=${this._handleValueChanged}
|
||||
.helper=${this.helper}
|
||||
.localizeValue=${this.localizeValue}
|
||||
></ha-selector>`;
|
||||
}
|
||||
|
||||
@@ -107,7 +108,12 @@ export class HaChooseSelector extends LitElement {
|
||||
: {
|
||||
[this._activeChoice!]: this.value,
|
||||
};
|
||||
this._activeChoice = ev.detail?.value || ev.target.value;
|
||||
const choice = ev.detail?.value || ev.target.value;
|
||||
this._activeChoice = choice;
|
||||
if (choice && "constant" in this.selector.choose.choices[choice].selector) {
|
||||
value[choice] =
|
||||
this.selector.choose.choices[choice].selector.constant?.value;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
...value,
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "../data/selector";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
|
||||
import "./ha-checkbox";
|
||||
import type { HaCheckbox } from "./ha-checkbox";
|
||||
import "./ha-icon-button";
|
||||
@@ -799,20 +800,8 @@ export class HaServiceControl extends LitElement {
|
||||
|
||||
let defaultValue = field?.default;
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"constant" in field.selector
|
||||
) {
|
||||
defaultValue = field.selector.constant?.value;
|
||||
}
|
||||
|
||||
if (
|
||||
defaultValue == null &&
|
||||
field?.selector &&
|
||||
"boolean" in field.selector
|
||||
) {
|
||||
defaultValue = false;
|
||||
if (defaultValue == null && field?.selector) {
|
||||
defaultValue = getSelectorFallbackValue(field.selector);
|
||||
}
|
||||
|
||||
if (defaultValue != null) {
|
||||
|
||||
@@ -664,6 +664,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { ensureArray } from "../common/array/ensure-array";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import type { DataTableFiltersValue } from "../data/data_table_filters";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../data/entity/entity";
|
||||
import type { EntitySources } from "../data/entity/entity_sources";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-filter-device-classes";
|
||||
import "./ha-filter-domains";
|
||||
import "./ha-filter-integrations";
|
||||
import "./ha-target-picker";
|
||||
|
||||
/**
|
||||
* Ways to narrow down the entities a target selection resolves to. Not to be
|
||||
* confused with `EntitySources`, which maps an entity to its integration.
|
||||
*/
|
||||
export interface SourceFilters {
|
||||
domains?: string[];
|
||||
deviceClasses?: string[];
|
||||
integrations?: string[];
|
||||
}
|
||||
|
||||
const TARGET_KEYS = [
|
||||
"floor_id",
|
||||
"area_id",
|
||||
"device_id",
|
||||
"entity_id",
|
||||
"label_id",
|
||||
] as const;
|
||||
|
||||
/** Number of picked targets, no matter which type they are. */
|
||||
export const countTargets = (target: HassServiceTarget): number =>
|
||||
TARGET_KEYS.reduce(
|
||||
(count, key) => count + (target[key] ? ensureArray(target[key]).length : 0),
|
||||
0
|
||||
);
|
||||
|
||||
/** Number of filters that have at least one option selected. */
|
||||
export const countSourceFilters = (filters: SourceFilters): number =>
|
||||
Object.values(filters).filter((value) => value?.length).length;
|
||||
|
||||
/**
|
||||
* Narrows entity IDs down by the selected filters: an entity is kept when it
|
||||
* matches every filter that has a selection.
|
||||
*/
|
||||
export const applySourceFilters = (
|
||||
entityIds: string[],
|
||||
filters: SourceFilters,
|
||||
states: HomeAssistant["states"],
|
||||
entities: HomeAssistant["entities"],
|
||||
entitySources?: EntitySources
|
||||
): string[] => {
|
||||
const domains = filters.domains?.length ? filters.domains : undefined;
|
||||
const deviceClasses = filters.deviceClasses?.length
|
||||
? filters.deviceClasses
|
||||
: undefined;
|
||||
const integrations = filters.integrations?.length
|
||||
? filters.integrations
|
||||
: undefined;
|
||||
|
||||
if (!domains && !deviceClasses && !integrations) {
|
||||
return entityIds;
|
||||
}
|
||||
|
||||
return entityIds.filter((entityId) => {
|
||||
if (domains && !domains.includes(computeDomain(entityId))) {
|
||||
return false;
|
||||
}
|
||||
if (deviceClasses) {
|
||||
const deviceClass = states[entityId]?.attributes.device_class;
|
||||
if (!deviceClass || !deviceClasses.includes(deviceClass)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (integrations) {
|
||||
const integration =
|
||||
entities[entityId]?.platform ?? entitySources?.[entityId]?.domain;
|
||||
if (!integration || !integrations.includes(integration)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Picker for what a page shows: the targets to include, narrowed down by
|
||||
* domain, device class and integration. Meant to be placed in an
|
||||
* `ha-filter-pane`.
|
||||
*
|
||||
* The pages resolve every entity of a target, secondary ones included, so the
|
||||
* target picker counts them too.
|
||||
*/
|
||||
@customElement("ha-sources-picker")
|
||||
export class HaSourcesPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public value: HassServiceTarget = {};
|
||||
|
||||
@property({ attribute: false }) public filters: SourceFilters = {};
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
/** Explains what the page shows while no target is picked. */
|
||||
@property() public description?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
// Only one filter panel is expanded at a time, so that the expanded one can
|
||||
// use the height that is left in the pane.
|
||||
@state() private _expandedFilter?: keyof SourceFilters;
|
||||
|
||||
protected render() {
|
||||
const noTargets = countTargets(this.value) === 0;
|
||||
|
||||
return html`
|
||||
${
|
||||
this.description && noTargets
|
||||
? html`<div class="description">${this.description}</div>`
|
||||
: nothing
|
||||
}
|
||||
<ha-target-picker
|
||||
class=${classMap({ "no-padding-top": noTargets })}
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.primaryEntitiesOnly=${false}
|
||||
.disabled=${this.disabled}
|
||||
@value-changed=${this._targetsChanged}
|
||||
></ha-target-picker>
|
||||
<div
|
||||
class=${classMap({ filters: true, expanded: !!this._expandedFilter })}
|
||||
>
|
||||
<ha-filter-domains
|
||||
.value=${this.filters.domains}
|
||||
.expanded=${this._expandedFilter === "domains"}
|
||||
@data-table-filter-changed=${this._domainsChanged}
|
||||
@expanded-changed=${this._domainsExpanded}
|
||||
></ha-filter-domains>
|
||||
<ha-filter-device-classes
|
||||
.value=${this.filters.deviceClasses}
|
||||
.expanded=${this._expandedFilter === "deviceClasses"}
|
||||
@data-table-filter-changed=${this._deviceClassesChanged}
|
||||
@expanded-changed=${this._deviceClassesExpanded}
|
||||
></ha-filter-device-classes>
|
||||
<ha-filter-integrations
|
||||
.value=${this.filters.integrations}
|
||||
.expanded=${this._expandedFilter === "integrations"}
|
||||
@data-table-filter-changed=${this._integrationsChanged}
|
||||
@expanded-changed=${this._integrationsExpanded}
|
||||
></ha-filter-integrations>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
// The filter panels label themselves with keys from the config panel.
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
}
|
||||
|
||||
private _targetsChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "value-changed", { value: ev.detail.value || {} });
|
||||
}
|
||||
|
||||
private _domainsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesChanged(ev: CustomEvent) {
|
||||
this._filterChanged("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsChanged(ev: CustomEvent) {
|
||||
this._filterChanged("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterChanged(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value as DataTableFiltersValue;
|
||||
fireEvent(this, "source-filters-changed", {
|
||||
value: {
|
||||
...this.filters,
|
||||
[key]: Array.isArray(value) && value.length ? value : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _domainsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("domains", ev);
|
||||
}
|
||||
|
||||
private _deviceClassesExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("deviceClasses", ev);
|
||||
}
|
||||
|
||||
private _integrationsExpanded(ev: CustomEvent) {
|
||||
this._filterExpanded("integrations", ev);
|
||||
}
|
||||
|
||||
private _filterExpanded(key: keyof SourceFilters, ev: CustomEvent) {
|
||||
if (ev.detail.expanded) {
|
||||
this._expandedFilter = key;
|
||||
} else if (this._expandedFilter === key) {
|
||||
this._expandedFilter = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
/* The sections are laid out by the pane, so that an expanded filter
|
||||
panel can use the height that is left. */
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.description {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 92px;
|
||||
margin: var(--ha-space-4) var(--ha-space-4) 0;
|
||||
padding: 0 var(--ha-space-6);
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-target-picker {
|
||||
display: block;
|
||||
flex: none;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
/* The description already spaces the picker from the pane header. */
|
||||
ha-target-picker.no-padding-top {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
/* An expanded panel sizes itself to the space that is left over. */
|
||||
.filters.expanded {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-sources-picker": HaSourcesPicker;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"source-filters-changed": { value: SourceFilters };
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,6 @@ import { domainToName } from "../data/integration";
|
||||
import { getLabels, labelComboBoxKeys } from "../data/label/label_picker";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import {
|
||||
areaMeetsFilter,
|
||||
deviceMeetsFilter,
|
||||
entityRegMeetsFilter,
|
||||
getTargetComboBoxItemType,
|
||||
type TargetItem,
|
||||
type TargetType,
|
||||
@@ -66,7 +63,6 @@ import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
@@ -85,8 +81,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public compact = false;
|
||||
|
||||
@property({ attribute: false }) public createDomains?: string[];
|
||||
|
||||
@property({ type: Boolean, attribute: "primary-entities-only" })
|
||||
@@ -116,8 +110,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
|
||||
|
||||
@state() private _selectedSection?: TargetTypeFloorless;
|
||||
|
||||
@state() private _replaceTarget?: TargetItem;
|
||||
@@ -158,6 +150,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix,
|
||||
nested: true,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -252,119 +245,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
Fuse.createIndex(keys, states);
|
||||
|
||||
protected render() {
|
||||
if (this.addOnTop) {
|
||||
return html` ${this._renderPicker()} ${this._renderItems()} `;
|
||||
}
|
||||
return html` ${this._renderItems()} ${this._renderPicker()} `;
|
||||
}
|
||||
|
||||
private _renderValueChips() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
: [];
|
||||
const deviceIds = this.value?.device_id
|
||||
? ensureArray(this.value.device_id)
|
||||
: [];
|
||||
const areaIds = this.value?.area_id ? ensureArray(this.value.area_id) : [];
|
||||
const floorIds = this.value?.floor_id
|
||||
? ensureArray(this.value.floor_id)
|
||||
: [];
|
||||
const labelIds = this.value?.label_id
|
||||
? ensureArray(this.value.label_id)
|
||||
: [];
|
||||
|
||||
if (
|
||||
!entityIds.length &&
|
||||
!deviceIds.length &&
|
||||
!areaIds.length &&
|
||||
!floorIds.length &&
|
||||
!labelIds.length
|
||||
) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="items">
|
||||
${
|
||||
floorIds.length
|
||||
? floorIds.map(
|
||||
(floor_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="floor"
|
||||
.itemId=${floor_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
areaIds.length
|
||||
? areaIds.map(
|
||||
(area_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="area"
|
||||
.itemId=${area_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
deviceIds.length
|
||||
? deviceIds.map(
|
||||
(device_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${device_id}
|
||||
.compositeSplits=${this._compositeSplits}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
entityIds.length
|
||||
? entityIds.map(
|
||||
(entity_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="entity"
|
||||
.itemId=${entity_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
${
|
||||
labelIds.length
|
||||
? labelIds.map(
|
||||
(label_id) => html`
|
||||
<ha-target-picker-value-chip
|
||||
.hass=${this.hass}
|
||||
type="label"
|
||||
.itemId=${label_id}
|
||||
@remove-target-item=${this._handleRemove}
|
||||
@expand-target-item=${this._handleExpand}
|
||||
></ha-target-picker-value-chip>
|
||||
`
|
||||
)
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderValueGroups() {
|
||||
const entityIds = this.value?.entity_id
|
||||
? ensureArray(this.value.entity_id)
|
||||
@@ -478,9 +361,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _renderItems() {
|
||||
return html`
|
||||
${this.compact ? this._renderValueChips() : this._renderValueGroups()}
|
||||
`;
|
||||
return html` ${this._renderValueGroups()} `;
|
||||
}
|
||||
|
||||
private _renderPicker() {
|
||||
@@ -655,152 +536,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: HASSDomEvent<HASSDomEvents["expand-target-item"]>) {
|
||||
const type = ev.detail.type;
|
||||
const itemId = ev.detail.id;
|
||||
const newAreas: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
const newEntities: string[] = [];
|
||||
|
||||
if (type === "floor") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.floor_id === itemId &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "area") {
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
if (
|
||||
device.area_id === itemId &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.area_id === itemId &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "device") {
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.device_id === itemId &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
false,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else if (type === "label") {
|
||||
Object.values(this.hass.areas).forEach((area) => {
|
||||
if (
|
||||
area.labels.includes(itemId) &&
|
||||
!this.value!.area_id?.includes(area.area_id) &&
|
||||
areaMeetsFilter(
|
||||
area,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newAreas.push(area.area_id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.devices).forEach((device) => {
|
||||
if (
|
||||
device.labels.includes(itemId) &&
|
||||
!this.value!.device_id?.includes(device.id) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newDevices.push(device.id);
|
||||
}
|
||||
});
|
||||
Object.values(this.hass.entities).forEach((entity) => {
|
||||
if (
|
||||
entity.labels.includes(itemId) &&
|
||||
!this.value!.entity_id?.includes(entity.entity_id) &&
|
||||
entityRegMeetsFilter(
|
||||
entity,
|
||||
true,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter
|
||||
)
|
||||
) {
|
||||
newEntities.push(entity.entity_id);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
let value = this.value;
|
||||
if (newEntities.length) {
|
||||
value = this._addItems(value, "entity_id", newEntities);
|
||||
}
|
||||
if (newDevices.length) {
|
||||
value = this._addItems(value, "device_id", newDevices);
|
||||
}
|
||||
if (newAreas.length) {
|
||||
value = this._addItems(value, "area_id", newAreas);
|
||||
}
|
||||
value = this._removeItem(value, type, itemId);
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _handleReplace(
|
||||
ev: HASSDomEvent<HASSDomEvents["replace-target-item"]>
|
||||
) {
|
||||
@@ -840,17 +575,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTargetAnchor = undefined;
|
||||
}
|
||||
|
||||
private _addItems(
|
||||
value: this["value"],
|
||||
type: string,
|
||||
ids: string[]
|
||||
): this["value"] {
|
||||
return {
|
||||
...value,
|
||||
[type]: value![type] ? ensureArray(value![type])!.concat(ids) : ids,
|
||||
};
|
||||
}
|
||||
|
||||
private _removeItem(
|
||||
value: this["value"],
|
||||
type: TargetType,
|
||||
@@ -1001,6 +725,28 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
}
|
||||
|
||||
if (!filterType || filterType === "device") {
|
||||
const selectedDeviceIds = targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined;
|
||||
// A selected parent device already targets its children, so exclude
|
||||
// those children from the picker too (mirrors selecting a floor
|
||||
// removing its areas from the list).
|
||||
const excludeDeviceIds = selectedDeviceIds
|
||||
? [
|
||||
...selectedDeviceIds,
|
||||
...Object.values(this.hass.devices)
|
||||
.filter(
|
||||
(device) =>
|
||||
device.parent_device_id !== null &&
|
||||
selectedDeviceIds.includes(device.parent_device_id)
|
||||
)
|
||||
.map((device) => device.id),
|
||||
]
|
||||
: undefined;
|
||||
let deviceItems = this._getDevicesMemoized(
|
||||
this.hass,
|
||||
configEntryLookup,
|
||||
@@ -1008,26 +754,41 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
includeDeviceClasses,
|
||||
deviceFilter,
|
||||
entityFilter,
|
||||
targetValue?.device_id
|
||||
? replacingDeviceId
|
||||
? ensureArray(targetValue.device_id).filter(
|
||||
(deviceId) => deviceId !== replacingDeviceId
|
||||
)
|
||||
: ensureArray(targetValue.device_id)
|
||||
: undefined,
|
||||
excludeDeviceIds,
|
||||
replacingDeviceId,
|
||||
`device${SEPARATOR}`
|
||||
).sort(this._sortBySortingLabel);
|
||||
);
|
||||
// getDevices already returns child devices nested under their parent
|
||||
// with the top-level devices sorted; keep that order rather than
|
||||
// re-sorting by label, which would separate children from their parent.
|
||||
|
||||
if (searchTerm) {
|
||||
// Keep the nested parent-then-children order (sort=false), matching
|
||||
// the areas group; the default sorted search would reorder matches by
|
||||
// relevance and pull children above their parent.
|
||||
deviceItems = this._filterGroup(
|
||||
"device",
|
||||
deviceItems,
|
||||
searchTerm,
|
||||
deviceComboBoxKeys
|
||||
deviceComboBoxKeys,
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
// Recompute the tree "last child" flag over the (possibly filtered)
|
||||
// list so the last visible child of each parent draws its end connector.
|
||||
deviceItems = deviceItems.map((item, index) => {
|
||||
if (!(item as DevicePickerItem).is_child) {
|
||||
return item;
|
||||
}
|
||||
const nextItem = deviceItems[index + 1] as
|
||||
DevicePickerItem | undefined;
|
||||
return {
|
||||
...item,
|
||||
last: !nextItem || !nextItem.is_child,
|
||||
};
|
||||
});
|
||||
|
||||
if (!filterType && deviceItems.length) {
|
||||
// show group title
|
||||
items.push(localize("ui.components.target-picker.type.devices"));
|
||||
@@ -1245,7 +1006,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
let hasFloor = false;
|
||||
let rtl = false;
|
||||
let showEntityId = false;
|
||||
if (type === "area" || type === "floor") {
|
||||
const isChildDeviceRow =
|
||||
type === "device" && !!(item as DevicePickerItem).is_child;
|
||||
if (type === "area" || type === "floor" || isChildDeviceRow) {
|
||||
rtl = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
@@ -1265,27 +1028,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
.type=${type === "empty" ? "text" : "button"}
|
||||
class=${type === "empty" ? "empty" : ""}
|
||||
style=${
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
${
|
||||
(item as FloorComboBoxItem).type === "area" && hasFloor
|
||||
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
|
||||
isChildDeviceRow
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "var(--ha-space-12)",
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
height: "100%",
|
||||
left: rtl ? undefined : "var(--ha-space-1)",
|
||||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${
|
||||
(item as FloorComboBoxItem & { last?: boolean | undefined })
|
||||
.last
|
||||
}
|
||||
.end=${(item as { last?: boolean }).last}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
@@ -1387,13 +1150,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.items {
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: var(--ha-space-2) 0;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.item-groups {
|
||||
overflow: hidden;
|
||||
border: var(--ha-border-width-sm) solid var(--divider-color);
|
||||
@@ -1409,7 +1165,6 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"remove-target-item": TargetItem;
|
||||
"expand-target-item": TargetItem;
|
||||
"replace-target-item": TargetItem;
|
||||
"migrate-target-item": { id: string; replacements: string[] };
|
||||
"remove-target-group": string;
|
||||
|
||||
@@ -8,10 +8,31 @@ export class HaTreeIndicator extends LitElement {
|
||||
public end?: boolean = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
// preserveAspectRatio="none" lets the connector stretch to the host box, so
|
||||
// it can span the full height of a taller row instead of being letterboxed
|
||||
// to a square in the middle. non-scaling-stroke keeps the line width and
|
||||
// dash pattern identical no matter how far it is stretched.
|
||||
return html`
|
||||
<svg width="100%" height="100%" viewBox="0 0 48 48">
|
||||
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
|
||||
<line x1="24" y1="24" x2="36" y2="24"></line>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 48 48"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<line
|
||||
x1="24"
|
||||
y1="0"
|
||||
x2="24"
|
||||
y2=${this.end ? "24" : "48"}
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
<line
|
||||
x1="24"
|
||||
y1="24"
|
||||
x2="36"
|
||||
y2="24"
|
||||
vector-effect="non-scaling-stroke"
|
||||
></line>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
|
||||
* @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 {boolean} indeterminate - Draws the checkbox in an indeterminate state, for a row that stands for a partially selected set.
|
||||
* @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"`.
|
||||
@@ -32,6 +33,8 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
|
||||
export class HaListItemOption extends HaListItemBase {
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public indeterminate = false;
|
||||
|
||||
@property({ type: String }) public value?: string;
|
||||
|
||||
@property({ type: String, reflect: true })
|
||||
@@ -80,6 +83,7 @@ export class HaListItemOption extends HaListItemBase {
|
||||
return html`<div part="checkbox" class="checkbox" inert>
|
||||
<ha-checkbox
|
||||
.checked=${this.selected}
|
||||
.indeterminate=${this.indeterminate}
|
||||
.disabled=${this.disabled}
|
||||
></ha-checkbox>
|
||||
</div>`;
|
||||
|
||||
@@ -10,6 +10,8 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
|
||||
class SelectableClass extends superClass {
|
||||
@property({ type: Boolean, reflect: true }) public multi = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public controlled = false;
|
||||
|
||||
protected override readonly hostRole = "listbox";
|
||||
|
||||
public connectedCallback(): void {
|
||||
@@ -67,15 +69,19 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
|
||||
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
|
||||
index
|
||||
);
|
||||
el.toggleAttribute("selected");
|
||||
if (!this.controlled) {
|
||||
el.toggleAttribute("selected");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el.selected) {
|
||||
fireEvent(this, "ha-list-item-selected", index);
|
||||
// deselect the other optional selected item
|
||||
this.clearSelection();
|
||||
el.toggleAttribute("selected", true);
|
||||
if (!this.controlled) {
|
||||
// deselect the other optional selected item
|
||||
this.clearSelection();
|
||||
el.toggleAttribute("selected", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ import { HaListVirtualized } from "./ha-list-virtualized";
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once. In
|
||||
* single-select mode, selecting a row clears any previous selection.
|
||||
* @attr {boolean} controlled - Only notify on click, never toggle a row's `selected` state.
|
||||
* Set it when the consumer derives `selected` from its own state, for example when a row stands
|
||||
* for a group whose click also changes its children.
|
||||
*
|
||||
* @fires ha-list-item-selected - Fires when the user selects a row.
|
||||
* `detail` is the row's index (number).
|
||||
|
||||
@@ -11,6 +11,9 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
|
||||
* Toggle single vs multi selection via the `multi` attribute.
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once.
|
||||
* @attr {boolean} controlled - Only notify on click, never toggle an option's `selected` state.
|
||||
* Set it when the consumer derives `selected` from its own state, for example when a row stands
|
||||
* for a group whose click also changes its children.
|
||||
*
|
||||
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
|
||||
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
|
||||
|
||||
@@ -393,6 +393,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
@@ -416,6 +419,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.labels.includes(this.itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
@@ -424,7 +430,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
: nextType === "device" && entries
|
||||
? entries.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities[entity_id].area_id === this.itemId
|
||||
this.hass.entities[entity_id]?.area_id === this.itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -433,7 +439,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
? entries.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
!devicesInAreas.includes(device_id) &&
|
||||
this.hass.devices[device_id].labels.includes(this.itemId)
|
||||
this.hass.devices[device_id]?.labels.includes(this.itemId)
|
||||
)
|
||||
: [];
|
||||
|
||||
@@ -528,6 +534,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_areas = entries.referenced_areas.filter(
|
||||
(area_id) => {
|
||||
const area = this.hass.areas[area_id];
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, so entities targeted through their
|
||||
// own area or label are not dropped along with it.
|
||||
if (!area) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
(this.type === "floor" || area.labels.includes(this.itemId)) &&
|
||||
areaMeetsFilter(
|
||||
@@ -560,6 +572,9 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_devices = entries.referenced_devices.filter(
|
||||
(device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
@@ -585,6 +600,11 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
entries.referenced_entities = entries.referenced_entities.filter(
|
||||
(entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
// Core can reference entities that are absent from the display
|
||||
// registry (e.g. disabled ones expanded from an area).
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (hiddenDeviceIds.includes(entity.device_id || "")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import "@home-assistant/webawesome/dist/components/tag/tag";
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiAlertOutline,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
mdiLabel,
|
||||
mdiTextureBox,
|
||||
mdiUnfoldMoreVertical,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeCssColor } from "../../common/color/compute-color";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { slugify } from "../../common/string/slugify";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { DeviceCompositeSplits } from "../../data/device/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { TargetType } from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-domain-icon";
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-icon";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-tooltip";
|
||||
|
||||
@customElement("ha-target-picker-value-chip")
|
||||
export class HaTargetPickerValueChip extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property() public type!: TargetType;
|
||||
|
||||
@property({ attribute: "item-id" }) public itemId!: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public compositeSplits?: DeviceCompositeSplits;
|
||||
|
||||
@state() private _domainName?: string;
|
||||
|
||||
@state() private _iconImg?: string;
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
_labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
protected render() {
|
||||
const { name, iconPath, fallbackIconPath, stateObject, color } =
|
||||
this._itemData(this.type, this.itemId);
|
||||
|
||||
const split =
|
||||
this.type === "device" && !this.hass.devices?.[this.itemId]
|
||||
? this.compositeSplits?.[this.itemId]
|
||||
: undefined;
|
||||
// Show the replaced reference using the primary replacement device's name,
|
||||
// falling back to the first still-existing split device if the primary
|
||||
// device itself was deleted. If no replacement device exists at all, fall
|
||||
// back to the normal "not found" display.
|
||||
const replacementDevice = split
|
||||
? (split.primary_id && this.hass.devices?.[split.primary_id]) ||
|
||||
split.split_ids
|
||||
.map((id) => this.hass.devices?.[id])
|
||||
.find((device) => device)
|
||||
: undefined;
|
||||
const replaced = !!replacementDevice;
|
||||
const replacedName = replacementDevice
|
||||
? computeDeviceNameDisplay(
|
||||
replacementDevice,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<wa-tag
|
||||
pill
|
||||
with-remove
|
||||
class=${classMap({ [this.type]: true, replaced })}
|
||||
style=${color ? `--color: rgb(${color});` : ""}
|
||||
@wa-remove=${this._removeItem}
|
||||
>
|
||||
<div class="icon">
|
||||
${
|
||||
replaced
|
||||
? html`<ha-svg-icon .path=${mdiAlertOutline}></ha-svg-icon>`
|
||||
: iconPath
|
||||
? html`<ha-icon .icon=${iconPath}></ha-icon>`
|
||||
: this._iconImg
|
||||
? html`<img
|
||||
alt=${this._domainName || ""}
|
||||
width="24"
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${this._iconImg}
|
||||
/>`
|
||||
: fallbackIconPath
|
||||
? html`<ha-svg-icon
|
||||
.path=${fallbackIconPath}
|
||||
></ha-svg-icon>`
|
||||
: stateObject
|
||||
? html`<ha-state-icon
|
||||
.stateObj=${stateObject}
|
||||
></ha-state-icon>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
<span class="name">
|
||||
${
|
||||
replaced
|
||||
? replacedName ||
|
||||
this.hass.localize(
|
||||
"ui.components.target-picker.replaced_device"
|
||||
)
|
||||
: name
|
||||
}
|
||||
</span>
|
||||
${
|
||||
this.type === "entity" || replaced
|
||||
? nothing
|
||||
: html`<ha-tooltip .for="expand-${slugify(this.itemId)}"
|
||||
>${this.hass.localize(
|
||||
`ui.components.target-picker.expand_${this.type}_id`
|
||||
)}
|
||||
</ha-tooltip>
|
||||
<ha-icon-button
|
||||
class="expand-btn mdc-chip__icon mdc-chip__icon--trailing"
|
||||
.label=${this.hass.localize(
|
||||
"ui.components.target-picker.expand"
|
||||
)}
|
||||
.path=${mdiUnfoldMoreVertical}
|
||||
hide-title
|
||||
.id="expand-${slugify(this.itemId)}"
|
||||
.type=${this.type}
|
||||
@click=${this._handleExpand}
|
||||
></ha-icon-button>`
|
||||
}
|
||||
</wa-tag>
|
||||
`;
|
||||
}
|
||||
|
||||
private _itemData = memoizeOne((type: TargetType, itemId: string) => {
|
||||
if (type === "floor") {
|
||||
const floor = this.hass.floors?.[itemId];
|
||||
return {
|
||||
name: floor?.name || itemId,
|
||||
iconPath: floor?.icon,
|
||||
fallbackIconPath: floor ? floorDefaultIconPath(floor) : mdiHome,
|
||||
};
|
||||
}
|
||||
if (type === "area") {
|
||||
const area = this.hass.areas?.[itemId];
|
||||
return {
|
||||
name: area?.name || itemId,
|
||||
iconPath: area?.icon,
|
||||
fallbackIconPath: mdiTextureBox,
|
||||
};
|
||||
}
|
||||
if (type === "device") {
|
||||
const device = this.hass.devices?.[itemId];
|
||||
|
||||
if (device?.primary_config_entry) {
|
||||
this._getDeviceDomain(device.primary_config_entry);
|
||||
}
|
||||
|
||||
return {
|
||||
name: device
|
||||
? computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
: itemId,
|
||||
fallbackIconPath: mdiDevices,
|
||||
};
|
||||
}
|
||||
if (type === "entity") {
|
||||
this._setDomainName(computeDomain(itemId));
|
||||
|
||||
const stateObj = this.hass.states[itemId];
|
||||
return {
|
||||
name: computeStateName(stateObj) || itemId,
|
||||
stateObject: stateObj,
|
||||
};
|
||||
}
|
||||
|
||||
// type label
|
||||
const label = this._labelRegistry.find((lab) => lab.label_id === itemId);
|
||||
let color = label?.color ? computeCssColor(label.color) : undefined;
|
||||
if (color?.startsWith("var(")) {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
color = computedStyles.getPropertyValue(
|
||||
color.substring(4, color.length - 1)
|
||||
);
|
||||
}
|
||||
if (color?.startsWith("#")) {
|
||||
color = hex2rgb(color).join(",");
|
||||
}
|
||||
return {
|
||||
name: label?.name || itemId,
|
||||
iconPath: label?.icon,
|
||||
fallbackIconPath: mdiLabel,
|
||||
color,
|
||||
};
|
||||
});
|
||||
|
||||
private _setDomainName(domain: string) {
|
||||
this._domainName = domainToName(this.hass.localize, domain);
|
||||
}
|
||||
|
||||
private async _getDeviceDomain(configEntryId: string) {
|
||||
try {
|
||||
const data = await getConfigEntry(this.hass, configEntryId);
|
||||
const domain = data.config_entry.domain;
|
||||
this._iconImg = brandsUrl(
|
||||
{
|
||||
domain: domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
);
|
||||
|
||||
this._setDomainName(domain);
|
||||
} catch {
|
||||
// failed to load config entry -> ignore
|
||||
}
|
||||
}
|
||||
|
||||
private _removeItem(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "remove-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleExpand(ev: MouseEvent) {
|
||||
ev.stopPropagation();
|
||||
fireEvent(this, "expand-target-item", {
|
||||
type: this.type,
|
||||
id: this.itemId,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
}
|
||||
wa-tag {
|
||||
background-color: var(--card-background-color);
|
||||
border-width: var(--ha-border-width-md);
|
||||
padding-inline-start: 0;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
wa-tag.entity {
|
||||
border-color: var(--ha-color-green-80);
|
||||
--background-color: var(--ha-color-green-80);
|
||||
}
|
||||
wa-tag.device {
|
||||
border-color: var(--ha-color-primary-80);
|
||||
--background-color: var(--ha-color-primary-80);
|
||||
}
|
||||
wa-tag.area {
|
||||
border-color: var(--ha-color-orange-80);
|
||||
--background-color: var(--ha-color-orange-80);
|
||||
}
|
||||
wa-tag.label {
|
||||
border-color: var(--color);
|
||||
--background-color: var(--color);
|
||||
--icon-primary-color: var(--primary-text-color);
|
||||
}
|
||||
wa-tag.replaced {
|
||||
border-color: var(--ha-color-border-warning-normal, var(--warning-color));
|
||||
--background-color: var(--warning-color);
|
||||
color: var(--ha-color-on-warning-normal, var(--warning-color));
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
background-color: var(--background-color);
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
padding: var(--ha-space-2) var(--ha-space-1);
|
||||
display: flex;
|
||||
}
|
||||
.expand-btn {
|
||||
--ha-icon-button-size: 16px;
|
||||
--mdc-icon-size: 14px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-target-picker-value-chip": HaTargetPickerValueChip;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, 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";
|
||||
@@ -25,6 +25,13 @@ export class HaTileContainer extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public actionHandlerOptions?: ActionHandlerOptions;
|
||||
|
||||
@state() private _hasFeatures = false;
|
||||
|
||||
private _handleFeaturesSlotChange(ev: Event) {
|
||||
this._hasFeatures =
|
||||
(ev.target as HTMLSlotElement).assignedElements().length > 0;
|
||||
}
|
||||
|
||||
private _handleFocus(ev: FocusEvent) {
|
||||
if ((ev.target as HTMLElement).matches(":focus-visible")) {
|
||||
this.setAttribute("focused", "");
|
||||
@@ -36,8 +43,12 @@ export class HaTileContainer extends LitElement {
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const containerOrientationClass =
|
||||
this.featurePosition === "inline" ? "horizontal" : "";
|
||||
const isInline = this.featurePosition === "inline";
|
||||
const containerClasses = {
|
||||
inline: isInline,
|
||||
"has-features-below": isInline && this._hasFeatures,
|
||||
"fixed-height": this.fixedInfoHeight,
|
||||
};
|
||||
const contentClasses = {
|
||||
vertical: this.vertical,
|
||||
"fixed-info-height": this.fixedInfoHeight,
|
||||
@@ -56,15 +67,21 @@ export class HaTileContainer extends LitElement {
|
||||
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
|
||||
</div>
|
||||
<div
|
||||
class="container ${containerOrientationClass}"
|
||||
class="container ${classMap(containerClasses)}"
|
||||
@action=${stopPropagation}
|
||||
@click=${stopPropagation}
|
||||
>
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
<div class="row">
|
||||
<div class="content ${classMap(contentClasses)}">
|
||||
<slot name="icon"></slot>
|
||||
<slot name="info" id="info"></slot>
|
||||
</div>
|
||||
<slot name="features-inline"></slot>
|
||||
</div>
|
||||
<slot name="features"></slot>
|
||||
<slot
|
||||
name="features"
|
||||
@slotchange=${this._handleFeaturesSlotChange}
|
||||
></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -97,7 +114,13 @@ export class HaTileContainer extends LitElement {
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
.container.horizontal {
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.container.inline .row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -153,13 +176,30 @@ export class HaTileContainer extends LitElement {
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
|
||||
.container.horizontal ::slotted([slot="features"]) {
|
||||
.container.inline ::slotted([slot="features-inline"]) {
|
||||
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
|
||||
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
|
||||
flex: none;
|
||||
--feature-height: var(--ha-space-9);
|
||||
padding: 0 var(--ha-space-3);
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
|
||||
.container.inline:not(.has-features-below)
|
||||
::slotted([slot="features-inline"]),
|
||||
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
|
||||
--feature-height: var(--ha-space-9);
|
||||
}
|
||||
|
||||
.container.inline.has-features-below ::slotted([slot="features"]) {
|
||||
/* keep both columns under the inline feature, which sits on the grid track */
|
||||
--ha-card-feature-column-gap: calc(
|
||||
var(--column-gap, 0px) + var(--ha-space-3) * 2
|
||||
);
|
||||
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
--ha-card-feature-divider-inset: calc(
|
||||
var(--ha-space-3) + var(--column-gap, 0px) / 2
|
||||
);
|
||||
}
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
|
||||
+210
-82
@@ -94,6 +94,45 @@ const localizeTimeString = (
|
||||
}
|
||||
};
|
||||
|
||||
// Seconds since midnight for a literal `HH:MM(:SS)` time, undefined for
|
||||
// anything else (entity ids contain a dot, and malformed input is ignored).
|
||||
const literalTimeToSeconds = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "string" || value.includes(".")) {
|
||||
return undefined;
|
||||
}
|
||||
const chunks = value.split(":");
|
||||
if (chunks.length < 2 || chunks.length > 3) {
|
||||
return undefined;
|
||||
}
|
||||
const hours = Number(chunks[0]);
|
||||
const minutes = Number(chunks[1]);
|
||||
const seconds = chunks.length > 2 ? Number(chunks[2]) : 0;
|
||||
if (
|
||||
!Number.isFinite(hours) ||
|
||||
!Number.isFinite(minutes) ||
|
||||
!Number.isFinite(seconds)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
};
|
||||
|
||||
const numericThresholdSuffix = (config: {
|
||||
above?: number | string;
|
||||
below?: number | string;
|
||||
}): "above" | "below" | "above_below" | undefined => {
|
||||
if (config.above !== undefined && config.below !== undefined) {
|
||||
return "above_below";
|
||||
}
|
||||
if (config.above !== undefined) {
|
||||
return "above";
|
||||
}
|
||||
if (config.below !== undefined) {
|
||||
return "below";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const formatNumericLimitValue = (
|
||||
hass: HomeAssistant,
|
||||
value?: number | string
|
||||
@@ -107,18 +146,26 @@ const formatNumericLimitValue = (
|
||||
: value;
|
||||
};
|
||||
|
||||
export interface DescribeOptions {
|
||||
// Skip the user defined alias and describe the underlying config.
|
||||
ignoreAlias?: boolean;
|
||||
// Leave the entities out of the sentence, for rows that render them as
|
||||
// target badges.
|
||||
hideEntities?: boolean;
|
||||
}
|
||||
|
||||
export const describeTrigger = (
|
||||
trigger: Trigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
): string => {
|
||||
try {
|
||||
const description = tryDescribeTrigger(
|
||||
trigger,
|
||||
hass,
|
||||
entityRegistry,
|
||||
ignoreAlias
|
||||
options
|
||||
);
|
||||
if (typeof description !== "string") {
|
||||
throw new Error(String(description));
|
||||
@@ -140,7 +187,7 @@ const tryDescribeTrigger = (
|
||||
trigger: Trigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
) => {
|
||||
if (isTriggerList(trigger)) {
|
||||
const triggers = ensureArray(trigger.triggers);
|
||||
@@ -156,14 +203,15 @@ const tryDescribeTrigger = (
|
||||
});
|
||||
}
|
||||
|
||||
if (trigger.alias && !ignoreAlias) {
|
||||
if (trigger.alias && !options?.ignoreAlias) {
|
||||
return trigger.alias;
|
||||
}
|
||||
|
||||
const description = describeLegacyTrigger(
|
||||
trigger as LegacyTrigger,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -187,7 +235,8 @@ const tryDescribeTrigger = (
|
||||
const describeLegacyTrigger = (
|
||||
trigger: LegacyTrigger,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
// Event Trigger
|
||||
if (trigger.trigger === "event" && trigger.event_type) {
|
||||
@@ -218,28 +267,16 @@ const describeLegacyTrigger = (
|
||||
}
|
||||
|
||||
// Numeric State Trigger
|
||||
if (trigger.trigger === "numeric_state" && trigger.entity_id) {
|
||||
const entities: string[] = [];
|
||||
if (
|
||||
trigger.trigger === "numeric_state" &&
|
||||
(trigger.entity_id || hideEntities)
|
||||
) {
|
||||
const states = hass.states;
|
||||
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
|
||||
if (Array.isArray(trigger.entity_id)) {
|
||||
for (const entity of trigger.entity_id.values()) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (trigger.entity_id) {
|
||||
entities.push(
|
||||
states[trigger.entity_id]
|
||||
? computeStateName(states[trigger.entity_id])
|
||||
: trigger.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
const attribute = trigger.attribute
|
||||
? stateObj
|
||||
? computeAttributeNameDisplay(
|
||||
@@ -255,6 +292,39 @@ const describeLegacyTrigger = (
|
||||
? describeDuration(hass.locale, trigger.for)
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(trigger);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.crossed_${suffix}`,
|
||||
{
|
||||
attribute: attribute,
|
||||
above: formatNumericLimitValue(hass, trigger.above),
|
||||
below: formatNumericLimitValue(hass, trigger.below),
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(trigger.entity_id)) {
|
||||
for (const entity of trigger.entity_id.values()) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (trigger.entity_id) {
|
||||
entities.push(
|
||||
states[trigger.entity_id]
|
||||
? computeStateName(states[trigger.entity_id])
|
||||
: trigger.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
if (trigger.above !== undefined && trigger.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
@@ -296,14 +366,14 @@ const describeLegacyTrigger = (
|
||||
|
||||
// State Trigger
|
||||
if (trigger.trigger === "state") {
|
||||
const entities: string[] = [];
|
||||
const states = hass.states;
|
||||
|
||||
const entityArray: string[] = ensureArray(trigger.entity_id);
|
||||
|
||||
const stateObj = hass.states[entityArray?.[0]] as HassEntity | undefined;
|
||||
|
||||
let attribute = "";
|
||||
if (trigger.attribute) {
|
||||
const stateObj = Array.isArray(trigger.entity_id)
|
||||
? hass.states[trigger.entity_id[0]]
|
||||
: (hass.states[trigger.entity_id] as HassEntity | undefined);
|
||||
attribute = stateObj
|
||||
? computeAttributeNameDisplay(
|
||||
hass.localize,
|
||||
@@ -314,17 +384,6 @@ const describeLegacyTrigger = (
|
||||
: trigger.attribute;
|
||||
}
|
||||
|
||||
const entityArray: string[] = ensureArray(trigger.entity_id);
|
||||
if (entityArray) {
|
||||
for (const entity of entityArray) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const stateObj = hass.states[entityArray[0]] as HassEntity | undefined;
|
||||
|
||||
let fromChoice = "other";
|
||||
let fromString = "";
|
||||
if (trigger.from !== undefined) {
|
||||
@@ -404,6 +463,32 @@ const describeLegacyTrigger = (
|
||||
duration = describeDuration(hass.locale, trigger.for) ?? "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.changed`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
anyChange: toChoice === "special" ? "true" : "false",
|
||||
fromChoice: fromChoice,
|
||||
fromString: fromString,
|
||||
toChoice: toChoice,
|
||||
toString: toString,
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (entityArray) {
|
||||
for (const entity of entityArray) {
|
||||
if (states[entity]) {
|
||||
entities.push(computeStateName(states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${triggerTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
@@ -893,14 +978,14 @@ export const describeCondition = (
|
||||
condition: Condition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
): string => {
|
||||
try {
|
||||
const description = tryDescribeCondition(
|
||||
condition,
|
||||
hass,
|
||||
entityRegistry,
|
||||
ignoreAlias
|
||||
options
|
||||
);
|
||||
if (typeof description !== "string") {
|
||||
throw new Error(String(description));
|
||||
@@ -922,7 +1007,7 @@ const tryDescribeCondition = (
|
||||
condition: Condition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
ignoreAlias = false
|
||||
options?: DescribeOptions
|
||||
) => {
|
||||
if (typeof condition === "string" && hasTemplate(condition)) {
|
||||
return hass.localize(
|
||||
@@ -930,7 +1015,7 @@ const tryDescribeCondition = (
|
||||
);
|
||||
}
|
||||
|
||||
if (condition.alias && !ignoreAlias) {
|
||||
if (condition.alias && !options?.ignoreAlias) {
|
||||
return condition.alias;
|
||||
}
|
||||
|
||||
@@ -952,7 +1037,8 @@ const tryDescribeCondition = (
|
||||
const description = describeLegacyCondition(
|
||||
condition as LegacyCondition,
|
||||
hass,
|
||||
entityRegistry
|
||||
entityRegistry,
|
||||
options?.hideEntities
|
||||
);
|
||||
|
||||
if (description) {
|
||||
@@ -978,7 +1064,8 @@ const tryDescribeCondition = (
|
||||
const describeLegacyCondition = (
|
||||
condition: LegacyCondition,
|
||||
hass: HomeAssistant,
|
||||
entityRegistry: EntityRegistryEntry[]
|
||||
entityRegistry: EntityRegistryEntry[],
|
||||
hideEntities = false
|
||||
) => {
|
||||
if (condition.condition === "or") {
|
||||
const conditions = ensureArray(condition.conditions);
|
||||
@@ -1035,17 +1122,20 @@ const describeLegacyCondition = (
|
||||
|
||||
// State Condition
|
||||
if (condition.condition === "state") {
|
||||
if (!condition.entity_id) {
|
||||
if (!condition.entity_id && !hideEntities) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.no_entity`
|
||||
);
|
||||
}
|
||||
|
||||
const stateObj = hass.states[
|
||||
Array.isArray(condition.entity_id)
|
||||
? condition.entity_id[0]
|
||||
: condition.entity_id
|
||||
] as HassEntity | undefined;
|
||||
|
||||
let attribute = "";
|
||||
if (condition.attribute) {
|
||||
const stateObj = Array.isArray(condition.entity_id)
|
||||
? hass.states[condition.entity_id[0]]
|
||||
: (hass.states[condition.entity_id] as HassEntity | undefined);
|
||||
attribute = stateObj
|
||||
? computeAttributeNameDisplay(
|
||||
hass.localize,
|
||||
@@ -1056,27 +1146,7 @@ const describeLegacyCondition = (
|
||||
: condition.attribute;
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(condition.entity_id)) {
|
||||
for (const entity of condition.entity_id.values()) {
|
||||
if (hass.states[entity]) {
|
||||
entities.push(computeStateName(hass.states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (condition.entity_id) {
|
||||
entities.push(
|
||||
hass.states[condition.entity_id]
|
||||
? computeStateName(hass.states[condition.entity_id])
|
||||
: condition.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
const states: string[] = [];
|
||||
const stateObj = hass.states[
|
||||
Array.isArray(condition.entity_id)
|
||||
? condition.entity_id[0]
|
||||
: condition.entity_id
|
||||
] as HassEntity | undefined;
|
||||
if (Array.isArray(condition.state)) {
|
||||
for (const state of condition.state.values()) {
|
||||
states.push(
|
||||
@@ -1093,7 +1163,7 @@ const describeLegacyCondition = (
|
||||
: state
|
||||
);
|
||||
}
|
||||
} else if (condition.state !== "") {
|
||||
} else if (condition.state != null && condition.state !== "") {
|
||||
states.push(
|
||||
stateObj
|
||||
? condition.attribute
|
||||
@@ -1114,6 +1184,37 @@ const describeLegacyCondition = (
|
||||
duration = describeDuration(hass.locale, condition.for) || "";
|
||||
}
|
||||
|
||||
if (hideEntities) {
|
||||
if (states.length === 0) {
|
||||
return hass.localize(`${conditionsTranslationBaseKey}.state.label`);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.is`,
|
||||
{
|
||||
hasAttribute: attribute !== "" ? "true" : "false",
|
||||
attribute: attribute,
|
||||
states: formatListWithOrs(hass.locale, states),
|
||||
hasDuration: duration !== "" ? "true" : "false",
|
||||
duration: duration,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entities: string[] = [];
|
||||
if (Array.isArray(condition.entity_id)) {
|
||||
for (const entity of condition.entity_id.values()) {
|
||||
if (hass.states[entity]) {
|
||||
entities.push(computeStateName(hass.states[entity]) || entity);
|
||||
}
|
||||
}
|
||||
} else if (condition.entity_id) {
|
||||
entities.push(
|
||||
hass.states[condition.entity_id]
|
||||
? computeStateName(hass.states[condition.entity_id])
|
||||
: condition.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.state.description.full`,
|
||||
{
|
||||
@@ -1136,15 +1237,14 @@ const describeLegacyCondition = (
|
||||
}
|
||||
|
||||
// Numeric State Condition
|
||||
if (condition.condition === "numeric_state" && condition.entity_id) {
|
||||
const entity_ids = ensureArray(condition.entity_id);
|
||||
if (
|
||||
condition.condition === "numeric_state" &&
|
||||
(condition.entity_id || hideEntities)
|
||||
) {
|
||||
const entity_ids = condition.entity_id
|
||||
? ensureArray(condition.entity_id)
|
||||
: [];
|
||||
const stateObj = hass.states[entity_ids[0]] as HassEntity | undefined;
|
||||
const entity = formatListWithAnds(
|
||||
hass.locale,
|
||||
entity_ids.map((id) =>
|
||||
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
|
||||
)
|
||||
);
|
||||
|
||||
const attribute = condition.attribute
|
||||
? stateObj
|
||||
@@ -1157,6 +1257,30 @@ const describeLegacyCondition = (
|
||||
: condition.attribute
|
||||
: undefined;
|
||||
|
||||
if (hideEntities) {
|
||||
const suffix = numericThresholdSuffix(condition);
|
||||
if (!suffix) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.label`
|
||||
);
|
||||
}
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.is_${suffix}`,
|
||||
{
|
||||
attribute,
|
||||
above: formatNumericLimitValue(hass, condition.above),
|
||||
below: formatNumericLimitValue(hass, condition.below),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const entity = formatListWithAnds(
|
||||
hass.locale,
|
||||
entity_ids.map((id) =>
|
||||
hass.states[id] ? computeStateName(hass.states[id]) : id || ""
|
||||
)
|
||||
);
|
||||
|
||||
if (condition.above !== undefined && condition.below !== undefined) {
|
||||
return hass.localize(
|
||||
`${conditionsTranslationBaseKey}.numeric_state.description.above-below`,
|
||||
@@ -1232,12 +1356,16 @@ const describeLegacyCondition = (
|
||||
|
||||
let hasTime = "";
|
||||
if (after !== undefined && before !== undefined) {
|
||||
if (
|
||||
typeof condition.after === "string" &&
|
||||
!condition.after.includes(".") &&
|
||||
typeof condition.before === "string" &&
|
||||
!condition.before.includes(".") &&
|
||||
condition.after > condition.before
|
||||
const afterSeconds = literalTimeToSeconds(condition.after);
|
||||
const beforeSeconds = literalTimeToSeconds(condition.before);
|
||||
if (beforeSeconds === 0) {
|
||||
// A window ending at midnight runs to the end of the day, so the
|
||||
// "before" boundary adds nothing to the summary.
|
||||
hasTime = "after";
|
||||
} else if (
|
||||
afterSeconds !== undefined &&
|
||||
beforeSeconds !== undefined &&
|
||||
afterSeconds > beforeSeconds
|
||||
) {
|
||||
hasTime = "after_before_or";
|
||||
} else {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
|
||||
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
|
||||
@@ -24,12 +25,17 @@ import {
|
||||
export interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain?: string;
|
||||
domain_name?: string;
|
||||
// Set when this device is a child rendered indented under its parent.
|
||||
is_child?: boolean;
|
||||
// Set on the last child of a parent so the tree connector draws its end.
|
||||
last?: boolean;
|
||||
}
|
||||
|
||||
export interface DeviceAreaLabel {
|
||||
areaName?: string;
|
||||
viaDeviceName?: string;
|
||||
viaDeviceAreaName?: string;
|
||||
parentDeviceName?: string;
|
||||
}
|
||||
|
||||
export interface GetDevicesOptions {
|
||||
@@ -41,6 +47,10 @@ export interface GetDevicesOptions {
|
||||
excludeDevices?: string[];
|
||||
value?: string;
|
||||
idPrefix?: string;
|
||||
// When set, order the result so children directly follow their parent and
|
||||
// flag them for indented rendering. Requires the picker to disable its own
|
||||
// sorting (no-sort) so this order is preserved.
|
||||
nested?: boolean;
|
||||
}
|
||||
|
||||
export const computeDeviceAreaLabel = (
|
||||
@@ -53,7 +63,8 @@ export const computeDeviceAreaLabel = (
|
||||
translationMetadata: HomeAssistant["translationMetadata"],
|
||||
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
|
||||
): DeviceAreaLabel => {
|
||||
const area = getDeviceArea(device, areas);
|
||||
// Pass devices so a child device inherits its parent's area.
|
||||
const area = getDeviceArea(device, areas, devices);
|
||||
|
||||
const viaDevice = device.via_device_id
|
||||
? devices[device.via_device_id]
|
||||
@@ -61,11 +72,23 @@ export const computeDeviceAreaLabel = (
|
||||
const viaDeviceName = viaDevice
|
||||
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
|
||||
: undefined;
|
||||
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
|
||||
const viaDeviceArea = viaDevice
|
||||
? getDeviceArea(viaDevice, areas, devices)
|
||||
: undefined;
|
||||
const viaDeviceAreaName = viaDeviceArea
|
||||
? computeAreaName(viaDeviceArea)
|
||||
: undefined;
|
||||
|
||||
// A child device is a logical part of its parent. We surface the parent name
|
||||
// only as a search term (below) — not in the area label, which stays the pure
|
||||
// (inherited) area. The nested tree rendering communicates the relationship.
|
||||
const parentDevice = device.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceNameDisplay(parentDevice, localize, states)
|
||||
: undefined;
|
||||
|
||||
const isRTL = computeRTL(language, translationMetadata.translations);
|
||||
|
||||
const areaName = area
|
||||
@@ -74,7 +97,7 @@ export const computeDeviceAreaLabel = (
|
||||
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
|
||||
: viaDeviceName || undefined;
|
||||
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName };
|
||||
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
|
||||
};
|
||||
|
||||
export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
@@ -102,6 +125,14 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
|
||||
name: "search_labels.viaDeviceArea",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.parentDeviceName",
|
||||
weight: 3,
|
||||
},
|
||||
{
|
||||
name: "search_labels.childDeviceNames",
|
||||
weight: 3,
|
||||
},
|
||||
];
|
||||
|
||||
export const getDevices = (
|
||||
@@ -118,6 +149,7 @@ export const getDevices = (
|
||||
excludeDevices,
|
||||
value,
|
||||
idPrefix = "",
|
||||
nested,
|
||||
} = options ?? {};
|
||||
|
||||
const devices = Object.values(hass.devices);
|
||||
@@ -125,26 +157,60 @@ export const getDevices = (
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
includeDeviceClasses ||
|
||||
entityFilter
|
||||
) {
|
||||
const filtersEntities =
|
||||
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
|
||||
|
||||
if (filtersEntities) {
|
||||
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
|
||||
}
|
||||
|
||||
// Targeting a device also targets its child devices (a parent inherits its
|
||||
// children's entities), so a device should match an entity-based filter when
|
||||
// it OR any of its children has a matching entity. Build a parent -> children
|
||||
// map and resolve each device's effective entity set accordingly. Nesting is
|
||||
// single-level, so one hop covers it.
|
||||
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
if (filtersEntities) {
|
||||
for (const device of devices) {
|
||||
if (device.parent_device_id) {
|
||||
const siblings = filterChildrenByParent.get(device.parent_device_id);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
filterChildrenByParent.set(device.parent_device_id, [device]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const effectiveEntities = (
|
||||
deviceId: string
|
||||
): EntityRegistryDisplayEntry[] => {
|
||||
const own = deviceEntityLookup[deviceId] ?? [];
|
||||
const children = filterChildrenByParent.get(deviceId);
|
||||
if (!children) {
|
||||
return own;
|
||||
}
|
||||
const combined = [...own];
|
||||
for (const child of children) {
|
||||
const childEntities = deviceEntityLookup[child.id];
|
||||
if (childEntities) {
|
||||
combined.push(...childEntities);
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
};
|
||||
|
||||
let inputDevices = devices.filter(
|
||||
(device) => device.id === value || !device.disabled_by
|
||||
);
|
||||
|
||||
if (includeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) =>
|
||||
return devEntities.some((entity) =>
|
||||
includeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -152,11 +218,11 @@ export const getDevices = (
|
||||
|
||||
if (excludeDomains) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return true;
|
||||
}
|
||||
return entities.every(
|
||||
return devEntities.every(
|
||||
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
|
||||
);
|
||||
});
|
||||
@@ -170,11 +236,11 @@ export const getDevices = (
|
||||
|
||||
if (includeDeviceClasses) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return deviceEntityLookup[device.id].some((entity) => {
|
||||
return devEntities.some((entity) => {
|
||||
const stateObj = hass.states[entity.entity_id];
|
||||
if (!stateObj) {
|
||||
return false;
|
||||
@@ -189,8 +255,8 @@ export const getDevices = (
|
||||
|
||||
if (entityFilter) {
|
||||
inputDevices = inputDevices.filter((device) => {
|
||||
const devEntities = deviceEntityLookup[device.id];
|
||||
if (!devEntities || !devEntities.length) {
|
||||
const devEntities = effectiveEntities(device.id);
|
||||
if (!devEntities.length) {
|
||||
return false;
|
||||
}
|
||||
return devEntities.some((entity) => {
|
||||
@@ -219,7 +285,7 @@ export const getDevices = (
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName } =
|
||||
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
|
||||
computeDeviceAreaLabel(
|
||||
device,
|
||||
hass.areas,
|
||||
@@ -256,10 +322,79 @@ export const getDevices = (
|
||||
domainName: domainName || null,
|
||||
viaDeviceName: viaDeviceName || null,
|
||||
viaDeviceArea: viaDeviceAreaName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
},
|
||||
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
|
||||
};
|
||||
});
|
||||
|
||||
return outputDevices;
|
||||
if (!nested) {
|
||||
return outputDevices;
|
||||
}
|
||||
|
||||
// Order children directly after their parent and flag them for indented
|
||||
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
|
||||
const itemByDeviceId = new Map<string, DevicePickerItem>();
|
||||
inputDevices.forEach((device, index) => {
|
||||
itemByDeviceId.set(device.id, outputDevices[index]);
|
||||
});
|
||||
const presentIds = new Set(inputDevices.map((device) => device.id));
|
||||
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
for (const device of inputDevices) {
|
||||
const parentId = device.parent_device_id;
|
||||
// A child whose parent was filtered out is shown as a top-level row.
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
|
||||
caseInsensitiveStringCompare(
|
||||
itemByDeviceId.get(a.id)!.primary,
|
||||
itemByDeviceId.get(b.id)!.primary,
|
||||
hass.locale.language
|
||||
);
|
||||
|
||||
topLevel.sort(compareByName);
|
||||
|
||||
const ordered: DevicePickerItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
const parentItem = itemByDeviceId.get(device.id)!;
|
||||
const children = childrenByParent.get(device.id);
|
||||
if (children) {
|
||||
children.sort(compareByName);
|
||||
// Add the children's names to the parent's search terms so a search that
|
||||
// matches a child keeps the parent visible (mirrors how a floor stays
|
||||
// visible when one of its areas matches).
|
||||
ordered.push({
|
||||
...parentItem,
|
||||
search_labels: {
|
||||
...parentItem.search_labels,
|
||||
childDeviceNames: children
|
||||
.map((child) => itemByDeviceId.get(child.id)!.primary)
|
||||
.join(" "),
|
||||
},
|
||||
});
|
||||
children.forEach((child, index) => {
|
||||
ordered.push({
|
||||
...itemByDeviceId.get(child.id)!,
|
||||
is_child: true,
|
||||
last: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
} else {
|
||||
ordered.push(parentItem);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,13 @@ export {
|
||||
subscribeDeviceRegistry,
|
||||
} from "../ws-device_registry";
|
||||
|
||||
export type DeviceDisabler =
|
||||
| "user"
|
||||
| "integration"
|
||||
| "config_entry"
|
||||
// The device's parent device is disabled (child devices only).
|
||||
| "device";
|
||||
|
||||
export interface DeviceRegistryEntry extends RegistryEntry {
|
||||
id: string;
|
||||
config_entries: string[];
|
||||
@@ -33,11 +40,116 @@ export interface DeviceRegistryEntry extends RegistryEntry {
|
||||
area_id: string | null;
|
||||
name_by_user: string | null;
|
||||
entry_type: "service" | null;
|
||||
disabled_by: "user" | "integration" | "config_entry" | null;
|
||||
disabled_by: DeviceDisabler | null;
|
||||
configuration_url: string | null;
|
||||
primary_config_entry: string | null;
|
||||
// Set when this device is a child (logical part) of another device.
|
||||
// null for regular top-level devices.
|
||||
parent_device_id: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A child device as it arrives over the wire from
|
||||
* `config/device_registry/list`. A child is a lightweight logical part of a
|
||||
* parent device (e.g. an outlet of a power strip); it only carries its own
|
||||
* fields and inherits the rest from its parent. It is never stored in
|
||||
* `hass.devices` in this shape — {@link resolveChildDevices} turns every child
|
||||
* into a complete {@link DeviceRegistryEntry} at ingestion, so downstream code
|
||||
* only ever sees full device entries.
|
||||
*/
|
||||
export interface ChildDeviceRegistryEntry extends RegistryEntry {
|
||||
id: string;
|
||||
config_entry_id: string;
|
||||
config_subentry_id: string | null;
|
||||
identifiers: [string, string][];
|
||||
name: string | null;
|
||||
name_by_user: string | null;
|
||||
labels: string[];
|
||||
area_id: string | null;
|
||||
disabled_by: DeviceDisabler | null;
|
||||
parent_device_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The raw, mixed list returned by `config/device_registry/list`: full devices
|
||||
* and stripped children, discriminated by the presence of full-device fields.
|
||||
*/
|
||||
export type DeviceRegistryListEntry =
|
||||
DeviceRegistryEntry | ChildDeviceRegistryEntry;
|
||||
|
||||
/** Whether a resolved device entry is a child (logical part) of another device. */
|
||||
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
|
||||
device.parent_device_id !== null;
|
||||
|
||||
/**
|
||||
* Devices whose effective area is the given area: devices with that area, and
|
||||
* child devices that inherit it because they have no area of their own. Mirrors
|
||||
* core's dr.async_entries_for_area, so a child device with a different explicit
|
||||
* area is not part of its parent's area.
|
||||
*/
|
||||
export const devicesInEffectiveArea = (
|
||||
devices: Record<string, DeviceRegistryEntry>,
|
||||
areaId: string
|
||||
): DeviceRegistryEntry[] =>
|
||||
Object.values(devices).filter((device) => {
|
||||
if (device.area_id) {
|
||||
return device.area_id === areaId;
|
||||
}
|
||||
if (device.parent_device_id) {
|
||||
return devices[device.parent_device_id]?.area_id === areaId;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
export interface DeviceRowItem {
|
||||
device: DeviceRegistryEntry;
|
||||
isChild: boolean;
|
||||
// True for the last child of a parent, so the tree connector draws its end.
|
||||
isLastChild: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order a flat device list so each child directly follows its parent, flagging
|
||||
* children for indented rendering. The incoming order of the top-level devices
|
||||
* (and of the children within each parent) is preserved. A child whose parent
|
||||
* is not in the list is treated as a top-level device.
|
||||
*/
|
||||
export const groupDevicesByParent = (
|
||||
devices: DeviceRegistryEntry[]
|
||||
): DeviceRowItem[] => {
|
||||
const presentIds = new Set(devices.map((device) => device.id));
|
||||
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
|
||||
const topLevel: DeviceRegistryEntry[] = [];
|
||||
|
||||
for (const device of devices) {
|
||||
const parentId = device.parent_device_id;
|
||||
if (parentId && presentIds.has(parentId)) {
|
||||
const siblings = childrenByParent.get(parentId);
|
||||
if (siblings) {
|
||||
siblings.push(device);
|
||||
} else {
|
||||
childrenByParent.set(parentId, [device]);
|
||||
}
|
||||
} else {
|
||||
topLevel.push(device);
|
||||
}
|
||||
}
|
||||
|
||||
const result: DeviceRowItem[] = [];
|
||||
for (const device of topLevel) {
|
||||
result.push({ device, isChild: false, isLastChild: false });
|
||||
const children = childrenByParent.get(device.id) ?? [];
|
||||
children.forEach((child, index) => {
|
||||
result.push({
|
||||
device: child,
|
||||
isChild: true,
|
||||
isLastChild: index === children.length - 1,
|
||||
});
|
||||
});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export type DeviceEntityDisplayLookup = Record<
|
||||
string,
|
||||
EntityRegistryDisplayEntry[]
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import type { DateRange } from "../common/datetime/calc_date_range";
|
||||
import { calcDateRange } from "../common/datetime/calc_date_range";
|
||||
import { formatTime24h } from "../common/datetime/format_time";
|
||||
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
|
||||
import { formatNumber } from "../common/number/format_number";
|
||||
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
|
||||
import { groupBy } from "../common/util/group-by";
|
||||
@@ -36,6 +37,7 @@ import type {
|
||||
import {
|
||||
fetchStatistics,
|
||||
getDisplayUnit,
|
||||
getStatisticLabel,
|
||||
getStatisticMetadata,
|
||||
VOLUME_UNITS,
|
||||
} from "./recorder";
|
||||
@@ -311,6 +313,59 @@ export interface EnergySourceByType {
|
||||
export const energySourcesByType = (prefs: EnergyPreferences) =>
|
||||
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
|
||||
|
||||
/**
|
||||
* Display name of a configured statistic. A name set by the user always wins;
|
||||
* otherwise the entity is named the same way the rest of the UI names
|
||||
* entities, so devices sharing an entity name stay distinguishable.
|
||||
* Statistics without an entity (external or removed) keep the statistic label.
|
||||
*/
|
||||
export const computeEnergyLabel = (
|
||||
hass: HomeAssistant,
|
||||
statisticId: string,
|
||||
statisticsMetaData?: StatisticsMetaData,
|
||||
customName?: string
|
||||
): string => {
|
||||
if (customName) {
|
||||
return customName;
|
||||
}
|
||||
|
||||
const stateObj = hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
|
||||
}
|
||||
|
||||
return getStatisticLabel(hass, statisticId, statisticsMetaData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Device labels keyed by statistic id. Cards that show live power or flow
|
||||
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
|
||||
* without the requested statistic are left out.
|
||||
*/
|
||||
export const computeEnergyDeviceLabels = (
|
||||
hass: HomeAssistant,
|
||||
devices: DeviceConsumptionEnergyPreference[],
|
||||
statsMetadata?: Record<string, StatisticsMetaData>,
|
||||
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
|
||||
): Record<string, string> => {
|
||||
const labels: Record<string, string> = {};
|
||||
|
||||
for (const device of devices) {
|
||||
const statisticId = device[statisticKey];
|
||||
if (statisticId) {
|
||||
labels[statisticId] = computeEnergyLabel(
|
||||
hass,
|
||||
statisticId,
|
||||
statsMetadata?.[statisticId],
|
||||
device.name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
export interface EnergyData {
|
||||
start: Date;
|
||||
end?: Date;
|
||||
|
||||
@@ -2,12 +2,15 @@ import type { Connection } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { LovelaceResource } from "../resource";
|
||||
import type { LovelaceStrategyConfig } from "./strategy";
|
||||
import type { LovelaceViewRawConfig } from "./view";
|
||||
import type {
|
||||
LovelaceDashboardBackgroundConfig,
|
||||
LovelaceViewRawConfig,
|
||||
} from "./view";
|
||||
|
||||
export interface LovelaceDashboardBaseConfig {}
|
||||
|
||||
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
|
||||
background?: string;
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
views: LovelaceViewRawConfig[];
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ export interface LovelaceViewBackgroundConfig {
|
||||
attachment?: "scroll" | "fixed";
|
||||
}
|
||||
|
||||
export type LovelaceDashboardBackgroundConfig =
|
||||
string | LovelaceViewBackgroundConfig;
|
||||
|
||||
export interface LovelaceViewHeaderConfig {
|
||||
card?: LovelaceCardConfig;
|
||||
layout?: "start" | "center" | "responsive";
|
||||
@@ -60,7 +63,7 @@ export interface LovelaceBaseViewConfig {
|
||||
show_icon_and_title?: boolean;
|
||||
theme?: string;
|
||||
panel?: boolean;
|
||||
background?: string | LovelaceViewBackgroundConfig;
|
||||
background?: LovelaceDashboardBackgroundConfig;
|
||||
visible?: boolean | ShowViewConfig[];
|
||||
subview?: boolean;
|
||||
back_path?: string;
|
||||
|
||||
@@ -45,6 +45,73 @@ export interface MatterNodeDiagnostics {
|
||||
|
||||
export type MatterPingResult = Record<string, boolean>;
|
||||
|
||||
export type MatterTopologyNodeKind =
|
||||
"matter" | "border_router" | "thread_unknown" | "wifi_ap";
|
||||
|
||||
export type MatterTopologyStrength =
|
||||
"strong" | "medium" | "weak" | "none" | "unknown";
|
||||
|
||||
export interface MatterTopologyDirectionInfo {
|
||||
strength: MatterTopologyStrength;
|
||||
lqi?: number | null;
|
||||
rssi?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyNode {
|
||||
id: string;
|
||||
kind: MatterTopologyNodeKind;
|
||||
network_type: string;
|
||||
node_id?: number | null;
|
||||
ha_device_id?: string | null;
|
||||
role?: string | null;
|
||||
available?: boolean | null;
|
||||
is_bridge?: boolean | null;
|
||||
ext_address?: string | null;
|
||||
rloc16?: number | null;
|
||||
ext_pan_id?: string | null;
|
||||
network_name?: string | null;
|
||||
ssid?: string | null;
|
||||
bssid?: string | null;
|
||||
host_name?: string | null;
|
||||
vendor_name?: string | null;
|
||||
model_name?: string | null;
|
||||
last_seen?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopologyConnection {
|
||||
source: string;
|
||||
target: string;
|
||||
network: string;
|
||||
strength: MatterTopologyStrength;
|
||||
source_to_target?: MatterTopologyDirectionInfo | null;
|
||||
target_to_source?: MatterTopologyDirectionInfo | null;
|
||||
via_route_table?: boolean | null;
|
||||
path_cost?: number | null;
|
||||
}
|
||||
|
||||
export interface MatterNetworkTopology {
|
||||
collected_at: number;
|
||||
nodes: MatterNetworkTopologyNode[];
|
||||
connections: MatterNetworkTopologyConnection[];
|
||||
}
|
||||
|
||||
export const fetchMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
refresh = false
|
||||
): Promise<MatterNetworkTopology> =>
|
||||
hass.callWS({
|
||||
type: "matter/network_topology",
|
||||
refresh,
|
||||
});
|
||||
|
||||
export const subscribeMatterNetworkTopology = (
|
||||
hass: HomeAssistant,
|
||||
callback: (topology: MatterNetworkTopology) => void
|
||||
): Promise<UnsubscribeFunc> =>
|
||||
hass.connection.subscribeMessage<MatterNetworkTopology>(callback, {
|
||||
type: "matter/subscribe_network_topology",
|
||||
});
|
||||
|
||||
export interface MatterCommissioningParameters {
|
||||
setup_pin_code: number;
|
||||
setup_manual_code: string;
|
||||
|
||||
+33
-11
@@ -14,6 +14,7 @@ import type {
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
type DeviceRegistryEntry,
|
||||
devicesInEffectiveArea,
|
||||
getDeviceIntegrationLookup,
|
||||
} from "./device/device_registry";
|
||||
import type {
|
||||
@@ -726,9 +727,10 @@ export const expandAreaTarget = (
|
||||
) => {
|
||||
const newEntities: string[] = [];
|
||||
const newDevices: string[] = [];
|
||||
Object.values(devices).forEach((device) => {
|
||||
// Devices of an area are its effective-area members: a child device inheriting
|
||||
// this area counts, a child with a different explicit area does not.
|
||||
devicesInEffectiveArea(devices, areaId).forEach((device) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -790,9 +792,8 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector: TargetSelector,
|
||||
entitySources?: EntitySources
|
||||
): boolean => {
|
||||
const hasMatchingdevice = Object.values(devices).some((device) => {
|
||||
if (
|
||||
device.area_id === areaId &&
|
||||
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
|
||||
(device) =>
|
||||
deviceMeetsTargetSelector(
|
||||
hass.states,
|
||||
Object.values(entities),
|
||||
@@ -800,11 +801,7 @@ export const areaMeetsTargetSelector = (
|
||||
targetSelector,
|
||||
entitySources
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
);
|
||||
if (hasMatchingdevice) {
|
||||
return true;
|
||||
}
|
||||
@@ -846,6 +843,8 @@ export const deviceMeetsTargetSelector = (
|
||||
}
|
||||
}
|
||||
if (targetSelector.target?.entity) {
|
||||
// Only the device's own entities: a child device is reached through the
|
||||
// device target itself, so a parent must not match on a child's behalf.
|
||||
const entities = entityRegistry.filter(
|
||||
(reg) => reg.device_id === device.id
|
||||
);
|
||||
@@ -1112,6 +1111,11 @@ export const resolveEntityIDs = (
|
||||
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
|
||||
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
|
||||
|
||||
// Only a directly targeted device pulls in its child devices. Devices that are
|
||||
// only reached through a label or an area must not, because core does not
|
||||
// inherit labels to children and resolves areas by effective area membership.
|
||||
const directDevices = new Set(targetDevices);
|
||||
|
||||
targetLabels.forEach((labelId) => {
|
||||
const expanded = expandLabelTarget(
|
||||
hass,
|
||||
@@ -1131,6 +1135,10 @@ export const resolveEntityIDs = (
|
||||
expanded.areas.forEach((id) => targetAreas.add(id));
|
||||
});
|
||||
|
||||
// Devices only reached through an area do not pull in entities that are
|
||||
// explicitly assigned to another area, matching core.
|
||||
const devicesNotViaArea = new Set(targetDevices);
|
||||
|
||||
targetAreas.forEach((areaId) => {
|
||||
const expanded = expandAreaTarget(
|
||||
hass,
|
||||
@@ -1143,6 +1151,16 @@ export const resolveEntityIDs = (
|
||||
expanded.entities.forEach((id) => targetEntities.add(id));
|
||||
});
|
||||
|
||||
// Targeting a device also targets its child devices, matching core's
|
||||
// server-side target resolution. Only direct device targets expand this way;
|
||||
// nesting is single-level, so one pass is enough.
|
||||
Object.values(devices).forEach((device) => {
|
||||
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
|
||||
targetDevices.add(device.id);
|
||||
devicesNotViaArea.add(device.id);
|
||||
}
|
||||
});
|
||||
|
||||
targetDevices.forEach((deviceId) => {
|
||||
const expanded = expandDeviceTarget(
|
||||
hass,
|
||||
@@ -1150,7 +1168,11 @@ export const resolveEntityIDs = (
|
||||
entities,
|
||||
targetSelector
|
||||
);
|
||||
expanded.entities.forEach((id) => targetEntities.add(id));
|
||||
expanded.entities.forEach((id) => {
|
||||
if (devicesNotViaArea.has(deviceId) || !entities[id]?.area_id) {
|
||||
targetEntities.add(id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(targetEntities);
|
||||
|
||||
+8
-4
@@ -7,7 +7,10 @@ import type { CallWS, HomeAssistant } from "../types";
|
||||
import type { AreaRegistryEntry } from "./area/area_registry";
|
||||
import type { FloorComboBoxItem } from "./area_floor_picker";
|
||||
import type { DevicePickerItem } from "./device/device_picker";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import {
|
||||
devicesInEffectiveArea,
|
||||
type DeviceRegistryEntry,
|
||||
} from "./device/device_registry";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
|
||||
import type { EntityComboBoxItem } from "./entity/entity_picker";
|
||||
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
|
||||
@@ -125,9 +128,7 @@ export const areaMeetsFilter = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
const areaDevices = Object.values(devices).filter(
|
||||
(device) => device.area_id === area.area_id
|
||||
);
|
||||
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
|
||||
|
||||
if (
|
||||
areaDevices.some((device) =>
|
||||
@@ -178,6 +179,9 @@ export const deviceMeetsFilter = (
|
||||
entityFilter?: HaEntityPickerEntityFilterFunc,
|
||||
includeSecondary = false
|
||||
): boolean => {
|
||||
// Only the device's own entities: child devices are targeted through the
|
||||
// device itself (see core's target resolution), not by making a parent match
|
||||
// on behalf of a child.
|
||||
const devEntities = Object.values(entities).filter(
|
||||
(entity) => entity.device_id === device.id
|
||||
);
|
||||
|
||||
@@ -184,6 +184,7 @@ export const checkForEntityUpdates = async (
|
||||
}
|
||||
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.checking_updates"),
|
||||
});
|
||||
|
||||
@@ -194,6 +195,7 @@ export const checkForEntityUpdates = async (
|
||||
if (computeDomain(event.data.entity_id) === "update") {
|
||||
updated++;
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.updates_refreshed", {
|
||||
count: updated,
|
||||
}),
|
||||
@@ -216,6 +218,7 @@ export const checkForEntityUpdates = async (
|
||||
|
||||
if (updated === 0) {
|
||||
showToast(element, {
|
||||
id: "check-updates",
|
||||
message: hass.localize("ui.panel.config.updates.no_new_updates"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ export enum VacuumEntityFeature {
|
||||
STOP = 8,
|
||||
RETURN_HOME = 16,
|
||||
FAN_SPEED = 32,
|
||||
BATTERY = 64,
|
||||
STATUS = 128,
|
||||
SEND_COMMAND = 256,
|
||||
LOCATE = 512,
|
||||
@@ -34,7 +33,6 @@ export enum VacuumEntityFeature {
|
||||
}
|
||||
|
||||
interface VacuumEntityAttributes extends HassEntityAttributeBase {
|
||||
battery_level?: number;
|
||||
fan_speed?: string;
|
||||
fan_speed_list?: string[];
|
||||
[key: string]: any;
|
||||
|
||||
+3
-2
@@ -566,9 +566,10 @@ export const getWeatherStateIcon = (
|
||||
if (userDefinedIcon) {
|
||||
return html`
|
||||
<div
|
||||
style="background-size: cover;${styleMap({
|
||||
style=${styleMap({
|
||||
"background-size": "cover",
|
||||
"background-image": userDefinedIcon,
|
||||
})}"
|
||||
})}
|
||||
></div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,87 @@ import type { Connection } from "home-assistant-js-websocket";
|
||||
import { createCollection } from "home-assistant-js-websocket";
|
||||
import type { Store } from "home-assistant-js-websocket/dist/store";
|
||||
import { debounce } from "../common/util/debounce";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
import type {
|
||||
ChildDeviceRegistryEntry,
|
||||
DeviceRegistryEntry,
|
||||
DeviceRegistryListEntry,
|
||||
} from "./device/device_registry";
|
||||
|
||||
// A full device carries fields that stripped children never do; use one of
|
||||
// those as the discriminant. This keeps "is a stripped child" decoupled from
|
||||
// "has a parent", so a hypothetical full-featured sub-device would still be
|
||||
// treated as a complete entry. We key off `connections` rather than
|
||||
// `config_entries` because the latter is a deprecated compatibility field core
|
||||
// plans to drop; `connections` is present on every full device and never on a
|
||||
// stripped child.
|
||||
const isChildEntry = (
|
||||
entry: DeviceRegistryListEntry
|
||||
): entry is ChildDeviceRegistryEntry => !("connections" in entry);
|
||||
|
||||
/**
|
||||
* Resolve the mixed device list from `config/device_registry/list` into a flat
|
||||
* list of complete {@link DeviceRegistryEntry} objects.
|
||||
*
|
||||
* Children are stripped over the wire and inherit the rest from their parent:
|
||||
* - config-entry association comes from the child's own `config_entry_id`, so
|
||||
* children still show up under their integration;
|
||||
* - hardware/display fields (manufacturer, model, versions, ...) are inherited
|
||||
* from the parent, since a child is a logical part of the same hardware;
|
||||
* - identity fields (`connections`, `via_device_id`) are NOT inherited — a
|
||||
* child is not the parent and has no connections of its own.
|
||||
*
|
||||
* Nesting is a single level (core rejects a child as another child's parent),
|
||||
* so no recursion is needed.
|
||||
*/
|
||||
export const resolveChildDevices = (
|
||||
entries: DeviceRegistryListEntry[]
|
||||
): DeviceRegistryEntry[] => {
|
||||
const parents = new Map<string, DeviceRegistryEntry>();
|
||||
for (const entry of entries) {
|
||||
if (!isChildEntry(entry)) {
|
||||
parents.set(entry.id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries.map((entry) => {
|
||||
if (!isChildEntry(entry)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
const parent = parents.get(entry.parent_device_id);
|
||||
|
||||
return {
|
||||
// Structural fields derived from the child's own config entry.
|
||||
config_entries: [entry.config_entry_id],
|
||||
config_entries_subentries: {
|
||||
[entry.config_entry_id]: [entry.config_subentry_id],
|
||||
},
|
||||
primary_config_entry: entry.config_entry_id,
|
||||
// Hardware/display fields inherited from the parent.
|
||||
manufacturer: parent?.manufacturer ?? null,
|
||||
model: parent?.model ?? null,
|
||||
model_id: parent?.model_id ?? null,
|
||||
sw_version: parent?.sw_version ?? null,
|
||||
hw_version: parent?.hw_version ?? null,
|
||||
serial_number: parent?.serial_number ?? null,
|
||||
entry_type: parent?.entry_type ?? null,
|
||||
configuration_url: parent?.configuration_url ?? null,
|
||||
// Identity fields — a child has none of its own.
|
||||
connections: [],
|
||||
via_device_id: null,
|
||||
// The child's own fields (id, name, area_id, labels, identifiers,
|
||||
// parent_device_id, ...) win over everything above.
|
||||
...entry,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchDeviceRegistry = (conn: Connection) =>
|
||||
conn.sendMessagePromise<DeviceRegistryEntry[]>({
|
||||
type: "config/device_registry/list",
|
||||
});
|
||||
conn
|
||||
.sendMessagePromise<DeviceRegistryListEntry[]>({
|
||||
type: "config/device_registry/list",
|
||||
})
|
||||
.then(resolveChildDevices);
|
||||
|
||||
const subscribeDeviceRegistryUpdates = (
|
||||
conn: Connection,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { callWS } from "../util/websocket";
|
||||
import type { DeviceRegistryEntry } from "./device/device_registry";
|
||||
|
||||
export enum InclusionState {
|
||||
/** The controller isn't doing anything regarding inclusion. */
|
||||
@@ -465,6 +466,27 @@ export interface RequestedGrant {
|
||||
clientSideAuth: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Z-Wave node ID of a device from its registry identifiers, which have
|
||||
* the form `<home id>-<node id>[-<manufacturer>:<product type>:<product id>]`.
|
||||
* Returns undefined for devices without a node, e.g. provisioning entries.
|
||||
*/
|
||||
export const getNodeIdFromDevice = (
|
||||
device: DeviceRegistryEntry
|
||||
): number | undefined => {
|
||||
for (const [domain, identifier] of device.identifiers) {
|
||||
// a provisioning entry is identified by its DSK, whose blocks parse as numbers
|
||||
if (domain !== "zwave_js" || identifier.startsWith("provision_")) {
|
||||
continue;
|
||||
}
|
||||
const nodeId = parseInt(identifier.split("-")[1]);
|
||||
if (!isNaN(nodeId)) {
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const invokeZWaveCCApi = <T = unknown>(
|
||||
hass: HomeAssistant,
|
||||
device_id: string,
|
||||
|
||||
@@ -7,21 +7,11 @@ import { isNumericEntity } from "../../data/history";
|
||||
import { CONTINUOUS_DOMAINS } from "../../data/logbook";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export const MORE_INFO_VIEWS = [
|
||||
"info",
|
||||
"history",
|
||||
"settings",
|
||||
"related",
|
||||
"add_to",
|
||||
"details",
|
||||
] as const;
|
||||
|
||||
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
|
||||
|
||||
export const isMoreInfoView = (
|
||||
value: string | undefined
|
||||
): value is MoreInfoView =>
|
||||
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
|
||||
export {
|
||||
isMoreInfoView,
|
||||
MORE_INFO_VIEWS,
|
||||
type MoreInfoView,
|
||||
} from "./more-info-view";
|
||||
|
||||
export const DOMAINS_NO_INFO = ["camera", "configurator"];
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createContext } from "@lit/context";
|
||||
|
||||
export interface MoreInfoContext {
|
||||
hash: URLSearchParams;
|
||||
setHashParam: (key: string, value?: string) => void;
|
||||
}
|
||||
|
||||
export const moreInfoContext =
|
||||
createContext<MoreInfoContext>("more-info-context");
|
||||
@@ -159,23 +159,6 @@ class MoreInfoVacuum extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Use deprecated battery_level and battery_icon attributes
|
||||
if (
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
|
||||
this.stateObj.attributes.battery_level
|
||||
) {
|
||||
return html`
|
||||
<span class="battery" slot="after-time">
|
||||
<span
|
||||
>${Math.round(
|
||||
this.stateObj.attributes.battery_level
|
||||
)}${blankBeforePercent(this._i18n.locale)}%</span
|
||||
>
|
||||
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
|
||||
import { formatTime } from "../../../common/datetime/format_time";
|
||||
import { transform } from "../../../common/decorators/transform";
|
||||
import { formatNumber } from "../../../common/number/format_number";
|
||||
import type { HASSDomEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-relative-time";
|
||||
import "../../../components/ha-spinner";
|
||||
@@ -48,11 +49,16 @@ import type {
|
||||
HomeAssistantFormatters,
|
||||
HomeAssistantInternationalization,
|
||||
} from "../../../types";
|
||||
import { moreInfoContext, type MoreInfoContext } from "../context";
|
||||
|
||||
@customElement("more-info-weather")
|
||||
class MoreInfoWeather extends LitElement {
|
||||
@property({ attribute: false }) public stateObj?: WeatherEntity;
|
||||
|
||||
@state()
|
||||
@consume({ context: moreInfoContext, subscribe: true })
|
||||
private _moreInfoContext?: MoreInfoContext;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: HomeAssistantInternationalization;
|
||||
@@ -127,15 +133,33 @@ class MoreInfoWeather extends LitElement {
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
|
||||
if ((changedProps.has("stateObj") || !this._subscribed) && this.stateObj) {
|
||||
if (
|
||||
(changedProps.has("stateObj") ||
|
||||
changedProps.has("_moreInfoContext") ||
|
||||
!this._subscribed) &&
|
||||
this.stateObj
|
||||
) {
|
||||
const oldState = changedProps.get("stateObj") as
|
||||
WeatherEntity | undefined;
|
||||
if (
|
||||
oldState?.entity_id !== this.stateObj?.entity_id ||
|
||||
changedProps.has("_moreInfoContext") ||
|
||||
!this._subscribed
|
||||
) {
|
||||
this._forecastType = getDefaultForecastType(this.stateObj);
|
||||
this._subscribeForecastEvents();
|
||||
const supportedForecastTypes = getSupportedForecastTypes(this.stateObj);
|
||||
const requestedForecastType =
|
||||
this._moreInfoContext?.hash.get("forecast");
|
||||
const selectedForecastType =
|
||||
supportedForecastTypes.find(
|
||||
(forecastType) => forecastType === requestedForecastType
|
||||
) ?? getDefaultForecastType(this.stateObj);
|
||||
if (selectedForecastType !== requestedForecastType) {
|
||||
this._moreInfoContext?.setHashParam("forecast", selectedForecastType);
|
||||
}
|
||||
if (this._forecastType !== selectedForecastType || !this._subscribed) {
|
||||
this._forecastType = selectedForecastType;
|
||||
this._subscribeForecastEvents();
|
||||
}
|
||||
}
|
||||
} else if (changedProps.has("_forecastType")) {
|
||||
this._subscribeForecastEvents();
|
||||
@@ -509,8 +533,17 @@ class MoreInfoWeather extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleForecastTypeChanged(ev: CustomEvent): void {
|
||||
private _handleForecastTypeChanged(
|
||||
ev: HASSDomEvent<{ name: ModernForecastType }>
|
||||
): void {
|
||||
if (
|
||||
!this.stateObj ||
|
||||
!getSupportedForecastTypes(this.stateObj).includes(ev.detail.name)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
this._forecastType = ev.detail.name;
|
||||
this._moreInfoContext?.setHashParam("forecast", this._forecastType);
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { consume } from "@lit/context";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeFloorName } from "../../common/entity/compute_floor_name";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import checkValidDate from "../../common/datetime/check_valid_date";
|
||||
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
|
||||
import "../../components/ha-attribute-value";
|
||||
@@ -11,7 +16,9 @@ import "../../components/item/ha-list-item-value";
|
||||
import "../../components/list/ha-grouped-list";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeShownAttributes } from "../../data/entity/entity_attributes";
|
||||
import { labelsContext } from "../../data/context";
|
||||
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import type { LabelRegistryEntry } from "../../data/label/label_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../../components/ha-yaml-editor";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
@@ -27,6 +34,7 @@ interface DetailsViewParams {
|
||||
interface DetailEntry {
|
||||
translationKey: LocalizeKeys;
|
||||
value: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
@customElement("ha-more-info-details")
|
||||
@@ -41,8 +49,15 @@ class HaMoreInfoDetails extends LitElement {
|
||||
|
||||
@state() private _stateObj?: HassEntity;
|
||||
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues<this>): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("entry") && this.entry) {
|
||||
this.hass.loadBackendTranslation("title", [this.entry.platform]);
|
||||
}
|
||||
if (changedProps.has("params") || changedProps.has("hass")) {
|
||||
if (this.params?.entityId && this.hass) {
|
||||
this._stateObj = this.hass.states[this.params.entityId];
|
||||
@@ -55,9 +70,93 @@ class HaMoreInfoDetails extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const { stateEntries, attributes, yamlData } = this._getDetailData(
|
||||
this._stateObj
|
||||
const {
|
||||
stateEntries,
|
||||
attributes,
|
||||
yamlData: stateYamlData,
|
||||
} = this._getDetailData(this._stateObj);
|
||||
const { floor, area, device } = getEntityContext(
|
||||
this._stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
const areaName = area ? (computeAreaName(area) ?? area.area_id) : undefined;
|
||||
const deviceName = device
|
||||
? computeDeviceNameDisplay(device, this.hass.localize, this.hass.states)
|
||||
: undefined;
|
||||
const integrationName = this.entry?.platform
|
||||
? this.hass.localize(`component.${this.entry.platform}.title`) ||
|
||||
this.entry.platform
|
||||
: undefined;
|
||||
const labelNames =
|
||||
this.entry?.labels.map(
|
||||
(labelId) =>
|
||||
this._labels?.find((label) => label.label_id === labelId)?.name ??
|
||||
labelId
|
||||
) ?? [];
|
||||
const contextEntries: DetailEntry[] = [];
|
||||
|
||||
if (floor && floorName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.dialogs.more_info_control.floor",
|
||||
value: floorName,
|
||||
href: "/config/areas/dashboard",
|
||||
});
|
||||
}
|
||||
if (area && areaName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.area",
|
||||
value: areaName,
|
||||
href: `/config/areas/area/${area.area_id}`,
|
||||
});
|
||||
}
|
||||
if (device && deviceName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.device",
|
||||
value: deviceName,
|
||||
href: `/config/devices/device/${device.id}`,
|
||||
});
|
||||
}
|
||||
if (this.entry?.platform && integrationName) {
|
||||
contextEntries.push({
|
||||
translationKey: "ui.components.related-items.integration",
|
||||
value: integrationName,
|
||||
href: this.entry.config_entry_id
|
||||
? `/config/integrations/integration/${this.entry.platform}#config_entry=${this.entry.config_entry_id}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const entityEntries: DetailEntry[] = [
|
||||
{
|
||||
translationKey: "ui.dialogs.more_info_control.entity_id",
|
||||
value: this.params.entityId,
|
||||
},
|
||||
{
|
||||
translationKey: "ui.dialogs.more_info_control.labels",
|
||||
value: labelNames.join(", ") || this.hass.localize("ui.common.none"),
|
||||
},
|
||||
];
|
||||
const yamlData = {
|
||||
...(contextEntries.length
|
||||
? {
|
||||
context: {
|
||||
...(floorName ? { floor: floorName } : {}),
|
||||
...(areaName ? { area: areaName } : {}),
|
||||
...(deviceName ? { device: deviceName } : {}),
|
||||
...(integrationName ? { integration: integrationName } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
entity: {
|
||||
entity_id: this.params.entityId,
|
||||
labels: labelNames,
|
||||
},
|
||||
...stateYamlData,
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
@@ -70,19 +169,32 @@ class HaMoreInfoDetails extends LitElement {
|
||||
in-dialog
|
||||
></ha-yaml-editor>`
|
||||
: html`
|
||||
${
|
||||
contextEntries.length
|
||||
? html`<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.context"
|
||||
)}
|
||||
>
|
||||
${this._renderEntries(contextEntries)}
|
||||
</ha-grouped-list>`
|
||||
: nothing
|
||||
}
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.components.entity.entity-state-picker.state"
|
||||
)}
|
||||
>
|
||||
${stateEntries.map(
|
||||
(entry) =>
|
||||
html`<ha-list-item-value
|
||||
.label=${this.hass.localize(entry.translationKey)}
|
||||
>
|
||||
${entry.value}
|
||||
</ha-list-item-value>`
|
||||
${this._renderEntries(stateEntries)}
|
||||
</ha-grouped-list>
|
||||
|
||||
<ha-grouped-list
|
||||
.header=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.entity"
|
||||
)}
|
||||
>
|
||||
${this._renderEntries(entityEntries)}
|
||||
</ha-grouped-list>
|
||||
|
||||
<ha-grouped-list
|
||||
@@ -163,6 +275,20 @@ class HaMoreInfoDetails extends LitElement {
|
||||
: value;
|
||||
}
|
||||
|
||||
private _renderEntries(entries: DetailEntry[]) {
|
||||
return entries.map(
|
||||
(entry) => html`
|
||||
<ha-list-item-value .label=${this.hass.localize(entry.translationKey)}>
|
||||
${
|
||||
entry.href
|
||||
? html`<a href=${entry.href}>${entry.value}</a>`
|
||||
: entry.value
|
||||
}
|
||||
</ha-list-item-value>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
private _renderAttributes(attributes: string[]) {
|
||||
if (attributes.length === 0) {
|
||||
return html`<div class="empty">
|
||||
@@ -234,6 +360,10 @@ class HaMoreInfoDetails extends LitElement {
|
||||
margin-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--secondary-text-color);
|
||||
text-align: center;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
mdiTransitConnectionVariant,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { provide } from "@lit/context";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
@@ -43,8 +44,10 @@ import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-reques
|
||||
import {
|
||||
getHistoryState,
|
||||
navigate,
|
||||
replaceCurrentUrl,
|
||||
updateHistoryState,
|
||||
} from "../../common/navigate";
|
||||
import { createMoreInfoUrl } from "../../common/url/more-info-query-params";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { withViewTransition } from "../../common/util/view-transition";
|
||||
@@ -85,6 +88,7 @@ import {
|
||||
EDITABLE_DOMAINS_WITH_UNIQUE_ID,
|
||||
type MoreInfoView,
|
||||
} from "./const";
|
||||
import { moreInfoContext, type MoreInfoContext } from "./context";
|
||||
import "./controls/more-info-default";
|
||||
import type { FavoritesDialogContext } from "./favorites";
|
||||
import { getFavoritesDialogHandler } from "./favorites";
|
||||
@@ -102,6 +106,9 @@ export interface MoreInfoDialogParams {
|
||||
tab?: MoreInfoView;
|
||||
large?: boolean;
|
||||
data?: Record<string, any>;
|
||||
hash?: URLSearchParams;
|
||||
fromUrl?: boolean;
|
||||
returnUrl?: string;
|
||||
parentElement?: LitElement;
|
||||
}
|
||||
|
||||
@@ -147,6 +154,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
|
||||
@state() private _data?: Record<string, any>;
|
||||
|
||||
@provide({ context: moreInfoContext })
|
||||
@state()
|
||||
private _moreInfoContext: MoreInfoContext = this._createMoreInfoContext();
|
||||
|
||||
private _returnUrl?: string;
|
||||
|
||||
@state() private _currView: MoreInfoView = DEFAULT_VIEW;
|
||||
|
||||
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
|
||||
@@ -181,6 +194,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
const view = params.view || params.tab || DEFAULT_VIEW;
|
||||
|
||||
this._data = params.data;
|
||||
this._moreInfoContext = this._createMoreInfoContext(params.hash);
|
||||
this._returnUrl = params.returnUrl;
|
||||
this._currView = view;
|
||||
this._initialView = view;
|
||||
this._childViewStack = [];
|
||||
@@ -216,6 +231,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
|
||||
private _dialogClosed() {
|
||||
if (this._returnUrl) {
|
||||
replaceCurrentUrl(this._returnUrl);
|
||||
}
|
||||
this._entityId = undefined;
|
||||
this._parentEntityIds = [];
|
||||
this._entry = undefined;
|
||||
@@ -224,6 +242,8 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
this._initialView = DEFAULT_VIEW;
|
||||
this._currView = DEFAULT_VIEW;
|
||||
this._childViewStack = [];
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
this._returnUrl = undefined;
|
||||
this._isEscapeEnabled = true;
|
||||
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
|
||||
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
|
||||
@@ -271,7 +291,10 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
return entity?.device_id ?? null;
|
||||
}
|
||||
|
||||
private _setView(view: MoreInfoView) {
|
||||
private _setView(view: MoreInfoView, preserveHash = false) {
|
||||
if (view !== this._currView && !preserveHash) {
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
}
|
||||
updateHistoryState({
|
||||
dialogParams: {
|
||||
...getHistoryState()?.dialogParams,
|
||||
@@ -279,6 +302,38 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
},
|
||||
});
|
||||
this._currView = view;
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _syncUrl() {
|
||||
if (!this._returnUrl || !this._entityId) {
|
||||
return;
|
||||
}
|
||||
replaceCurrentUrl(
|
||||
createMoreInfoUrl(this._returnUrl, {
|
||||
entityId: this._entityId,
|
||||
view: this._currView,
|
||||
hash: this._moreInfoContext.hash,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private _createMoreInfoContext(hash?: URLSearchParams): MoreInfoContext {
|
||||
return {
|
||||
hash: new URLSearchParams(hash),
|
||||
setHashParam: (key, value) => this._setHashParam(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
private _setHashParam(key: string, value?: string) {
|
||||
const hash = new URLSearchParams(this._moreInfoContext.hash);
|
||||
if (value) {
|
||||
hash.set(key, value);
|
||||
} else {
|
||||
hash.delete(key);
|
||||
}
|
||||
this._moreInfoContext = this._createMoreInfoContext(hash);
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _goBack() {
|
||||
@@ -306,7 +361,9 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
if (this._parentEntityIds.length > 0) {
|
||||
this._entityId = this._parentEntityIds.pop();
|
||||
this._currView = DEFAULT_VIEW;
|
||||
this._moreInfoContext = this._createMoreInfoContext();
|
||||
this._loadEntityRegistryEntry();
|
||||
this._syncUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1007,19 +1064,22 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
|
||||
}
|
||||
const view = ev.detail.view || ev.detail.tab || DEFAULT_VIEW;
|
||||
if (entityId === this._entityId) {
|
||||
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
this._setView(view);
|
||||
this._setView(view, true);
|
||||
return;
|
||||
}
|
||||
this._parentEntityIds = [...this._parentEntityIds, this._entityId!];
|
||||
this._entityId = entityId;
|
||||
this._moreInfoContext = this._createMoreInfoContext(ev.detail.hash);
|
||||
this._currView = view === "details" ? view : DEFAULT_VIEW;
|
||||
this._initialView = view;
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
this._childViewStack = [];
|
||||
this._loadEntityRegistryEntry();
|
||||
this._syncUrl();
|
||||
}
|
||||
|
||||
private _enableEscapeKeyClose = () => {
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const MORE_INFO_VIEWS = [
|
||||
"info",
|
||||
"history",
|
||||
"settings",
|
||||
"related",
|
||||
"add_to",
|
||||
"details",
|
||||
] as const;
|
||||
|
||||
export type MoreInfoView = (typeof MORE_INFO_VIEWS)[number];
|
||||
|
||||
export const isMoreInfoView = (
|
||||
value: string | undefined
|
||||
): value is MoreInfoView =>
|
||||
value !== undefined && (MORE_INFO_VIEWS as readonly string[]).includes(value);
|
||||
@@ -60,6 +60,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { isIosApp } from "../../util/is_ios";
|
||||
import { isMac } from "../../util/is_mac";
|
||||
import { showConfirmationDialog } from "../generic/show-dialog-box";
|
||||
import "../restart/automation-restart-status";
|
||||
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
|
||||
import { showVoiceCommandDialog } from "../voice-command-dialog/show-ha-voice-command-dialog";
|
||||
import {
|
||||
@@ -799,9 +800,9 @@ export class QuickBar extends LitElement {
|
||||
title: this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_title`
|
||||
),
|
||||
text: this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_description`
|
||||
),
|
||||
text: html`${this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_description`
|
||||
)}<br /><br /><automation-restart-status></automation-restart-status>`,
|
||||
confirmText: this.hass.localize(
|
||||
`ui.dialogs.restart.${actionItem.action}.confirm_action`
|
||||
),
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { LitElement, html } from "lit";
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import { STRINGS_SEPARATOR_DOT } from "../../common/const";
|
||||
import {
|
||||
formattersContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
} from "../../data/context";
|
||||
|
||||
const ENTITY_NAME_FORMAT: EntityNameItem[] = [
|
||||
{ type: "entity" },
|
||||
{ type: "area" },
|
||||
] as const;
|
||||
const ENTITY_NAME_OPTIONS = { separator: STRINGS_SEPARATOR_DOT } as const;
|
||||
|
||||
@customElement("automation-restart-status")
|
||||
class AutomationRestartStatus extends LitElement {
|
||||
@state()
|
||||
@consume({ context: formattersContext, subscribe: true })
|
||||
private _formatters!: ContextType<typeof formattersContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n!: ContextType<typeof internationalizationContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: statesContext, subscribe: true })
|
||||
private _states!: ContextType<typeof statesContext>;
|
||||
|
||||
protected render() {
|
||||
const automations = Object.values(this._states).filter((s) => {
|
||||
const domain = computeDomain(s.entity_id);
|
||||
return (
|
||||
(domain === "script" || domain === "automation") && s.attributes.current
|
||||
);
|
||||
});
|
||||
|
||||
return automations.length
|
||||
? html`${this._i18n.localize("ui.dialogs.restart.interrupt_automations")}
|
||||
<ul>
|
||||
${automations.map((a) => html`<li>${this._formatters.formatEntityName(a, ENTITY_NAME_FORMAT, ENTITY_NAME_OPTIONS)}</li>`)}
|
||||
</ul>`
|
||||
: html`${this._i18n.localize("ui.dialogs.restart.no_interrupt_automations")}`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"automation-restart-status": AutomationRestartStatus;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
showConfirmationDialog,
|
||||
} from "../generic/show-dialog-box";
|
||||
import { showRestartWaitDialog } from "./show-dialog-restart";
|
||||
import "./automation-restart-status";
|
||||
|
||||
@customElement("dialog-restart")
|
||||
class DialogRestart extends LitElement {
|
||||
@@ -357,12 +358,12 @@ class DialogRestart extends LitElement {
|
||||
const confirmed = await showConfirmationDialog(this, {
|
||||
title: this.hass.localize(`ui.dialogs.restart.${action}.confirm_title`),
|
||||
text: html`${this.hass.localize(
|
||||
`ui.dialogs.restart.${action}.confirm_description`
|
||||
)}${
|
||||
backupProgressMessage
|
||||
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
|
||||
: nothing
|
||||
}`,
|
||||
`ui.dialogs.restart.${action}.confirm_description`
|
||||
)}${
|
||||
backupProgressMessage
|
||||
? html`<br /><br /><ha-alert>${backupProgressMessage}</ha-alert>`
|
||||
: nothing
|
||||
} <br /><br /><automation-restart-status></automation-restart-status>`,
|
||||
confirmText: this.hass.localize(
|
||||
`ui.dialogs.restart.${action}.confirm_action${backupState === "idle" ? "" : "_backup"}`
|
||||
),
|
||||
|
||||
@@ -74,11 +74,6 @@ export class MockVacuumEntity extends MockBaseEntity {
|
||||
stateAttrs.fan_speed = attrs.fan_speed ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.BATTERY)) {
|
||||
stateAttrs.battery_level = attrs.battery_level ?? null;
|
||||
stateAttrs.battery_icon = attrs.battery_icon ?? null;
|
||||
}
|
||||
|
||||
if (supportsFeatureFromAttributes(attrs, VacuumEntityFeature.STATUS)) {
|
||||
stateAttrs.status = attrs.status ?? null;
|
||||
}
|
||||
|
||||
@@ -28,12 +28,13 @@ import type {
|
||||
SortingDirection,
|
||||
} from "../components/data-table/ha-data-table";
|
||||
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
|
||||
import "../components/ha-adaptive-dialog";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-dialog";
|
||||
import "../components/ha-dialog-footer";
|
||||
import "../components/ha-dropdown";
|
||||
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
|
||||
import "../components/ha-dropdown-item";
|
||||
import "../components/ha-filter-pane-chip";
|
||||
import "../components/ha-icon-button";
|
||||
import "../components/ha-svg-icon";
|
||||
import "../components/input/ha-input-search";
|
||||
@@ -237,20 +238,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
const localize = this.localizeFunc || this.hass.localize;
|
||||
const showPane = this._showPaneController.value ?? !this.narrow;
|
||||
const filterButton = this.hasFilters
|
||||
? html`<div class="relative">
|
||||
<ha-assist-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.active=${this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiFilterVariant}></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<div class="badge">${this.filters}</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>`
|
||||
? html`<ha-filter-pane-chip
|
||||
.label=${localize("ui.components.subpage-data-table.filters")}
|
||||
.path=${mdiFilterVariant}
|
||||
.count=${this.filters}
|
||||
.active=${!!this.filters}
|
||||
@click=${this._toggleFilters}
|
||||
></ha-filter-pane-chip>`
|
||||
: nothing;
|
||||
|
||||
const selectModeBtn =
|
||||
@@ -471,18 +465,14 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
? nothing
|
||||
: html`<div class="pane" slot="pane">
|
||||
<div class="table-header">
|
||||
<ha-assist-chip
|
||||
<ha-filter-pane-chip
|
||||
.label=${localize(
|
||||
"ui.components.subpage-data-table.filters"
|
||||
)}
|
||||
.path=${mdiFilterVariant}
|
||||
active
|
||||
@click=${this._toggleFilters}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiFilterVariant}
|
||||
></ha-svg-icon>
|
||||
</ha-assist-chip>
|
||||
></ha-filter-pane-chip>
|
||||
${
|
||||
this.filters
|
||||
? html`<ha-icon-button
|
||||
@@ -576,16 +566,17 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
</hass-tabs-subpage>
|
||||
${
|
||||
this.showFilters && !showPane
|
||||
? html`<ha-dialog
|
||||
.open=${true}
|
||||
? html`<ha-adaptive-dialog
|
||||
open
|
||||
flexcontent
|
||||
width="full"
|
||||
header-title=${localize("ui.components.subpage-data-table.filters")}
|
||||
@closed=${this._closeFilters}
|
||||
>
|
||||
<ha-icon-button
|
||||
slot="headerNavigationIcon"
|
||||
data-dialog="close"
|
||||
.path=${mdiClose}
|
||||
@click=${this._closeFilters}
|
||||
.label=${localize(
|
||||
"ui.components.subpage-data-table.close_filter"
|
||||
)}
|
||||
@@ -606,13 +597,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
<slot name="filter-pane"></slot>
|
||||
</div>
|
||||
<ha-dialog-footer slot="footer">
|
||||
<ha-button slot="primaryAction" @click=${this._closeFilters}>
|
||||
<ha-button slot="primaryAction" data-dialog="close">
|
||||
${localize("ui.components.subpage-data-table.show_results", {
|
||||
number: this.data.length,
|
||||
})}
|
||||
</ha-button>
|
||||
</ha-dialog-footer>
|
||||
</ha-dialog>`
|
||||
</ha-adaptive-dialog>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
@@ -885,24 +876,6 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
inset-inline-end: -4px;
|
||||
inset-inline-start: initial;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
|
||||
.narrow-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -951,11 +924,8 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ha-assist-chip {
|
||||
ha-assist-chip,
|
||||
ha-filter-pane-chip {
|
||||
--ha-assist-chip-container-shape: 10px;
|
||||
--ha-assist-chip-container-color: var(--card-background-color);
|
||||
}
|
||||
@@ -965,20 +935,19 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
|
||||
--md-assist-chip-trailing-space: 8px;
|
||||
}
|
||||
|
||||
ha-dialog {
|
||||
ha-adaptive-dialog {
|
||||
--dialog-content-padding: 0;
|
||||
/* Fixed height so the sheet does not resize while filtering. */
|
||||
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
|
||||
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
|
||||
}
|
||||
|
||||
.filter-dialog-content {
|
||||
height: calc(
|
||||
100vh -
|
||||
70px - var(--header-height, 0px) - var(
|
||||
--safe-area-inset-top,
|
||||
0px
|
||||
) - var(--safe-area-inset-bottom, 0px)
|
||||
);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
ha-dropdown ha-assist-chip {
|
||||
|
||||
@@ -6,12 +6,16 @@ import { storage } from "../common/decorators/storage";
|
||||
import { isNavigationClick } from "../common/dom/is-navigation-click";
|
||||
import { navigate } from "../common/navigate";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { decodeMoreInfoUrl } from "../common/url/more-info-query-params";
|
||||
import { extractSearchParamsObject } from "../common/url/search-params";
|
||||
import { afterNextRender } from "../common/util/render-status";
|
||||
import { fetchHttpConfig } from "../data/http";
|
||||
import type { HttpConfigState } from "../data/http";
|
||||
import type { WindowWithPreloads } from "../data/preloads";
|
||||
import type { RecorderInfo } from "../data/recorder";
|
||||
import { getRecorderInfo } from "../data/recorder";
|
||||
import { showHttpPendingConfigDialog } from "../dialogs/http-pending-config/show-dialog-http-pending-config";
|
||||
import { showMoreInfoDialog } from "../dialogs/more-info/show-ha-more-info-dialog";
|
||||
import "../resources/custom-card-support";
|
||||
import { HassElement } from "../state/hass-element";
|
||||
import QuickBarMixin from "../state/quick-bar-mixin";
|
||||
@@ -144,6 +148,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// the shadow root. Appending the dialog before that render would let it
|
||||
// tear the freshly-added dialog straight back out of the DOM.
|
||||
this.checkHttpPendingConfig();
|
||||
this._restoreMoreInfoFromUrl();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,6 +183,12 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
// Handle history changes
|
||||
window.addEventListener("popstate", () => updateRoute());
|
||||
|
||||
// Restore a more-info dialog deep-linked in the current URL, if any.
|
||||
window.addEventListener("location-changed", () =>
|
||||
this._restoreMoreInfoFromUrl()
|
||||
);
|
||||
window.addEventListener("popstate", () => this._restoreMoreInfoFromUrl());
|
||||
|
||||
// Handle clicking on links
|
||||
window.addEventListener("click", (ev) => {
|
||||
const href = isNavigationClick(ev);
|
||||
@@ -297,6 +308,35 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
|
||||
});
|
||||
}
|
||||
|
||||
private _restoreMoreInfoFromUrl() {
|
||||
// Only restore once the main UI is rendered so the dialog has access to
|
||||
// the loaded entities.
|
||||
if (this.render !== this.renderHass) {
|
||||
return;
|
||||
}
|
||||
const searchParams = extractSearchParamsObject();
|
||||
if (!searchParams["more-info-entity-id"]) {
|
||||
return;
|
||||
}
|
||||
const { entityId, view, hash } = decodeMoreInfoUrl(
|
||||
window.location.search,
|
||||
window.location.hash
|
||||
);
|
||||
if (!entityId) {
|
||||
return;
|
||||
}
|
||||
// Wait for the next render to ensure the view is fully loaded
|
||||
// because the more info dialog is closed when the url changes
|
||||
afterNextRender(() => {
|
||||
showMoreInfoDialog(this, {
|
||||
entityId,
|
||||
view,
|
||||
hash,
|
||||
fromUrl: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected async checkDataBaseMigration() {
|
||||
if (__DEMO__) {
|
||||
this._databaseMigration = false;
|
||||
|
||||
@@ -6,9 +6,13 @@ import { fireEvent, type HASSDomEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-analytics";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-spinner";
|
||||
import "../components/ha-svg-icon";
|
||||
import type { Analytics } from "../data/analytics";
|
||||
import { setAnalyticsPreferences } from "../data/analytics";
|
||||
import {
|
||||
getAnalyticsDetails,
|
||||
setAnalyticsPreferences,
|
||||
} from "../data/analytics";
|
||||
import { onboardAnalyticsStep } from "../data/onboarding";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { documentationUrl } from "../util/documentation-url";
|
||||
@@ -22,9 +26,11 @@ class OnboardingAnalytics extends LitElement {
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _analyticsDetails: Analytics = {
|
||||
preferences: {},
|
||||
};
|
||||
// Undefined while we are still waiting for the analytics integration to be
|
||||
// set up (Home Assistant may still be starting up during onboarding).
|
||||
@state() private _analyticsDetails?: Analytics;
|
||||
|
||||
private _retryTimeout?: number;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
@@ -40,13 +46,26 @@ class OnboardingAnalytics extends LitElement {
|
||||
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
|
||||
</a>
|
||||
</p>
|
||||
<ha-analytics
|
||||
translation_key_panel="page-onboarding"
|
||||
@analytics-preferences-changed=${this._preferencesChanged}
|
||||
.localize=${this.localize}
|
||||
.analytics=${this._analyticsDetails}
|
||||
>
|
||||
</ha-analytics>
|
||||
${
|
||||
this._analyticsDetails
|
||||
? html`
|
||||
<ha-analytics
|
||||
translation_key_panel="page-onboarding"
|
||||
@analytics-preferences-changed=${this._preferencesChanged}
|
||||
.localize=${this.localize}
|
||||
.analytics=${this._analyticsDetails}
|
||||
>
|
||||
</ha-analytics>
|
||||
`
|
||||
: html`
|
||||
<div class="loading">
|
||||
<ha-spinner></ha-spinner>
|
||||
<p>
|
||||
${this.localize("ui.panel.page-onboarding.analytics.waiting")}
|
||||
</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
${this._error ? html`<div class="error">${this._error}</div>` : ""}
|
||||
<div class="footer">
|
||||
<ha-button @click=${this._save} .disabled=${!this._analyticsDetails}>
|
||||
@@ -63,6 +82,35 @@ class OnboardingAnalytics extends LitElement {
|
||||
this._save(ev);
|
||||
}
|
||||
});
|
||||
this._loadAnalyticsDetails();
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
if (this._retryTimeout) {
|
||||
clearTimeout(this._retryTimeout);
|
||||
this._retryTimeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadAnalyticsDetails(): Promise<void> {
|
||||
try {
|
||||
// The analytics integration registers its WebSocket commands during
|
||||
// setup, but only stores its data once the config entry is set up. On a
|
||||
// fresh install we can reach this step before that happened, so keep
|
||||
// retrying until it is ready instead of failing on save.
|
||||
this._analyticsDetails = await getAnalyticsDetails(this.hass);
|
||||
this._error = undefined;
|
||||
} catch (err: any) {
|
||||
if (err.code === "not_found") {
|
||||
this._retryTimeout = window.setTimeout(
|
||||
() => this._loadAnalyticsDetails(),
|
||||
1000
|
||||
);
|
||||
return;
|
||||
}
|
||||
this._error = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
private _preferencesChanged(
|
||||
@@ -76,6 +124,9 @@ class OnboardingAnalytics extends LitElement {
|
||||
|
||||
private async _save(ev) {
|
||||
ev.preventDefault();
|
||||
if (!this._analyticsDetails) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setAnalyticsPreferences(
|
||||
this.hass,
|
||||
@@ -98,6 +149,13 @@ class OnboardingAnalytics extends LitElement {
|
||||
.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
a {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
|
||||
@@ -49,6 +49,7 @@ const SUPPORTED_UI_TYPES = [
|
||||
|
||||
const secretTag = defineScalarTag("!secret", {
|
||||
resolve: (data) => `!secret ${data}`,
|
||||
identify: () => false,
|
||||
});
|
||||
|
||||
const ADDON_YAML_SCHEMA = YAML11_SCHEMA.withTags(secretTag);
|
||||
|
||||
@@ -545,7 +545,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
|
||||
: nothing
|
||||
}
|
||||
${this._currentAddon.description}.<br />
|
||||
<div class="description-text">${this._currentAddon.description}</div>
|
||||
${this.i18n.localize(
|
||||
"ui.panel.config.apps.dashboard.visit_app_page",
|
||||
{
|
||||
@@ -1658,6 +1658,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.description:dir(rtl) > .description-text {
|
||||
text-align: right;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.long-description {
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
img.logo {
|
||||
max-width: 100%;
|
||||
max-height: 40px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user