Compare commits

..

5 Commits

Author SHA1 Message Date
Wendelin
28e3c75ff4 Add cli to package.json 2025-05-14 17:21:49 +02:00
Wendelin
b2ec3c8c37 Code more like cli 2025-05-14 17:19:41 +02:00
Wendelin
fe37f8fad5 Merge branch 'dev' of github.com:home-assistant/frontend into auto-jsdoc-markdown 2025-05-14 16:47:46 +02:00
Wendelin
c46368b141 Merge branch 'dev' of github.com:home-assistant/frontend into auto-jsdoc-markdown 2025-05-12 09:00:26 +02:00
Wendelin
c49b0803cd Add first auto doc test 2025-05-05 16:43:35 +02:00
396 changed files with 9170 additions and 10710 deletions

View File

@@ -108,9 +108,9 @@ body:
render: yaml
- type: textarea
attributes:
label: JavaScript errors shown in your browser console/inspector
label: Javascript errors shown in your browser console/inspector
description: >
If you come across any JavaScript or other error logs, e.g., in your
If you come across any Javascript or other error logs, e.g., in your
browser console/inspector please provide them.
render: txt
- type: textarea

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Send bundle stats and build information to RelativeCI
uses: relative-ci/agent-action@v3.0.0
uses: relative-ci/agent-action@v2.2.0
with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }}

View File

@@ -1 +1 @@
yarn run lint-staged --relative
yarn run lint-staged --relative --shell "/bin/bash"

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableGlobalCache: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.2.cjs
yarnPath: .yarn/releases/yarn-4.9.1.cjs

View File

@@ -3,6 +3,9 @@ import { glob } from "glob";
import gulp from "gulp";
import yaml from "js-yaml";
import { marked } from "marked";
import ts from "typescript";
import { create } from "@custom-elements-manifest/analyzer";
import { litPlugin } from "@custom-elements-manifest/analyzer/src/features/framework-plugins/lit/lit.js";
import path from "path";
import paths from "../paths.cjs";
import "./clean.js";
@@ -13,6 +16,28 @@ import "./service-worker.js";
import "./translations.js";
import "./rspack.js";
gulp.task("generate-component-docs", async function generateComponentDocs() {
const filePaths = ["src/components/ha-alert.ts"];
const modules = await Promise.all(
filePaths.map(async (file) => {
const filePath = path.resolve(file);
console.log(`Reading ${file} -> ${filePath}`);
const source = fs.readFileSync(filePath).toString();
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2015, true);
})
);
const manifest = create({
modules,
plugins: litPlugin(),
context: { dev: true },
});
console.log(manifest);
});
gulp.task("gather-gallery-pages", async function gatherPages() {
const pageDir = path.resolve(paths.gallery_dir, "src/pages");
const files = await glob(path.resolve(pageDir, "**/*"));

View File

@@ -88,7 +88,7 @@ class HcLayout extends LitElement {
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-condensed);
line-height: 32px;
padding: 24px 16px 16px;
display: block;
margin: 0;

186
custom-elements.json Normal file
View File

@@ -0,0 +1,186 @@
{
"schemaVersion": "1.0.0",
"readme": "",
"modules": [
{
"kind": "javascript-module",
"path": "src/components/ha-alert.ts",
"declarations": [
{
"kind": "class",
"description": "A custom alert component for displaying messages with various alert types.",
"name": "HaAlert",
"cssProperties": [
{
"description": "The color used for \"info\" alerts.",
"name": "--info-color"
},
{
"description": "The color used for \"warning\" alerts.",
"name": "--warning-color"
},
{
"description": "The color used for \"error\" alerts.",
"name": "--error-color"
},
{
"description": "The color used for \"success\" alerts.",
"name": "--success-color"
},
{
"description": "The primary text color used in the alert.",
"name": "--primary-text-color"
}
],
"cssParts": [
{
"description": "The container for the alert.",
"name": "issue-type"
},
{
"description": "The container for the alert icon.",
"name": "icon"
},
{
"description": "The container for the alert content.",
"name": "content"
},
{
"description": "The container for the alert actions.",
"name": "action"
},
{
"description": "The container for the alert title.",
"name": "title"
}
],
"slots": [
{
"description": "The main content of the alert.",
"name": ""
},
{
"description": "Slot for providing a custom icon for the alert.",
"name": "icon"
},
{
"description": "Slot for providing custom actions or buttons for the alert.",
"name": "action"
}
],
"members": [
{
"kind": "field",
"name": "title",
"type": {
"text": "string"
},
"privacy": "public",
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"attribute": "title"
},
{
"kind": "field",
"name": "alertType",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"privacy": "public",
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"attribute": "alert-type"
},
{
"kind": "field",
"name": "dismissable",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"attribute": "dismissable"
},
{
"kind": "field",
"name": "narrow",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"attribute": "narrow"
},
{
"kind": "method",
"name": "_dismissClicked",
"privacy": "private"
}
],
"events": [
{
"description": "Fired when the dismiss button is clicked.",
"name": "alert-dismissed-clicked"
}
],
"attributes": [
{
"name": "title",
"type": {
"text": "string"
},
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"fieldName": "title"
},
{
"name": "alert-type",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"fieldName": "alertType"
},
{
"name": "dismissable",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"fieldName": "dismissable"
},
{
"name": "narrow",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"fieldName": "narrow"
}
],
"superclass": {
"name": "LitElement",
"package": "lit"
},
"tagName": "ha-alert",
"customElement": true
}
],
"exports": [
{
"kind": "custom-element-definition",
"name": "ha-alert",
"declaration": {
"name": "HaAlert",
"module": "src/components/ha-alert.ts"
}
}
]
}
]
}

View File

@@ -68,7 +68,7 @@
}
#ha-launch-screen .ha-launch-screen-spacer-top {
flex: 1;
margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px );
margin-top: calc( 2 * max(env(safe-area-inset-bottom), 48px) + 46px );
padding-top: 48px;
}
#ha-launch-screen .ha-launch-screen-spacer-bottom {
@@ -76,7 +76,7 @@
padding-top: 48px;
}
.ohf-logo {
margin: max(var(--safe-area-inset-bottom), 48px) 0;
margin: max(env(safe-area-inset-bottom), 48px) 0;
display: flex;
flex-direction: column;
align-items: center;

View File

@@ -1,30 +1,7 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let changeFunction;
export const mockFrontend = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_user_data", () => ({
value: null,
}));
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
if (key === "sidebar") {
changeFunction?.({
value: {
panelOrder: value.panelOrder || [],
hiddenPanels: value.hiddenPanels || [],
},
});
}
});
hass.mockWS("frontend/subscribe_user_data", (_msg, _hass, onChange) => {
changeFunction = onChange;
onChange?.({
value: {
panelOrder: [],
hiddenPanels: [],
},
});
// eslint-disable-next-line @typescript-eslint/no-empty-function
return () => {};
});
};

View File

@@ -38,12 +38,12 @@ class PageDescription extends HaMarkdown {
}
.title {
font-size: 42px;
line-height: var(--ha-line-height-condensed);
line-height: 56px;
padding-bottom: 8px;
}
.subtitle {
font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-normal);
line-height: 24px;
}
.root {
max-width: 800px;

View File

@@ -252,12 +252,12 @@ class HaGallery extends LitElement {
.page-footer .header {
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
line-height: 28px;
text-align: center;
}
.page-footer .secondary {
line-height: var(--ha-line-height-normal);
line-height: 23px;
text-align: center;
}

View File

@@ -430,7 +430,7 @@ class HassioAddonConfig extends LitElement {
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
line-height: 48px;
padding: 12px 16px 16px;
display: block;
margin-block: 0px;

View File

@@ -101,7 +101,7 @@ class HassioCardContent extends LitElement {
overflow: hidden;
position: relative;
height: 2.4em;
line-height: var(--ha-line-height-condensed);
line-height: 1.2em;
}
.icon_image img {
max-height: 40px;

View File

@@ -132,9 +132,9 @@ class HassioDashboard extends LitElement {
}
ha-fab.non-tabs {
position: fixed;
right: calc(16px + var(--safe-area-inset-right));
bottom: calc(16px + var(--safe-area-inset-bottom));
inset-inline-end: calc(16px + var(--safe-area-inset-right));
right: calc(16px + env(safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom));
inset-inline-end: calc(16px + env(safe-area-inset-right));
inset-inline-start: initial;
z-index: 1;
}

View File

@@ -610,7 +610,7 @@ export class DialogHassioNetwork
display: flex;
justify-content: space-between;
padding: 8px;
padding-bottom: max(var(--safe-area-inset-bottom), 8px);
padding-bottom: max(env(safe-area-inset-bottom), 8px);
background-color: var(--mdc-theme-surface, #fff);
}
.warning {

View File

@@ -354,7 +354,7 @@ class HassioIngressView extends LitElement {
.main-title {
margin: var(--margin-title);
line-height: var(--ha-line-height-condensed);
line-height: 20px;
flex-grow: 1;
}

View File

@@ -20,21 +20,23 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"analyze": "cem analyze --litelement --globs \"src/components/ha-alert.ts\" --dev",
"doc": "gulp generate-component-docs"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/runtime": "7.27.6",
"@babel/runtime": "7.27.1",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.1",
"@codemirror/language": "6.11.0",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11",
"@codemirror/search": "6.5.10",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.37.1",
"@codemirror/view": "6.36.7",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11",
@@ -89,8 +91,8 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.8.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.7.8",
"@vaadin/vaadin-themable-mixin": "24.7.8",
"@vaadin/combo-box": "24.7.5",
"@vaadin/vaadin-themable-mixin": "24.7.5",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -111,7 +113,7 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.5",
"hls.js": "1.6.2",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
@@ -122,7 +124,7 @@
"lit": "3.3.0",
"lit-html": "3.3.0",
"luxon": "3.6.1",
"marked": "15.0.12",
"marked": "15.0.11",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.3",
"object-hash": "3.0.0",
@@ -137,6 +139,7 @@
"tinykeys": "3.0.0",
"ua-parser-js": "2.0.3",
"vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16",
"vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0",
@@ -149,26 +152,28 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.27.4",
"@babel/core": "7.27.1",
"@babel/helper-define-polyfill-provider": "0.6.4",
"@babel/plugin-transform-runtime": "7.27.4",
"@babel/plugin-transform-runtime": "7.27.1",
"@babel/preset-env": "7.27.2",
"@bundle-stats/plugin-webpack-filter": "4.20.2",
"@lokalise/node-api": "14.8.0",
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.1.3",
"@rspack/cli": "1.3.12",
"@rspack/core": "1.3.12",
"@bundle-stats/plugin-webpack-filter": "4.20.1",
"@custom-elements-manifest/analyzer": "0.10.4",
"@custom-elements-manifest/to-markdown": "0.1.0",
"@lokalise/node-api": "14.7.0",
"@octokit/auth-oauth-device": "7.1.5",
"@octokit/plugin-retry": "7.2.1",
"@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "1.1.2",
"@rspack/cli": "1.3.9",
"@rspack/core": "1.3.9",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.22",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0",
"@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.18",
"@types/leaflet": "1.9.17",
"@types/leaflet-draw": "1.0.12",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9",
@@ -179,12 +184,12 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.2.2",
"@vitest/coverage-v8": "3.1.3",
"babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.28.0",
"eslint": "9.26.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5",
"eslint-import-resolver-webpack": "0.13.10",
@@ -196,7 +201,7 @@
"fancy-log": "2.0.0",
"fs-extra": "11.3.0",
"glob": "11.0.2",
"gulp": "5.0.1",
"gulp": "5.0.0",
"gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0",
"gulp-rename": "2.0.0",
@@ -204,7 +209,7 @@
"husky": "9.1.7",
"jsdom": "26.1.0",
"jszip": "3.10.1",
"lint-staged": "16.1.0",
"lint-staged": "15.5.2",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
"lodash.template": "4.5.0",
@@ -218,9 +223,9 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.8.3",
"typescript-eslint": "8.33.1",
"typescript-eslint": "8.32.0",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.2",
"vitest": "3.1.3",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -232,9 +237,9 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.0",
"@fullcalendar/daygrid": "6.1.17",
"globals": "16.2.0",
"globals": "16.1.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"
},
"packageManager": "yarn@4.9.2"
"packageManager": "yarn@4.9.1"
}

View File

@@ -94,7 +94,7 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
}
p {
font-size: var(--ha-font-size-m);
line-height: var(--ha-line-height-normal);
line-height: 20px;
}
.card-content {
background: var(

View File

@@ -59,7 +59,7 @@ export class HaPickAuthProvider extends LitElement {
text-align: center;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal);
line-height: 20px;
}
h3:before {
border-top: 1px solid var(--divider-color);

View File

@@ -2,6 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
import { computeStateDomain } from "./compute_state_domain";
import { updateIcon } from "./update_icon";
import { deviceTrackerIcon } from "./device_tracker_icon";
import { batteryIcon } from "./battery_icon";
export const stateIcon = (
stateObj: HassEntity,
@@ -9,10 +10,17 @@ export const stateIcon = (
): string | undefined => {
const domain = computeStateDomain(stateObj);
const compareState = state ?? stateObj.state;
const dc = stateObj.attributes.device_class;
switch (domain) {
case "update":
return updateIcon(stateObj, compareState);
case "sensor":
if (dc === "battery") {
return batteryIcon(stateObj, compareState);
}
break;
case "device_tracker":
return deviceTrackerIcon(stateObj, compareState);

View File

@@ -1,4 +0,0 @@
const validServiceId = /^(\w+)\.(\w+)$/;
export const isValidServiceId = (actionId: string) =>
validServiceId.test(actionId);

View File

@@ -1,19 +1,9 @@
// https://gist.github.com/hagemann/382adfc57adbd5af078dc93feef01fe1
export const slugify = (value: string, delimiter = "_") => {
const a =
"àáâäæãåāăąабçćčđďдèéêëēėęěеёэфğǵгḧхîïíīįìıİийкłлḿмñńǹňнôöòóœøōõőоṕпŕřрßśšşșсťțтûüùúūǘůűųувẃẍÿýыžźżз·";
const b = `aaaaaaaaaaabcccdddeeeeeeeeeeefggghhiiiiiiiiijkllmmnnnnnoooooooooopprrrsssssstttuuuuuuuuuuvwxyyyzzzz${delimiter}`;
"àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·";
const b = `aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz${delimiter}`;
const p = new RegExp(a.split("").join("|"), "g");
const complex_cyrillic = {
ж: "zh",
х: "kh",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "shch",
ю: "iu",
я: "ia",
};
let slugified;
@@ -24,7 +14,6 @@ export const slugify = (value: string, delimiter = "_") => {
.toString()
.toLowerCase()
.replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters
.replace(/[а-я]/g, (c) => complex_cyrillic[c] || "") // Replace some cyrillic characters
.replace(/(\d),(?=\d)/g, "$1") // Remove Commas between numbers
.replace(/[^a-z0-9]+/g, delimiter) // Replace all non-word characters
.replace(new RegExp(`(${delimiter})\\1+`, "g"), "$1") // Replace multiple delimiters with single delimiter

View File

@@ -1,14 +0,0 @@
import { html } from "lit";
import type { LocalizeFunc } from "./localize";
const MARKDOWN_SUPPORT_URL = "https://commonmark.org/help/";
export const supportsMarkdownHelper = (localize: LocalizeFunc) =>
localize("ui.common.supports_markdown", {
markdown_help_link: html`<a
href=${MARKDOWN_SUPPORT_URL}
target="_blank"
rel="noreferrer"
>${localize("ui.common.markdown")}</a
>`,
});

View File

@@ -1,72 +0,0 @@
import type { LineSeriesOption } from "echarts";
export function downSampleLineData(
data: LineSeriesOption["data"],
chartWidth: number,
minX?: number,
maxX?: number
) {
if (!data || data.length < 10) {
return data;
}
const width = chartWidth * window.devicePixelRatio;
if (data.length <= width) {
return data;
}
const min = minX ?? getPointData(data[0]!)[0];
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
const step = Math.floor((max - min) / width);
const frames = new Map<
number,
{
min: { point: (typeof data)[number]; x: number; y: number };
max: { point: (typeof data)[number]; x: number; y: number };
}
>();
// Group points into frames
for (const point of data) {
const pointData = getPointData(point);
if (!Array.isArray(pointData)) continue;
const x = Number(pointData[0]);
const y = Number(pointData[1]);
if (isNaN(x) || isNaN(y)) continue;
const frameIndex = Math.floor((x - min) / step);
const frame = frames.get(frameIndex);
if (!frame) {
frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } });
} else {
if (frame.min.y > y) {
frame.min = { point, x, y };
}
if (frame.max.y < y) {
frame.max = { point, x, y };
}
}
}
// Convert frames back to points
const result: typeof data = [];
for (const [_i, frame] of frames) {
// Use min/max points to preserve visual accuracy
// The order of the data must be preserved so max may be before min
if (frame.min.x > frame.max.x) {
result.push(frame.max.point);
}
result.push(frame.min.point);
if (frame.min.x < frame.max.x) {
result.push(frame.max.point);
}
}
return result;
}
function getPointData(point: NonNullable<LineSeriesOption["data"]>[number]) {
const pointData =
point && typeof point === "object" && "value" in point
? point.value
: point;
return pointData as number[];
}

View File

@@ -27,7 +27,6 @@ import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array";
import "../chips/ha-assist-chip";
import { downSampleLineData } from "./down-sample";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
@@ -49,8 +48,7 @@ export class HaChartBase extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@property({ attribute: false }) public extraComponents?: any[];
@state()
@consume({ context: themesContext, subscribe: true })
@@ -108,49 +106,48 @@ export class HaChartBase extends LitElement {
})
);
if (!this.options?.dataZoom) {
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if (
!this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
// Add keyboard event listeners
const handleKeyDown = (ev: KeyboardEvent) => {
if (
!this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = true;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
};
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
}
};
const handleKeyUp = (ev: KeyboardEvent) => {
if (
this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
const handleKeyUp = (ev: KeyboardEvent) => {
if (
this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) {
this._modifierPressed = false;
if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
}
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
}
protected firstUpdated() {
@@ -194,19 +191,16 @@ export class HaChartBase extends LitElement {
<div class="chart"></div>
</div>
${this._renderLegend()}
<div class="chart-controls">
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
${this._isZoomed
? html`<ha-icon-button
class="zoom-reset"
.path=${mdiRestart}
@click=${this._handleZoomReset}
title=${this.hass.localize(
"ui.components.history_charts.zoom_reset"
)}
></ha-icon-button>`
: nothing}
</div>
`;
}
@@ -216,15 +210,15 @@ export class HaChartBase extends LitElement {
return nothing;
}
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
if (!legend.show || legend.type !== "custom") {
if (!legend.show) {
return nothing;
}
const datasets = ensureArray(this.data);
const items: LegendComponentOption["data"] =
legend.data ||
((datasets
const items = (legend.data ||
datasets
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
.map((d) => d.name ?? d.id) || []) as string[]);
.map((d) => d.name ?? d.id) ||
[]) as string[];
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
@@ -239,32 +233,20 @@ export class HaChartBase extends LitElement {
})}
>
<ul>
${items.map((item, index) => {
${items.map((item: string, index: number) => {
if (!this.expandLegend && index >= overflowLimit) {
return nothing;
}
let itemStyle: Record<string, any> = {};
let name = "";
if (typeof item === "string") {
name = item;
const dataset = datasets.find(
(d) => d.id === item || d.name === item
);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
};
} else {
name = item.name ?? "";
itemStyle = item.itemStyle ?? {};
}
const color = itemStyle?.color as string;
const borderColor = itemStyle?.borderColor as string;
const dataset = datasets.find(
(d) => d.id === item || d.name === item
);
const color = dataset?.color as string;
const borderColor = dataset?.itemStyle?.borderColor as string;
return html`<li
.name=${name}
.name=${item}
@click=${this._legendClick}
class=${classMap({ hidden: this._hiddenDatasets.has(name) })}
.title=${name}
class=${classMap({ hidden: this._hiddenDatasets.has(item) })}
.title=${item}
>
<div
class="bullet"
@@ -273,7 +255,7 @@ export class HaChartBase extends LitElement {
borderColor: borderColor || color,
})}
></div>
<div class="label">${name}</div>
<div class="label">${item}</div>
</li>`;
})}
${items.length > overflowLimit
@@ -333,9 +315,7 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
if (!this.options?.dataZoom) {
this.chart.getZr().on("dblclick", this._handleClickZoom);
}
this.chart.getZr().on("dblclick", this._handleClickZoom);
if (this._isTouchDevice) {
this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) {
@@ -400,9 +380,9 @@ export class HaChartBase extends LitElement {
if (axis.type !== "time" || axis.show === false) {
return axis;
}
if (axis.min) {
if (axis.max && axis.min) {
this._minutesDifference = differenceInMinutes(
(axis.max as Date) || new Date(),
axis.max as Date,
axis.min as Date
);
}
@@ -430,12 +410,6 @@ export class HaChartBase extends LitElement {
} as XAXisOption;
});
}
let legend = this.options?.legend;
if (legend) {
legend = ensureArray(legend).map((l) =>
l.type === "custom" ? { show: false } : l
);
}
const options = {
animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false,
@@ -450,7 +424,7 @@ export class HaChartBase extends LitElement {
iconStyle: { opacity: 0 },
},
...this.options,
legend,
legend: { show: false },
xAxis,
};
@@ -494,13 +468,6 @@ export class HaChartBase extends LitElement {
smooth: false,
},
bar: { itemStyle: { barBorderWidth: 1.5 } },
graph: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
},
},
categoryAxis: {
axisLine: { show: false },
axisTick: { show: false },
@@ -633,21 +600,19 @@ export class HaChartBase extends LitElement {
}
private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const series = ensureArray(this.data).filter(
(d) => !this._hiddenDatasets.has(String(d.name ?? d.id))
);
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data)
.filter((d) => !this._hiddenDatasets.has(String(d.name ?? d.id)))
.map((s) => {
if (s.type === "line") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: s.data?.map((v) =>
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return series.map((d) =>
d.type === "line"
? {
...d,
data: d.data?.map((v) =>
Array.isArray(v)
? [
v[0],
@@ -656,26 +621,10 @@ export class HaChartBase extends LitElement {
]
: v
),
};
}
if (s.sampling === "minmax") {
const minX =
xAxis?.min && typeof xAxis.min === "number"
? xAxis.min
: undefined;
const maxX =
xAxis?.max && typeof xAxis.max === "number"
? xAxis.max
: undefined;
return {
...s,
sampling: undefined,
data: downSampleLineData(s.data, this.clientWidth, minX, maxX),
};
}
}
return s;
});
}
: d
);
}
return series;
}
@@ -776,26 +725,16 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart-controls {
.zoom-reset {
position: absolute;
top: 16px;
right: 4px;
display: flex;
flex-direction: column;
gap: 4px;
}
.chart-controls ha-icon-button,
.chart-controls ::slotted(ha-icon-button) {
background: var(--card-background-color);
border-radius: 4px;
--mdc-icon-button-size: 32px;
color: var(--primary-color);
border: 1px solid var(--divider-color);
}
.chart-controls ha-icon-button.inactive,
.chart-controls ::slotted(ha-icon-button.inactive) {
color: var(--state-inactive-color);
}
.chart-legend {
max-height: 60%;
overflow-y: auto;

View File

@@ -1,299 +0,0 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
export interface NetworkNode {
id: string;
name?: string;
category?: number;
label?: string;
value?: number;
symbolSize?: number;
symbol?: string;
itemStyle?: {
color?: string;
borderColor?: string;
borderWidth?: number;
};
fixed?: boolean;
/**
* Distance from the center, where 0 is the center and 1 is the edge
*/
polarDistance?: number;
}
export interface NetworkLink {
source: string;
target: string;
value?: number;
reverseValue?: number;
lineStyle?: {
width?: number;
color?: string;
type?: "solid" | "dashed" | "dotted";
};
symbolSize?: number | number[];
symbol?: string;
label?: {
show?: boolean;
formatter?: string;
};
ignoreForceLayout?: boolean;
}
export interface NetworkData {
nodes: NetworkNode[];
links: NetworkLink[];
categories?: { name: string; symbol: string }[];
}
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let GraphChart: typeof import("echarts/lib/chart/graph/install");
@customElement("ha-network-graph")
export class HaNetworkGraph extends LitElement {
public chart?: EChartsType;
@property({ attribute: false }) public data!: NetworkData;
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
public hass!: HomeAssistant;
@state() private _reducedMotion = false;
@state() private _physicsEnabled = true;
@state() private _showLabels = true;
private _listeners: (() => void)[] = [];
private _nodePositions: Record<string, { x: number; y: number }> = {};
@query("ha-chart-base") private _baseChart?: HaChartBase;
constructor() {
super();
if (!GraphChart) {
import("echarts/lib/chart/graph/install").then((module) => {
GraphChart = module;
this.requestUpdate();
});
}
}
public async connectedCallback() {
super.connectedCallback();
this._listeners.push(
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) {
this._reducedMotion = matches;
}
})
);
}
public disconnectedCallback() {
super.disconnectedCallback();
while (this._listeners.length) {
this._listeners.pop()!();
}
}
protected render() {
if (!GraphChart) {
return nothing;
}
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._getSeries(
this.data,
this._physicsEnabled,
this._reducedMotion,
this._showLabels
)}
.options=${this._createOptions(this.data?.categories)}
height="100%"
.extraComponents=${[GraphChart]}
>
<slot name="button" slot="button"></slot>
<ha-icon-button
slot="button"
class=${this._physicsEnabled ? "active" : "inactive"}
.path=${mdiGoogleCirclesGroup}
@click=${this._togglePhysics}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_physics"
)}
></ha-icon-button>
<ha-icon-button
slot="button"
class=${this._showLabels ? "active" : "inactive"}
.path=${mdiFormatTextVariant}
@click=${this._toggleLabels}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_labels"
)}
></ha-icon-button>
</ha-chart-base>`;
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
formatter: this.tooltipFormatter,
},
legend: {
show: !!categories?.length,
data: categories?.map((category) => ({
...category,
icon: category.symbol,
})),
top: 8,
},
dataZoom: {
type: "inside",
filterMode: "none",
},
})
);
private _getSeries = memoizeOne(
(
data: NetworkData,
physicsEnabled: boolean,
reducedMotion: boolean,
showLabels: boolean
) => {
const containerWidth = this.clientWidth;
const containerHeight = this.clientHeight;
return [
{
id: "network",
type: "graph",
layout: physicsEnabled ? "force" : "none",
draggable: true,
roam: true,
selectedMode: "single",
label: {
show: showLabels,
position: "right",
},
emphasis: {
focus: "adjacency",
},
force: {
repulsion: [400, 600],
edgeLength: [200, 300],
gravity: 0.1,
layoutAnimation: !reducedMotion && data.nodes.length < 100,
},
edgeSymbol: ["none", "arrow"],
edgeSymbolSize: 10,
data: data.nodes.map((node) => {
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] =
{
id: node.id,
name: node.name,
category: node.category,
value: node.value,
symbolSize: node.symbolSize || 30,
symbol: node.symbol || "circle",
itemStyle: node.itemStyle || {},
fixed: node.fixed,
};
if (this._nodePositions[node.id]) {
echartsNode.x = this._nodePositions[node.id].x;
echartsNode.y = this._nodePositions[node.id].y;
} else if (typeof node.polarDistance === "number") {
// set the position of the node at polarDistance from the center in a random direction
const angle = Math.random() * 2 * Math.PI;
echartsNode.x =
containerWidth / 2 +
((Math.cos(angle) * containerWidth) / 2) * node.polarDistance;
echartsNode.y =
containerHeight / 2 +
((Math.sin(angle) * containerHeight) / 2) * node.polarDistance;
this._nodePositions[node.id] = {
x: echartsNode.x,
y: echartsNode.y,
};
}
return echartsNode;
}),
links: data.links.map((link) => ({
...link,
value: link.reverseValue
? Math.max(link.value ?? 0, link.reverseValue)
: link.value,
// remove arrow for bidirectional links
symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work
})),
categories: data.categories || [],
},
] as any;
}
);
private _togglePhysics() {
if (this._baseChart?.chart) {
this._baseChart.chart
// @ts-ignore private method but no other way to get the graph positions
.getModel()
.getSeriesByIndex(0)
.getGraph()
.eachNode((node: any) => {
const layout = node.getLayout();
if (layout) {
this._nodePositions[node.id] = {
x: layout[0],
y: layout[1],
};
}
});
}
this._physicsEnabled = !this._physicsEnabled;
}
private _toggleLabels() {
this._showLabels = !this._showLabels;
}
static styles = css`
:host {
display: block;
position: relative;
}
ha-chart-base {
height: 100%;
--chart-max-height: 100%;
}
ha-icon-button,
::slotted(ha-icon-button) {
margin-right: 12px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-network-graph": HaNetworkGraph;
}
interface HASSDomEvents {
"node-selected": { id: string };
}
}

View File

@@ -82,8 +82,6 @@ export class StateHistoryChartLine extends LitElement {
private _chartTime: Date = new Date();
private _previousYAxisLabelValue = 0;
protected render() {
return html`
<ha-chart-base
@@ -229,20 +227,14 @@ export class StateHistoryChartLine extends LitElement {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return Math.abs(value) < 1 ? value : Math.floor(value);
};
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return Math.abs(value) < 1 ? value : Math.ceil(value);
};
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
this._chartOptions = {
xAxis: {
@@ -266,11 +258,35 @@ export class StateHistoryChartLine extends LitElement {
},
axisLabel: {
margin: 5,
formatter: this._formatYAxisLabel,
formatter: (value: number) => {
const formatOptions =
value >= 1 || value <= -1
? undefined
: {
// show the first significant digit for tiny values
maximumFractionDigits: Math.max(
2,
-Math.floor(Math.log10(Math.abs(value % 1 || 1)))
),
};
const label = formatNumber(
value,
this.hass.locale,
formatOptions
);
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
return label;
},
},
} as YAXisOption,
legend: {
type: "custom",
show: this.showNames,
},
grid: {
@@ -728,41 +744,14 @@ export class StateHistoryChartLine extends LitElement {
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
}
private _formatYAxisLabel = (value: number) => {
const formatOptions =
value >= 1 || value <= -1
? undefined
: {
// show the first significant digit for tiny values
maximumFractionDigits: Math.max(
2,
// use the difference to the previous value to determine the number of significant digits #25526
-Math.floor(
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
)
),
};
const label = formatNumber(value, this.hass.locale, formatOptions);
const width = measureTextWidth(label, 12) + 5;
if (width > this._yWidth) {
this._yWidth = width;
fireEvent(this, "y-width-changed", {
value: this._yWidth,
chartIndex: this.chartIndex,
});
}
this._previousYAxisLabelValue = value;
return label;
};
private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, Number.EPSILON);
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), Number.EPSILON);
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;

View File

@@ -241,20 +241,14 @@ export class StatisticsChart extends LitElement {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
}
} else if (this.logarithmicScale) {
minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return Math.abs(value) < 1 ? value : Math.floor(value);
};
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05);
}
if (typeof maxYAxis === "number") {
if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
}
} else if (this.logarithmicScale) {
maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return Math.abs(value) < 1 ? value : Math.ceil(value);
};
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95);
}
const endTime = this.endTime ?? new Date();
let startTime = this.startTime;
@@ -314,7 +308,6 @@ export class StatisticsChart extends LitElement {
},
},
legend: {
type: "custom",
show: !this.hideLegend,
data: this._legendData,
},
@@ -625,10 +618,10 @@ export class StatisticsChart extends LitElement {
if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value
if (typeof value === "number") {
return Math.max(value, Number.EPSILON);
return Math.max(value, 0.1);
}
if (typeof value === "function") {
return (values: any) => Math.max(value(values), Number.EPSILON);
return (values: any) => Math.max(value(values), 0.1);
}
}
return value;

View File

@@ -740,7 +740,6 @@ export class HaDataTable extends LitElement {
}, {});
const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => {
const collapsed = collapsedGroups.includes(groupName);
groupedItems.push({
append: true,
selectable: false,
@@ -752,10 +751,9 @@ export class HaDataTable extends LitElement {
>
<ha-icon-button
.path=${mdiChevronUp}
.label=${this.hass.localize(
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
)}
class=${collapsed ? "collapsed" : ""}
class=${collapsedGroups.includes(groupName)
? "collapsed"
: ""}
>
</ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY
@@ -1018,7 +1016,7 @@ export class HaDataTable extends LitElement {
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem;
line-height: var(--ha-line-height-condensed);
line-height: 1.25rem;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em;
text-decoration: inherit;
@@ -1138,7 +1136,7 @@ export class HaDataTable extends LitElement {
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem;
line-height: var(--ha-line-height-condensed);
line-height: 1.25rem;
font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em;
text-decoration: inherit;
@@ -1259,8 +1257,8 @@ export class HaDataTable extends LitElement {
font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing);
font-size: var(--ha-font-size-s);
line-height: var(--ha-line-height-normal);
font-size: 0.875rem;
line-height: 1.375rem;
font-weight: var(--ha-font-weight-medium);
letter-spacing: 0.0071428571em;
text-decoration: inherit;

View File

@@ -12,7 +12,6 @@ import type { EntityRegistryEntry } from "../../data/entity_registry";
import type { HomeAssistant } from "../../types";
import "../ha-list-item";
import "../ha-select";
import { stopPropagation } from "../../common/dom/stop_propagation";
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";
@@ -104,7 +103,6 @@ export abstract class HaDeviceAutomationPicker<
.label=${this.label}
.value=${value}
@selected=${this._automationChanged}
@closed=${stopPropagation}
.disabled=${this._automations.length === 0}
>
${value === NO_AUTOMATION_KEY

View File

@@ -1,28 +1,33 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceContext } from "../../common/entity/context/get_device_context";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
import { stringCompare } from "../../common/string/compare";
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../../data/device_registry";
import { domainToName } from "../../data/integration";
import type { HomeAssistant } from "../../types";
import { brandsUrl } from "../../util/brands-url";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import { getDeviceEntityDisplayLookup } from "../../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item";
interface Device {
name: string;
area: string;
id: string;
}
type ScorableDevice = ScorableTextItem & Device;
export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry
@@ -30,35 +35,25 @@ export type HaDevicePickerDeviceFilterFunc = (
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
}
const rowRenderer: ComboBoxLitRenderer<Device> = (item) => html`
<ha-combo-box-item type="button">
<span slot="headline">${item.name}</span>
${item.area
? html`<span slot="supporting-text">${item.area}</span>`
: nothing}
</ha-combo-box-item>
`;
@customElement("ha-device-picker")
export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
* Show only devices with entities from specific domains.
* @type {Array}
@@ -97,52 +92,38 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: false })
public entityFilter?: HaDevicePickerEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ type: Boolean }) public disabled = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@property({ type: Boolean }) public required = false;
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _opened?: boolean;
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._loadConfigEntries();
}
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
configEntries.map((entry) => [entry.entry_id, entry])
);
}
private _getItems = () =>
this._getDevices(
this.hass.devices,
this.hass.entities,
this._configEntryLookup,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeDevices
);
private _init = false;
private _getDevices = memoizeOne(
(
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
configEntryLookup: Record<string, ConfigEntry>,
devices: DeviceRegistryEntry[],
areas: HomeAssistant["areas"],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeDevices: this["excludeDevices"]
): DevicePickerItem[] => {
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
): ScorableDevice[] => {
if (!devices.length) {
return [
{
id: "no_devices",
area: "",
name: this.hass.localize("ui.components.device-picker.no_devices"),
strings: [],
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
@@ -233,158 +214,133 @@ export class HaDevicePicker extends LitElement {
);
}
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
const deviceName = computeDeviceNameDisplay(
const outputDevices = inputDevices.map((device) => {
const name = computeDeviceNameDisplay(
device,
this.hass,
deviceEntityLookup[device.id]
);
const { area } = getDeviceContext(device, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
: undefined;
const domain = configEntry?.domain;
const domainName = domain
? domainToName(this.hass.localize, domain)
: undefined;
return {
id: device.id,
label: "",
primary:
deviceName ||
name:
name ||
this.hass.localize("ui.components.device-picker.unnamed_device"),
secondary: areaName,
domain: configEntry?.domain,
domain_name: domainName,
search_labels: [deviceName, areaName, domain, domainName].filter(
Boolean
) as string[],
sorting_label: deviceName || "zzz",
area:
device.area_id && areas[device.area_id]
? areas[device.area_id].name
: this.hass.localize("ui.components.device-picker.no_area"),
strings: [name || ""],
};
});
return outputDevices;
}
);
private _valueRenderer = memoizeOne(
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
const deviceId = value;
const device = this.hass.devices[deviceId];
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
if (!outputDevices.length) {
return [
{
id: "no_devices",
area: "",
name: this.hass.localize("ui.components.device-picker.no_match"),
strings: [],
},
];
}
const { area } = getDeviceContext(device, this.hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = deviceName;
const secondary = areaName;
const configEntry = device.primary_config_entry
? configEntriesLookup[device.primary_config_entry]
: undefined;
return html`
${configEntry
? html`<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: configEntry.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
})}
/>`
: nothing}
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
`;
if (outputDevices.length === 1) {
return outputDevices;
}
return outputDevices.sort((a, b) =>
stringCompare(a.name || "", b.name || "", this.hass.locale.language)
);
}
);
private _rowRenderer: ComboBoxLitRenderer<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl({
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
})}
/>
`
: 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>
`;
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.device-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.device-picker.no_match"
);
const valueRenderer = this._valueRenderer(this._configEntryLookup);
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
public async open() {
await this.updateComplete;
await this._picker?.open();
await this.comboBox?.open();
}
private _valueChanged(ev) {
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const devices = this._getDevices(
Object.values(this.hass.devices),
this.hass.areas,
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeDevices
);
this.comboBox.items = devices;
this.comboBox.filteredItems = devices;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.device-picker.device")
: this.label}
.value=${this._value}
.helper=${this.helper}
.renderer=${rowRenderer}
.disabled=${this.disabled}
.required=${this.required}
item-id-path="id"
item-value-path="id"
item-label-path="name"
@opened-changed=${this._openedChanged}
@value-changed=${this._deviceChanged}
@filter-changed=${this._filterChanged}
></ha-combo-box>
`;
}
private get _value() {
return this.value || "";
}
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.toLowerCase();
target.filteredItems = filterString.length
? fuzzyFilterSort<ScorableDevice>(filterString, target.items || [])
: target.items;
}
private _deviceChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
let newValue = ev.detail.value;
if (newValue === "no_devices") {
newValue = "";
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}

View File

@@ -1,7 +1,7 @@
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import "./ha-device-picker";
import type {
HaDevicePickerDeviceFilterFunc,

View File

@@ -4,8 +4,8 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { HaEntityComboBoxEntityFilterFunc } from "./ha-entity-combo-box";
import "./ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker";
@customElement("ha-entities-picker")
class HaEntitiesPicker extends LitElement {
@@ -72,7 +72,7 @@ class HaEntitiesPicker extends LitElement {
public excludeEntities?: string[];
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
public entityFilter?: HaEntityComboBoxEntityFilterFunc;
@property({ attribute: false, type: Array }) public createDomains?: string[];

View File

@@ -0,0 +1,514 @@
import { mdiMagnify, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
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 { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import type { HelperDomain } from "../../panels/config/helpers/const";
import { isHelperDomain } from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import { HaFuse } from "../../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item";
import "../ha-icon-button";
import "../ha-svg-icon";
import "./state-badge";
interface EntityComboBoxItem {
// Force empty label to always display empty value by default in the search field
id: string;
label: "";
primary: string;
secondary?: string;
domain_name?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
stateObj?: HassEntity;
}
export type HaEntityComboBoxEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
const NO_ENTITIES_ID = "___no-entities___";
@customElement("ha-entity-combo-box")
export class HaEntityComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
* Show entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* Show only entities with these unit of measuments.
* @type {Array}
* @attr include-unit-of-measurement
*/
@property({ type: Array, attribute: "include-unit-of-measurement" })
public includeUnitOfMeasurement?: string[];
/**
* List of allowed entities to show.
* @type {Array}
* @attr include-entities
*/
@property({ type: Array, attribute: "include-entities" })
public includeEntities?: string[];
/**
* List of entities to be excluded.
* @type {Array}
* @attr exclude-entities
*/
@property({ type: Array, attribute: "exclude-entities" })
public excludeEntities?: string[];
@property({ attribute: false })
public entityFilter?: HaEntityComboBoxEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@state() private _opened = false;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _initialItems = false;
private _items: EntityComboBoxItem[] = [];
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.hass.loadBackendTranslation("title");
}
private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
`
: html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${item.stateObj.entity_id}
</span>
`
: nothing}
${item.domain_name && !showEntityId
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _getItems = memoizeOne(
(
_opened: boolean,
hass: this["hass"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"],
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"],
createDomains: this["createDomains"]
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(hass.states);
const createItems = createDomains?.length
? createDomains.map((domain) => {
const primary = hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? hass.localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
)
: domainToName(hass.localize, domain),
}
);
return {
id: CREATE_ID + domain,
label: "",
primary: primary,
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
})
: [];
if (!entityIds.length) {
return [
{
id: NO_ENTITIES_ID,
label: "",
primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_entities"
),
icon_path: mdiMagnify,
},
...createItems,
];
}
if (includeEntities) {
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
const isRTL = computeRTL(this.hass);
items = entityIds
.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: entityId,
label: "",
primary: primary,
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
stateObj: stateObj,
};
})
.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
this.hass.locale.language
)
);
if (includeDeviceClasses) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.device_class &&
includeDeviceClasses.includes(
item.stateObj.attributes.device_class
))
);
}
if (includeUnitOfMeasurement) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes(
item.stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilter) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj && entityFilter!(item.stateObj))
);
}
if (!items.length) {
return [
{
id: NO_ENTITIES_ID,
label: "",
primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_match"
),
icon_path: mdiMagnify,
},
...createItems,
];
}
if (createItems?.length) {
items.push(...createItems);
}
return items;
}
);
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
public willUpdate(changedProps: PropertyValues) {
if (!this._initialItems || (changedProps.has("_opened") && this._opened)) {
this._items = this._getItems(
this._opened,
this.hass,
this.includeDomains,
this.excludeDomains,
this.entityFilter,
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities,
this.createDomains
);
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
if (changedProps.has("createDomains") && this.createDomains?.length) {
this.hass.loadFragmentTranslation("config");
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="label"
.hass=${this.hass}
.value=${this._value}
.label=${this.label === undefined
? this.hass.localize("ui.components.entity.entity-picker.entity")
: this.label}
.helper=${this.helper}
.allowCustomValue=${this.allowCustomEntity}
.filteredItems=${this._items}
.renderer=${this._rowRenderer}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
`;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
// Clear the input field to prevent showing the old value next time
this.comboBox.setTextFieldValue("");
const newValue = ev.detail.value?.trim();
if (newValue && newValue.startsWith(CREATE_ID)) {
const domain = newValue.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
},
});
return;
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _fuseIndex = memoizeOne((states: EntityComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.trim().toLowerCase() as string;
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, {}, index);
const results = fuse.multiTermsSearch(filterString);
if (results) {
if (results.length === 0) {
target.filteredItems = [
{
id: NO_ENTITIES_ID,
label: "",
primary: this.hass!.localize(
"ui.components.entity.entity-picker.no_match"
),
icon_path: mdiMagnify,
},
] as EntityComboBoxItem[];
} else {
target.filteredItems = results.map((result) => result.item);
}
} else {
target.filteredItems = this._items;
}
}
private _setValue(value: string | undefined) {
if (!value || !isValidEntityId(value)) {
return;
}
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-entity-combo-box": HaEntityComboBox;
}
}

View File

@@ -1,45 +1,34 @@
import { mdiPlus, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { mdiClose, mdiMenuDown, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import {
isHelperDomain,
type HelperDomain,
} from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import { debounce } from "../../common/util/debounce";
import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-icon-button";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-svg-icon";
import "./ha-entity-combo-box";
import type {
HaEntityComboBox,
HaEntityComboBoxEntityFilterFunc,
} from "./ha-entity-combo-box";
import "./state-badge";
interface EntityComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
stateObj?: HassEntity;
}
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -54,9 +43,6 @@ export class HaEntityPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ type: Boolean, attribute: "show-entity-id" })
public showEntityId = false;
@property() public label?: string;
@property() public value?: string;
@@ -65,9 +51,6 @@ export class HaEntityPicker extends LitElement {
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[];
/**
@@ -119,12 +102,16 @@ export class HaEntityPicker extends LitElement {
public excludeEntities?: string[];
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
public entityFilter?: HaEntityComboBoxEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("#anchor") private _anchor?: HaMdListItem;
@query("#input") private _input?: HaEntityComboBox;
@state() private _opened = false;
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
@@ -132,19 +119,39 @@ export class HaEntityPicker extends LitElement {
this.hass.loadBackendTranslation("title");
}
private _valueRenderer: PickerValueRenderer = (value) => {
const entityId = value || "";
private _renderContent() {
const entityId = this.value || "";
if (!this.value) {
return html`
<span slot="headline" class="placeholder"
>${this.placeholder ??
this.hass.localize(
"ui.components.entity.entity-picker.placeholder"
)}</span
>
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
const stateObj = this.hass.states[entityId];
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
if (!stateObj) {
return html`
<ha-svg-icon
slot="start"
.path=${mdiShape}
style="margin: 0 4px"
></ha-svg-icon>
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<span slot="headline">${entityId}</span>
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
@@ -169,310 +176,170 @@ export class HaEntityPicker extends LitElement {
></state-badge>
<span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</span>
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
};
private get _showEntityId() {
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
}
private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this._showEntityId;
return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon
slot="start"
style="margin: 0 4px"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.stateObj && showEntityId
? html`
<span slot="supporting-text" class="code">
${item.stateObj.entity_id}
</span>
`
: nothing}
${item.domain_name && !showEntityId
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
};
private _getAdditionalItems = () =>
this._getCreateItems(this.hass.localize, this.createDomains);
private _getCreateItems = memoizeOne(
(
localize: this["hass"]["localize"],
createDomains: this["createDomains"]
) => {
if (!createDomains?.length) {
return [];
}
return createDomains.map((domain) => {
const primary = localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
)
: domainToName(localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: localize("ui.components.entity.entity-picker.new_entity"),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
}
);
private _getItems = () =>
this._getEntities(
this.hass,
this.includeDomains,
this.excludeDomains,
this.entityFilter,
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities
);
private _getEntities = memoizeOne(
(
hass: this["hass"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"],
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"]
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(hass.states);
if (includeEntities) {
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
const isRTL = computeRTL(this.hass);
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return {
id: entityId,
primary: primary,
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj,
};
});
if (includeDeviceClasses) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.device_class &&
includeDeviceClasses.includes(
item.stateObj.attributes.device_class
))
);
}
if (includeUnitOfMeasurement) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes(
item.stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilter) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj && entityFilter!(item.stateObj))
);
}
return items;
}
);
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.entity.entity-picker.no_match"
);
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.helper=${this.helper}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
${!this._opened
? html`<ha-combo-box-item
.disabled=${this.disabled}
id="anchor"
type="button"
compact
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>`
: html`<ha-entity-combo-box
id="input"
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomEntity=${this.allowCustomEntity}
.label=${this.hass.localize("ui.common.search")}
.value=${this.value}
.createDomains=${this.createDomains}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.includeUnitOfMeasurement=${this.includeUnitOfMeasurement}
.includeEntities=${this.includeEntities}
.excludeEntities=${this.excludeEntities}
.entityFilter=${this.entityFilter}
hide-clear-icon
@opened-changed=${this._debounceOpenedChanged}
@input=${stopPropagation}
></ha-entity-combo-box>`}
${this._renderHelper()}
</div>
`;
}
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
search,
filteredItems
) => {
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex(
(item) => item.stateObj?.entity_id === search
);
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
public async open() {
await this.updateComplete;
await this._picker?.open();
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
private _valueChanged(ev) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
return;
}
if (value.startsWith(CREATE_ID)) {
const domain = value.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) this._setValue(item.entityId);
},
});
return;
}
if (!isValidEntityId(value)) {
return;
}
this._setValue(value);
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
private _clear(e) {
e.stopPropagation();
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
private async _showPicker() {
if (this.disabled) {
return;
}
this._opened = true;
await this.updateComplete;
this._input?.focus();
this._input?.open();
}
// Multiple calls to _openedChanged can be triggered in quick succession
// when the menu is opened
private _debounceOpenedChanged = debounce(
(ev) => this._openedChanged(ev),
10
);
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._anchor?.focus();
}
}
static get styles(): CSSResultGroup {
return [
css`
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
.container {
position: relative;
display: block;
}
ha-combo-box-item {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: 4px;
border-end-end-radius: 0;
border-end-start-radius: 0;
--md-list-item-one-line-container-height: 56px;
--md-list-item-two-line-container-height: 56px;
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--ha-md-list-item-gap: 8px;
/* Remove the default focus ring */
--md-focus-ring-width: 0px;
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
ha-combo-box-item:focus:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-combo-box-item ha-svg-icon[slot="start"] {
margin: 0 4px;
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.edit {
--mdc-icon-size: 20px;
width: 32px;
}
label {
display: block;
margin: 0 0 8px;
}
.placeholder {
color: var(--secondary-text-color);
padding: 0 8px;
}
`,
];
}
}
declare global {

View File

@@ -0,0 +1,481 @@
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration";
import type { StatisticsMetaData } from "../../data/recorder";
import { getStatisticIds, getStatisticLabel } from "../../data/recorder";
import { HaFuse } from "../../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-combo-box-item";
import "../ha-svg-icon";
import "./state-badge";
import { documentationUrl } from "../../util/documentation-url";
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticItem {
// Force empty label to always display empty value by default in the search field
id: string;
statistic_id?: string;
label: "";
primary: string;
secondary?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
type?: StatisticItemType;
stateObj?: HassEntity;
}
const MISSING_ID = "___missing-entity___";
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
@customElement("ha-statistic-combo-box")
export class HaStatisticComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property({ attribute: "statistic-types" })
public statisticTypes?: "mean" | "sum";
@property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity;
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ type: Boolean }) public disabled = false;
/**
* Show only statistics natively stored with these units of measurements.
* @type {Array}
* @attr include-statistics-unit-of-measurement
*/
@property({
type: Array,
attribute: "include-statistics-unit-of-measurement",
})
public includeStatisticsUnitOfMeasurement?: string | string[];
/**
* Show only statistics with these unit classes.
* @attr include-unit-class
*/
@property({ attribute: "include-unit-class" })
public includeUnitClass?: string | string[];
/**
* Show only statistics with these device classes.
* @attr include-device-class
*/
@property({ attribute: "include-device-class" })
public includeDeviceClass?: string | string[];
/**
* Show only statistics on entities.
* @type {Boolean}
* @attr entities-only
*/
@property({ type: Boolean, attribute: "entities-only" })
public entitiesOnly = false;
/**
* List of statistics to be excluded.
* @type {Array}
* @attr exclude-statistics
*/
@property({ type: Array, attribute: "exclude-statistics" })
public excludeStatistics?: string[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
@state() private _opened = false;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _initialItems = false;
private _items: StatisticItem[] = [];
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
this.hass.loadBackendTranslation("title");
}
private _rowRenderer: ComboBoxLitRenderer<StatisticItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon
style="margin: 0 4px"
slot="start"
.path=${item.icon_path}
></ha-svg-icon>
`
: item.stateObj
? html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
<span slot="headline">${item.primary} </span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${item.id && showEntityId
? html`<span slot="supporting-text" class="code">
${item.statistic_id}
</span>`
: nothing}
</ha-combo-box-item>
`;
};
private _getItems = memoizeOne(
(
_opened: boolean,
hass: this["hass"],
statisticIds: StatisticsMetaData[],
includeStatisticsUnitOfMeasurement?: string | string[],
includeUnitClass?: string | string[],
includeDeviceClass?: string | string[],
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticItem[] => {
if (!statisticIds.length) {
return [
{
id: "",
label: "",
primary: this.hass.localize(
"ui.components.statistic-picker.no_statistics"
),
},
];
}
if (includeStatisticsUnitOfMeasurement) {
const includeUnits: (string | null)[] = ensureArray(
includeStatisticsUnitOfMeasurement
);
statisticIds = statisticIds.filter((meta) =>
includeUnits.includes(meta.statistics_unit_of_measurement)
);
}
if (includeUnitClass) {
const includeUnitClasses: (string | null)[] =
ensureArray(includeUnitClass);
statisticIds = statisticIds.filter((meta) =>
includeUnitClasses.includes(meta.unit_class)
);
}
if (includeDeviceClass) {
const includeDeviceClasses: (string | null)[] =
ensureArray(includeDeviceClass);
statisticIds = statisticIds.filter((meta) => {
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
return true;
}
return includeDeviceClasses.includes(
stateObj.attributes.device_class || ""
);
});
}
const isRTL = computeRTL(this.hass);
const output: StatisticItem[] = [];
statisticIds.forEach((meta) => {
if (
excludeStatistics &&
meta.statistic_id !== value &&
excludeStatistics.includes(meta.statistic_id)
) {
return;
}
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
if (!entitiesOnly) {
const id = meta.statistic_id;
const label = getStatisticLabel(this.hass, meta.statistic_id, meta);
const type =
meta.statistic_id.includes(":") &&
!meta.statistic_id.includes(".")
? "external"
: "no_state";
if (type === "no_state") {
output.push({
id,
primary: label,
secondary: this.hass.localize(
"ui.components.statistic-picker.no_state"
),
label: "",
type,
sorting_label: label,
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
output.push({
id,
statistic_id: id,
primary: label,
secondary: domainName,
label: "",
type,
sorting_label: label,
search_labels: [label, domainName, id],
icon_path: mdiChartLine,
});
}
}
return;
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
output.push({
id,
statistic_id: id,
label: "",
primary,
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
id,
].filter(Boolean) as string[],
});
});
if (!output.length) {
return [
{
id: "",
primary: this.hass.localize(
"ui.components.statistic-picker.no_match"
),
label: "",
},
];
}
if (output.length > 1) {
output.sort((a, b) => {
const aPrefix = TYPE_ORDER.indexOf(a.type || "no_state");
const bPrefix = TYPE_ORDER.indexOf(b.type || "no_state");
return caseInsensitiveStringCompare(
`${aPrefix}_${a.sorting_label || ""}`,
`${bPrefix}_${b.sorting_label || ""}`,
this.hass.locale.language
);
});
}
output.push({
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
label: "",
icon_path: mdiHelpCircle,
});
return output;
}
);
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
public willUpdate(changedProps: PropertyValues) {
if (
(!this.hasUpdated && !this.statisticIds) ||
changedProps.has("statisticTypes")
) {
this._getStatisticIds();
}
if (
this.statisticIds &&
(!this._initialItems || (changedProps.has("_opened") && this._opened))
) {
this._items = this._getItems(
this._opened,
this.hass,
this.statisticIds!,
this.includeStatisticsUnitOfMeasurement,
this.includeUnitClass,
this.includeDeviceClass,
this.entitiesOnly,
this.excludeStatistics,
this.value
);
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
}
protected render(): TemplateResult | typeof nothing {
if (this._items.length === 0) {
return nothing;
}
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="label"
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.statistic-picker.statistic")
: this.label}
.value=${this._value}
.renderer=${this._rowRenderer}
.disabled=${this.disabled}
.allowCustomValue=${this.allowCustomEntity}
.filteredItems=${this._items}
@opened-changed=${this._openedChanged}
@value-changed=${this._statisticChanged}
@filter-changed=${this._filterChanged}
></ha-combo-box>
`;
}
private async _getStatisticIds() {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
}
private get _value() {
return this.value || "";
}
private _statisticChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === MISSING_ID) {
newValue = "";
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _fuseIndex = memoizeOne((states: StatisticItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const filterString = ev.detail.value.trim().toLowerCase() as string;
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, {}, index);
const results = fuse.multiTermsSearch(filterString);
if (results) {
target.filteredItems = results.map((result) => result.item);
} else {
target.filteredItems = this._items;
}
}
private _setValue(value: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-statistic-combo-box": HaStatisticComboBox;
}
}

View File

@@ -1,48 +1,45 @@
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { mdiChartLine, mdiClose, mdiMenuDown, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeStateName } from "../../common/entity/compute_state_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { domainToName } from "../../data/integration";
import {
getStatisticIds,
getStatisticLabel,
type StatisticsMetaData,
} from "../../data/recorder";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button";
import "../ha-input-helper-text";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import type { HaMdListItem } from "../ha-md-list-item";
import "../ha-svg-icon";
import "./ha-entity-combo-box";
import type { HaEntityComboBox } from "./ha-entity-combo-box";
import "./ha-statistic-combo-box";
import "./state-badge";
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
const MISSING_ID = "___missing-entity___";
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string;
interface StatisticItem {
primary: string;
secondary?: string;
iconPath?: string;
stateObj?: HassEntity;
type?: StatisticItemType;
}
@customElement("ha-statistic-picker")
@@ -73,9 +70,6 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
/**
* Show only statistics natively stored with these units of measurements.
* @type {Array}
@@ -120,7 +114,11 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("#anchor") private _anchor?: HaMdListItem;
@query("#input") private _input?: HaEntityComboBox;
@state() private _opened = false;
public willUpdate(changedProps: PropertyValues) {
if (
@@ -135,167 +133,6 @@ export class HaStatisticPicker extends LitElement {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
}
private _getItems = () =>
this._getStatisticsItems(
this.hass,
this.statisticIds,
this.includeStatisticsUnitOfMeasurement,
this.includeUnitClass,
this.includeDeviceClass,
this.entitiesOnly,
this.excludeStatistics,
this.value
);
private _getAdditionalItems(): StatisticComboBoxItem[] {
return [
{
id: MISSING_ID,
primary: this.hass.localize(
"ui.components.statistic-picker.missing_entity"
),
icon_path: mdiHelpCircle,
},
];
}
private _getStatisticsItems = memoizeOne(
(
hass: HomeAssistant,
statisticIds?: StatisticsMetaData[],
includeStatisticsUnitOfMeasurement?: string | string[],
includeUnitClass?: string | string[],
includeDeviceClass?: string | string[],
entitiesOnly?: boolean,
excludeStatistics?: string[],
value?: string
): StatisticComboBoxItem[] => {
if (!statisticIds) {
return [];
}
if (includeStatisticsUnitOfMeasurement) {
const includeUnits: (string | null)[] = ensureArray(
includeStatisticsUnitOfMeasurement
);
statisticIds = statisticIds.filter((meta) =>
includeUnits.includes(meta.statistics_unit_of_measurement)
);
}
if (includeUnitClass) {
const includeUnitClasses: (string | null)[] =
ensureArray(includeUnitClass);
statisticIds = statisticIds.filter((meta) =>
includeUnitClasses.includes(meta.unit_class)
);
}
if (includeDeviceClass) {
const includeDeviceClasses: (string | null)[] =
ensureArray(includeDeviceClass);
statisticIds = statisticIds.filter((meta) => {
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
return true;
}
return includeDeviceClasses.includes(
stateObj.attributes.device_class || ""
);
});
}
const isRTL = computeRTL(this.hass);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
if (
excludeStatistics &&
meta.statistic_id !== value &&
excludeStatistics.includes(meta.statistic_id)
) {
return;
}
const stateObj = this.hass.states[meta.statistic_id];
if (!stateObj) {
if (!entitiesOnly) {
const id = meta.statistic_id;
const label = getStatisticLabel(this.hass, meta.statistic_id, meta);
const type =
meta.statistic_id.includes(":") &&
!meta.statistic_id.includes(".")
? "external"
: "no_state";
const sortingPrefix = `${TYPE_ORDER.indexOf(type)}`;
if (type === "no_state") {
output.push({
id,
primary: label,
secondary: this.hass.localize(
"ui.components.statistic-picker.no_state"
),
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, id],
icon_path: mdiShape,
});
} else if (type === "external") {
const domain = id.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
output.push({
id,
statistic_id: id,
primary: label,
secondary: domainName,
type,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, id],
icon_path: mdiChartLine,
});
}
}
return;
}
const id = meta.statistic_id;
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const primary = entityName || deviceName || id;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
id,
statistic_id: id,
primary,
secondary,
a11y_label: a11yLabel,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
id,
].filter(Boolean) as string[],
});
});
return output;
}
);
private _statisticMetaData = memoizeOne(
(statisticId: string, statisticIds: StatisticsMetaData[]) => {
if (!statisticIds) {
@@ -307,11 +144,26 @@ export class HaStatisticPicker extends LitElement {
}
);
private _valueRenderer: PickerValueRenderer = (value) => {
const statisticId = value;
private _renderContent() {
const statisticId = this.value || "";
if (!this.value) {
return html`
<span slot="headline" class="placeholder"
>${this.placeholder ??
this.hass.localize(
"ui.components.statistic-picker.placeholder"
)}</span
>
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
const item = this._computeItem(statisticId);
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
return html`
${item.stateObj
? html`
@@ -321,19 +173,29 @@ export class HaStatisticPicker extends LitElement {
slot="start"
></state-badge>
`
: item.icon_path
? html`
<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
`
: item.iconPath
? html`<ha-svg-icon
slot="start"
.path=${item.iconPath}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
};
}
private _computeItem(statisticId: string): StatisticComboBoxItem {
private _computeItem(statisticId: string): StatisticItem {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
@@ -349,24 +211,11 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
id: statisticId,
statistic_id: statisticId,
primary,
secondary,
stateObj: stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
statisticId,
].filter(Boolean) as string[],
stateObj,
};
}
@@ -381,143 +230,175 @@ export class HaStatisticPicker extends LitElement {
: "no_state";
if (type === "external") {
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic);
const domain = statisticId.split(":")[0];
const domainName = domainToName(this.hass.localize, domain);
return {
id: statisticId,
statistic_id: statisticId,
primary: label,
secondary: domainName,
type: "external",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, statisticId],
icon_path: mdiChartLine,
iconPath: mdiChartLine,
};
}
}
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic);
return {
id: statisticId,
primary: label,
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, statisticId],
icon_path: mdiShape,
primary: statisticId,
iconPath: mdiShape,
};
}
private _rowRenderer: ComboBoxLitRenderer<StatisticComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html`
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
${item.icon_path
? html`
<ha-svg-icon
style="margin: 0 4px"
slot="start"
.path=${item.icon_path}
></ha-svg-icon>
`
: item.stateObj
? html`
<state-badge
slot="start"
.stateObj=${item.stateObj}
.hass=${this.hass}
></state-badge>
`
: nothing}
<span slot="headline">${item.primary} </span>
${item.secondary || item.type
? html`<span slot="supporting-text"
>${item.secondary} - ${item.type}</span
>`
: nothing}
${item.statistic_id && showEntityId
? html`<span slot="supporting-text" class="code">
${item.statistic_id}
</span>`
: nothing}
</ha-combo-box-item>
`;
};
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
${!this._opened
? html`
<ha-combo-box-item
.disabled=${this.disabled}
id="anchor"
type="button"
compact
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>
`
: html`
<ha-statistic-combo-box
id="input"
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomEntity=${this.allowCustomEntity}
.label=${this.hass.localize("ui.common.search")}
.value=${this.value}
.includeStatisticsUnitOfMeasurement=${this
.includeStatisticsUnitOfMeasurement}
.includeUnitClass=${this.includeUnitClass}
.includeDeviceClass=${this.includeDeviceClass}
.statisticTypes=${this.statisticTypes}
.statisticIds=${this.statisticIds}
.excludeStatistics=${this.excludeStatistics}
hide-clear-icon
@opened-changed=${this._debounceOpenedChanged}
@input=${stopPropagation}
></ha-statistic-combo-box>
`}
${this._renderHelper()}
</div>
`;
}
private _searchFn: PickerComboBoxSearchFn<StatisticComboBoxItem> = (
search,
filteredItems
) => {
// If there is exact match for entity id or statistic id, put it first
const index = filteredItems.findIndex(
(item) =>
item.stateObj?.entity_id === search || item.statistic_id === search
);
if (index === -1) {
return filteredItems;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
};
private _clear(e) {
e.stopPropagation();
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (value === MISSING_ID) {
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
private async _showPicker() {
if (this.disabled) {
return;
}
this.value = value;
fireEvent(this, "value-changed", { value });
this._opened = true;
await this.updateComplete;
this._input?.focus();
this._input?.open();
}
public async open() {
await this.updateComplete;
await this._picker?.open();
// Multiple calls to _openedChanged can be triggered in quick succession
// when the menu is opened
private _debounceOpenedChanged = debounce(
(ev) => this._openedChanged(ev),
10
);
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._anchor?.focus();
}
}
static get styles(): CSSResultGroup {
return [
css`
.container {
position: relative;
display: block;
}
ha-combo-box-item {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: 4px;
border-end-end-radius: 0;
border-end-start-radius: 0;
--md-list-item-one-line-container-height: 56px;
--md-list-item-two-line-container-height: 56px;
--md-list-item-top-space: 8px;
--md-list-item-bottom-space: 8px;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--ha-md-list-item-gap: 8px;
/* Remove the default focus ring */
--md-focus-ring-width: 0px;
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
ha-combo-box-item:focus:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
ha-combo-box-item ha-svg-icon[slot="start"] {
margin: 0 4px;
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.edit {
--mdc-icon-size: 20px;
width: 32px;
}
label {
display: block;
margin: 0 0 8px;
}
.placeholder {
color: var(--secondary-text-color);
padding: 0 8px;
}
`,
];
}
}

View File

@@ -108,7 +108,7 @@ class StateInfo extends LitElement {
.name.in-dialog,
:host([secondary-line]) .name {
line-height: var(--ha-line-height-condensed);
line-height: 20px;
}
.time-ago,

View File

@@ -25,6 +25,36 @@ declare global {
}
}
/**
* A custom alert component for displaying messages with various alert types.
*
* @element ha-alert
*
* @property {string} title - The title of the alert. Defaults to an empty string.
* @property {"info" | "warning" | "error" | "success"} alertType - The type of alert to display.
* Defaults to "info". Determines the styling and icon used.
* @property {boolean} dismissable - Whether the alert can be dismissed. Defaults to `false`.
* If `true`, a dismiss button is displayed.
* @property {boolean} narrow - Whether the alert should use a narrow layout. Defaults to `false`.
*
* @slot - The main content of the alert.
* @slot icon - Slot for providing a custom icon for the alert.
* @slot action - Slot for providing custom actions or buttons for the alert.
*
* @fires alert-dismissed-clicked - Fired when the dismiss button is clicked.
*
* @csspart issue-type - The container for the alert.
* @csspart icon - The container for the alert icon.
* @csspart content - The container for the alert content.
* @csspart action - The container for the alert actions.
* @csspart title - The container for the alert title.
*
* @cssprop --info-color - The color used for "info" alerts.
* @cssprop --warning-color - The color used for "warning" alerts.
* @cssprop --error-color - The color used for "error" alerts.
* @cssprop --success-color - The color used for "success" alerts.
* @cssprop --primary-text-color - The primary text color used in the alert.
*/
@customElement("ha-alert")
class HaAlert extends LitElement {
// eslint-disable-next-line lit/no-native-attributes
@@ -35,7 +65,7 @@ class HaAlert extends LitElement {
| "warning"
| "error"
| "success" = "info";
@property({ type: Boolean }) public dismissable = false;
@property({ type: Boolean }) public narrow = false;

View File

@@ -1,16 +1,16 @@
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
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 { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { stringCompare } from "../common/string/compare";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import { computeRTL } from "../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../data/area_registry";
import type {
@@ -19,33 +19,29 @@ import type {
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import {
getFloorAreaLookup,
type FloorRegistryEntry,
} from "../data/floor_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import { getFloorAreaLookup } from "../data/floor_registry";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
import "./ha-tree-indicator";
const SEPARATOR = "________";
type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry;
interface FloorComboBoxItem extends PickerComboBoxItem {
type: "floor" | "area";
floor?: FloorRegistryEntry;
area?: AreaRegistryEntry;
}
interface AreaFloorValue {
id: string;
interface FloorAreaEntry {
id: string | null;
name: string;
icon: string | null;
strings: string[];
type: "floor" | "area";
level: number | null;
hasFloor?: boolean;
lastArea?: boolean;
}
@customElement("ha-area-floor-picker")
@@ -54,15 +50,12 @@ export class HaAreaFloorPicker extends LitElement {
@property() public label?: string;
@property({ attribute: false }) public value?: AreaFloorValue;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
/**
* Show only areas with entities from specific domains.
* @type {Array}
@@ -113,53 +106,66 @@ export class HaAreaFloorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _init = false;
public async open() {
await this.updateComplete;
await this._picker?.open();
await this.comboBox?.open();
}
private _valueRenderer: PickerValueRenderer = (value: string) => {
const item = this._parseValue(value);
const area = item.type === "area" && this.hass.areas[value];
if (area) {
const areaName = computeAreaName(area);
return html`
${area.icon
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<slot name="headline">${areaName}</slot>
`;
}
const floor = item.type === "floor" && this.hass.floors[value];
if (floor) {
const floorName = computeFloorName(floor);
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) => {
const rtl = computeRTL(this.hass);
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${value}</span>
<ha-combo-box-item
type="button"
style=${item.type === "area" && item.hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && item.hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.lastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor"
? html`<ha-floor-icon slot="start" .floor=${item}></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
`;
};
private _getAreasAndFloors = memoizeOne(
private _getAreas = memoizeOne(
(
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
@@ -167,11 +173,19 @@ export class HaAreaFloorPicker extends LitElement {
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"]
): FloorComboBoxItem[] => {
const floors = Object.values(haFloors);
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
): FloorAreaEntry[] => {
if (!areas.length && !floors.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_areas"),
icon: null,
strings: [],
level: null,
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
@@ -312,6 +326,19 @@ export class HaAreaFloorPicker extends LitElement {
);
}
if (!outputAreas.length) {
return [
{
id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
strings: [],
level: null,
},
];
}
const floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
@@ -333,186 +360,151 @@ export class HaAreaFloorPicker extends LitElement {
return stringCompare(floorA.name, floorB.name);
});
const items: FloorComboBoxItem[] = [];
const output: FloorAreaEntry[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) {
const floorName = computeFloorName(floor);
const areaSearchLabels = floorAreas
.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return [area.area_id, areaName, ...area.aliases];
})
.flat();
items.push({
id: this._formatValue({ id: floor.floor_id, type: "floor" }),
output.push({
id: floor.floor_id,
type: "floor",
primary: floorName,
floor: floor,
search_labels: [
floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
name: floor.name,
icon: floor.icon,
strings: [floor.floor_id, ...floor.aliases, floor.name],
level: floor.level,
});
}
items.push(
...floorAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: this._formatValue({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
area: area,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
};
})
output.push(
...floorAreas.map((area, index, array) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
hasFloor: true,
level: null,
lastArea: index === array.length - 1,
}))
);
});
items.push(
...unassisgnedAreas.map((area) => {
const areaName = computeAreaName(area) || area.area_id;
return {
id: this._formatValue({ id: area.area_id, type: "area" }),
type: "area" as const,
primary: areaName,
icon: area.icon || undefined,
search_labels: [area.area_id, areaName, ...area.aliases],
};
})
if (!output.length && !unassisgnedAreas.length) {
output.push({
id: "no_areas",
type: "area",
name: this.hass.localize(
"ui.components.area-picker.unassigned_areas"
),
icon: null,
strings: [],
level: null,
});
}
output.push(
...unassisgnedAreas.map((area) => ({
id: area.area_id,
type: "area" as const,
name: area.name,
icon: area.icon,
strings: [area.area_id, ...area.aliases, area.name],
level: null,
}))
);
return items;
return output;
}
);
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
item,
{ index },
combobox
) => {
const nextItem = combobox.filteredItems?.[index + 1];
const isLastArea =
!nextItem ||
nextItem.type === "floor" ||
(nextItem.type === "area" && !nextItem.area?.floor_id);
const rtl = computeRTL(this.hass);
const hasFloor = item.type === "area" && item.area?.floor_id;
return html`
<ha-combo-box-item
type="button"
style=${item.type === "area" && hasFloor
? "--md-list-item-leading-space: 48px;"
: ""}
>
${item.type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "48px",
position: "absolute",
top: "0px",
left: rtl ? undefined : "4px",
right: rtl ? "4px" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${isLastArea}
slot="start"
></ha-tree-indicator>
`
: nothing}
${item.type === "floor" && item.floor
? html`<ha-floor-icon
slot="start"
.floor=${item.floor}
></ha-floor-icon>`
: item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path || mdiTextureBox}
></ha-svg-icon>`}
${item.primary}
</ha-combo-box-item>
`;
};
private _getItems = () =>
this._getAreasAndFloors(
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
private _formatValue = memoizeOne((value: AreaFloorValue): string =>
[value.type, value.id].join(SEPARATOR)
);
private _parseValue = memoizeOne((value: string): AreaFloorValue => {
const [type, id] = value.split(SEPARATOR);
return { id, type: type as "floor" | "area" };
});
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const areas = this._getAreas(
Object.values(this.hass.floors),
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas,
this.excludeFloors
);
this.comboBox.items = areas;
this.comboBox.filteredItems = areas;
}
}
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const value = this.value ? this._formatValue(this.value) : undefined;
return html`
<ha-generic-picker
<ha-combo-box
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.placeholder=${placeholder}
.value=${value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
.helper=${this.helper}
item-value-path="id"
item-id-path="id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${this._rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
>
</ha-generic-picker>
</ha-combo-box>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const selected = this._parseValue(value);
this._setValue(selected);
const filteredItems = fuzzyFilterSort<ScorableAreaFloorEntry>(
filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
}
private _setValue(value?: AreaFloorValue) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private async _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (newValue === "no_areas") {
return;
}
const selected = this.comboBox.selectedItem;
fireEvent(this, "value-changed", {
value: {
id: selected.id,
type: selected.type,
},
});
}
}

View File

@@ -1,14 +1,15 @@
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import type { AreaRegistryEntry } from "../data/area_registry";
import { createAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
@@ -20,15 +21,26 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
`;
const ADD_NEW_ID = "___ADD_NEW___";
const NO_ITEMS_ID = "___NO_ITEMS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
@customElement("ha-area-picker")
export class HaAreaPicker extends LitElement {
@@ -87,68 +99,41 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this._picker?.open();
await this.comboBox?.open();
}
// Recompute value renderer when the areas change
private _computeValueRenderer = memoizeOne(
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
(value) => {
const area = this.hass.areas[value];
if (!area) {
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${area}</span>
`;
}
const { floor } = getAreaContext(area, this.hass);
const areaName = area ? computeAreaName(area) : undefined;
const floorName = floor ? computeFloorName(floor) : undefined;
const icon = area.icon;
return html`
${icon
? html`<ha-icon slot="start" .icon=${icon}></ha-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiTextureBox}
></ha-svg-icon>`}
<span slot="headline">${areaName}</span>
${floorName
? html`<span slot="supporting-text">${floorName}</span>`
: nothing}
`;
}
);
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getAreas = memoizeOne(
(
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeAreas: this["excludeAreas"]
): PickerComboBoxItem[] => {
): AreaRegistryEntry[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
if (
includeDomains ||
excludeDomains ||
@@ -278,147 +263,225 @@ export class HaAreaPicker extends LitElement {
);
}
const items = outputAreas.map<PickerComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {
id: area.area_id,
primary: areaName || area.area_id,
secondary: floorName,
icon: area.icon || undefined,
icon_path: area.icon ? undefined : mdiTextureBox,
sorting_label: areaName,
search_labels: [
areaName,
floorName,
area.area_id,
...area.aliases,
].filter((v): v is string => Boolean(v)),
};
});
if (!outputAreas.length) {
outputAreas = [
{
area_id: NO_ITEMS_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_areas"),
picture: null,
icon: null,
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
}
return items;
}
);
private _getItems = () =>
this._getAreas(
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeAreas
);
private _allAreaNames = memoizeOne(
(areas: HomeAssistant["areas"]) =>
Object.values(areas)
.map((area) => computeAreaName(area)?.toLowerCase())
.filter(Boolean) as string[]
);
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
}
const allAreas = this._allAreaNames(this.hass.areas);
if (searchString && !allAreas.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
return noAdd
? outputAreas
: [
...outputAreas,
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
area_id: ADD_NEW_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.add_new"),
picture: null,
icon: "mdi:plus",
aliases: [],
labels: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
];
}
);
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const areas = this._getAreas(
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeAreas
).map((area) => ({
...area,
strings: [area.area_id, ...area.aliases, area.name],
}));
this.comboBox.items = areas;
this.comboBox.filteredItems = areas;
}
}
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas);
return html`
<ha-generic-picker
<ha-combo-box
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.area-picker.no_match"
)}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
.helper=${this.helper}
item-value-path="area_id"
item-id-path="area_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
>
</ha-generic-picker>
</ha-combo-box>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
const filteredItems = fuzzyFilterSort<ScorableAreaRegistryEntry>(
filterString,
target.items?.filter(
(item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (!this.noAdd) {
this.comboBox.filteredItems = [
{
area_id: NO_ITEMS_ID,
floor_id: null,
name: this.hass.localize("ui.components.area-picker.no_match"),
icon: null,
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
area_id: ADD_NEW_SUGGESTION_ID,
floor_id: null,
name: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
picture: null,
labels: [],
aliases: [],
temperature_entity_id: null,
humidity_entity_id: null,
created_at: 0,
modified_at: 0,
},
] as AreaRegistryEntry[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
const suggestedName = value.substring(ADD_NEW_ID.length);
private get _value() {
return this.value || "";
}
showAreaRegistryDetailDialog(this, {
suggestedName: suggestedName,
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this.hass, values);
this._setValue(area.area_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.area-picker.failed_create_area"
),
text: err.message,
});
}
},
});
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_ITEMS_ID) {
newValue = "";
this.comboBox.setInputValue("");
return;
}
this._setValue(value);
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
this.hass.loadFragmentTranslation("config");
showAreaRegistryDetailDialog(this, {
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values) => {
try {
const area = await createAreaRegistryEntry(this.hass, values);
const areas = [...Object.values(this.hass.areas), area];
this.comboBox.filteredItems = this._getAreas(
areas,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeAreas
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(area.area_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.area-picker.failed_create_area"
),
text: err.message,
});
}
},
});
this._suggestion = undefined;
this.comboBox.setInputValue("");
}
private _setValue(value?: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}

View File

@@ -5,11 +5,8 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../types";
import {
type PipelineRunEvent,
runAssistPipeline,
type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
} from "../data/assist_pipeline";
import { supportsFeature } from "../common/entity/supports-feature";
import { ConversationEntityFeature } from "../data/conversation";
@@ -93,7 +90,7 @@ export class HaAssistChat extends LitElement {
super.disconnectedCallback();
this._audioRecorder?.close();
this._audioRecorder = undefined;
this._unloadAudio();
this._audio?.pause();
this._conversation = [];
this._conversationId = null;
}
@@ -112,24 +109,25 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html`
<div class="messages" id="scroll-container">
${controlHA
? nothing
: html`
<ha-alert>
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
`}
<div class="spacer"></div>
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html`
${controlHA
? nothing
: html`
<ha-alert>
${this.hass.localize(
"ui.dialogs.voice_command.conversation_no_control"
)}
</ha-alert>
`}
<div class="messages">
<div class="messages-container" id="scroll-container">
${this._conversation!.map(
// New lines matter for messages
// prettier-ignore
(message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
`
)}
)}
</div>
</div>
<div class="input" slot="primaryAction">
<ha-textfield
@@ -275,8 +273,8 @@ export class HaAssistChat extends LitElement {
}
private async _startListening() {
this._unloadAudio();
this._processing = true;
this._audio?.pause();
if (!this._audioRecorder) {
this._audioRecorder = new AudioRecorder((audio) => {
if (this._audioBuffer) {
@@ -295,36 +293,27 @@ export class HaAssistChat extends LitElement {
await this._audioRecorder.start();
this._addMessage(userMessage);
this.requestUpdate("_audioRecorder");
const hassMessageProcesser = this._createAddHassMessageProcessor();
let continueConversation = false;
let hassMessage = {
who: "hass",
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
try {
const unsub = await runAssistPipeline(
this.hass,
(event: PipelineRunEvent) => {
(event) => {
if (event.type === "run-start") {
this._stt_binary_handler_id =
event.data.runner_data.stt_binary_handler_id;
this._audio = new Audio(event.data.tts_output!.url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
if (hassMessageProcesser.continueConversation) {
this._startListening();
}
});
this._audio.addEventListener("pause", this._unloadAudio);
this._audio.addEventListener("canplaythrough", () =>
this._audio?.play()
);
this._audio.addEventListener("error", () => {
this._unloadAudio();
showAlertDialog(this, { title: "Error playing audio." });
});
}
// When we start STT stage, the WS has a binary handler
else if (event.type === "stt-start" && this._audioBuffer) {
if (event.type === "stt-start" && this._audioBuffer) {
// Send the buffer over the WS to the STT engine.
for (const buffer of this._audioBuffer) {
this._sendAudioChunk(buffer);
@@ -333,26 +322,91 @@ export class HaAssistChat extends LitElement {
}
// Stop recording if the server is done with STT stage
else if (event.type === "stt-end") {
if (event.type === "stt-end") {
this._stt_binary_handler_id = undefined;
this._stopListening();
userMessage.text = event.data.stt_output.text;
this.requestUpdate("_conversation");
// Add the response message placeholder to the chat when we know the STT is done
hassMessageProcesser.addMessage();
} else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
} else if (event.type === "run-end") {
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
}
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (currentDeltaRole && delta.role && hassMessage.text !== "…") {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
continueConversation =
event.data.intent_output.continue_conversation;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
}
this.requestUpdate("_conversation");
}
if (event.type === "tts-end") {
const url = event.data.tts_output.url;
this._audio = new Audio(url);
this._audio.play();
this._audio.addEventListener("ended", () => {
this._unloadAudio();
if (continueConversation) {
this._startListening();
}
});
this._audio.addEventListener("pause", this._unloadAudio);
this._audio.addEventListener("canplaythrough", this._playAudio);
this._audio.addEventListener("error", this._audioError);
}
if (event.type === "run-end") {
this._stt_binary_handler_id = undefined;
unsub();
} else if (event.type === "error") {
this._unloadAudio();
}
if (event.type === "error") {
this._stt_binary_handler_id = undefined;
if (userMessage.text === "…") {
userMessage.text = event.data.message;
userMessage.error = true;
} else {
hassMessageProcesser.setError(event.data.message);
hassMessage.text = event.data.message;
hassMessage.error = true;
}
this._stopListening();
this.requestUpdate("_conversation");
@@ -410,33 +464,90 @@ export class HaAssistChat extends LitElement {
this.hass.connection.socket!.send(data);
}
private _playAudio = () => {
this._audio?.play();
};
private _audioError = () => {
showAlertDialog(this, { title: "Error playing audio." });
this._audio?.removeAttribute("src");
};
private _unloadAudio = () => {
if (!this._audio) {
return;
}
this._audio.pause();
this._audio.removeAttribute("src");
this._audio?.removeAttribute("src");
this._audio = undefined;
};
private async _processText(text: string) {
this._unloadAudio();
this._processing = true;
this._audio?.pause();
this._addMessage({ who: "user", text });
const hassMessageProcesser = this._createAddHassMessageProcessor();
hassMessageProcesser.addMessage();
let hassMessage = {
who: "hass",
text: "…",
error: false,
};
let currentDeltaRole = "";
// To make sure the answer is placed at the right user text, we add it before we process it
this._addMessage(hassMessage);
try {
const unsub = await runAssistPipeline(
this.hass,
(event) => {
if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-progress") {
const delta = event.data.chat_log_delta;
// new message and previous message has content
if (delta.role) {
// If currentDeltaRole exists, it means we're receiving our
// second or later message. Let's add it to the chat.
if (
currentDeltaRole &&
delta.role === "assistant" &&
hassMessage.text !== "…"
) {
// Remove progress indicator of previous message
hassMessage.text = hassMessage.text.substring(
0,
hassMessage.text.length - 1
);
hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(hassMessage);
}
currentDeltaRole = delta.role;
}
if (
currentDeltaRole === "assistant" &&
"content" in delta &&
delta.content
) {
hassMessage.text =
hassMessage.text.substring(0, hassMessage.text.length - 1) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
}
if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
const plain = event.data.intent_output.response.speech?.plain;
if (plain) {
hassMessage.text = plain.speech;
}
this.requestUpdate("_conversation");
unsub();
}
if (event.type === "error") {
hassMessageProcesser.setError(event.data.message);
hassMessage.text = event.data.message;
hassMessage.error = true;
this.requestUpdate("_conversation");
unsub();
}
},
@@ -449,126 +560,20 @@ export class HaAssistChat extends LitElement {
}
);
} catch {
hassMessageProcesser.setError(
this.hass.localize("ui.dialogs.voice_command.error")
);
hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error");
hassMessage.error = true;
this.requestUpdate("_conversation");
} finally {
this._processing = false;
}
}
private _createAddHassMessageProcessor() {
let currentDeltaRole = "";
const progressToNextMessage = () => {
if (progress.hassMessage.text === "…") {
return;
}
progress.hassMessage.text = progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
);
progress.hassMessage = {
who: "hass",
text: "…",
error: false,
};
this._addMessage(progress.hassMessage);
};
const isAssistantDelta = (
_delta: any
): _delta is Partial<ConversationChatLogAssistantDelta> =>
currentDeltaRole === "assistant";
const isToolResult = (
_delta: any
): _delta is ConversationChatLogToolResultDelta =>
currentDeltaRole === "tool_result";
const tools: Record<
string,
ConversationChatLogAssistantDelta["tool_calls"][0]
> = {};
const progress = {
continueConversation: false,
hassMessage: {
who: "hass",
text: "…",
error: false,
},
addMessage: () => {
this._addMessage(progress.hassMessage);
},
setError: (error: string) => {
progressToNextMessage();
progress.hassMessage.text = error;
progress.hassMessage.error = true;
this.requestUpdate("_conversation");
},
processEvent: (event: PipelineRunEvent) => {
if (event.type === "intent-progress" && event.data.chat_log_delta) {
const delta = event.data.chat_log_delta;
// new message
if (delta.role) {
progressToNextMessage();
currentDeltaRole = delta.role;
}
if (isAssistantDelta(delta)) {
if (delta.content) {
progress.hassMessage.text =
progress.hassMessage.text.substring(
0,
progress.hassMessage.text.length - 1
) +
delta.content +
"…";
this.requestUpdate("_conversation");
}
if (delta.tool_calls) {
for (const toolCall of delta.tool_calls) {
tools[toolCall.id] = toolCall;
}
}
} else if (isToolResult(delta)) {
if (tools[delta.tool_call_id]) {
delete tools[delta.tool_call_id];
}
}
} else if (event.type === "intent-end") {
this._conversationId = event.data.intent_output.conversation_id;
progress.continueConversation =
event.data.intent_output.continue_conversation;
const response =
event.data.intent_output.response.speech?.plain.speech;
if (!response) {
return;
}
if (event.data.intent_output.response.response_type === "error") {
progress.setError(response);
} else {
progress.hassMessage.text = response;
this.requestUpdate("_conversation");
}
}
},
};
return progress;
}
static styles = css`
:host {
flex: 1;
display: flex;
flex-direction: column;
}
ha-alert {
margin-bottom: 8px;
}
ha-textfield {
display: block;
}
@@ -576,14 +581,17 @@ export class HaAssistChat extends LitElement {
flex: 1;
display: block;
box-sizing: border-box;
position: relative;
}
.messages-container {
position: absolute;
bottom: 0px;
right: 0px;
left: 0px;
padding: 0px 10px 16px;
box-sizing: border-box;
overflow-y: auto;
max-height: 100%;
display: flex;
flex-direction: column;
padding: 0 12px 16px;
}
.spacer {
flex: 1;
}
.message {
white-space: pre-line;
@@ -593,9 +601,6 @@ export class HaAssistChat extends LitElement {
padding: 8px;
border-radius: 15px;
}
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) {
.message {
@@ -614,7 +619,7 @@ export class HaAssistChat extends LitElement {
margin-left: 24px;
margin-inline-start: 24px;
margin-inline-end: initial;
align-self: flex-end;
float: var(--float-end);
text-align: right;
border-bottom-right-radius: 0px;
background-color: var(--chat-background-color-user, var(--primary-color));
@@ -626,7 +631,7 @@ export class HaAssistChat extends LitElement {
margin-right: 24px;
margin-inline-end: 24px;
margin-inline-start: initial;
align-self: flex-start;
float: var(--float-start);
border-bottom-left-radius: 0px;
background-color: var(
--chat-background-color-hass,

View File

@@ -106,7 +106,7 @@ export class HaBadge extends LitElement {
font-size: var(--ha-badge-font-size, var(--ha-font-size-s));
font-style: normal;
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
line-height: 16px;
letter-spacing: 0.1px;
color: var(--primary-text-color);
}

View File

@@ -81,27 +81,27 @@ export class HaBaseTimeInput extends LitElement {
/**
* Label for the day input
*/
@property({ type: String, attribute: "day-label" }) dayLabel = "";
@property({ attribute: false }) dayLabel = "";
/**
* Label for the hour input
*/
@property({ type: String, attribute: "hour-label" }) hourLabel = "";
@property({ attribute: false }) hourLabel = "";
/**
* Label for the min input
*/
@property({ type: String, attribute: "min-label" }) minLabel = "";
@property({ attribute: false }) minLabel = "";
/**
* Label for the sec input
*/
@property({ type: String, attribute: "sec-label" }) secLabel = "";
@property({ attribute: false }) secLabel = "";
/**
* Label for the milli sec input
*/
@property({ type: String, attribute: "ms-label" }) millisecLabel = "";
@property({ attribute: false }) millisecLabel = "";
/**
* show the sec field
@@ -342,7 +342,7 @@ export class HaBaseTimeInput extends LitElement {
padding-right: 3px;
}
ha-textfield {
width: 60px;
width: 55px;
flex-grow: 1;
text-align: center;
--mdc-shape-small: 0;
@@ -388,10 +388,7 @@ export class HaBaseTimeInput extends LitElement {
var(--mdc-typography-font-family, var(--ha-font-family-body))
);
font-size: var(--mdc-typography-body2-font-size, var(--ha-font-size-s));
line-height: var(
--mdc-typography-body2-line-height,
var(--ha-line-height-condensed)
);
line-height: var(--mdc-typography-body2-line-height, 1.25rem);
font-weight: var(
--mdc-typography-body2-font-weight,
var(--ha-font-weight-normal)
@@ -409,7 +406,7 @@ export class HaBaseTimeInput extends LitElement {
}
ha-input-helper-text {
padding-top: 8px;
line-height: var(--ha-line-height-condensed);
line-height: normal;
}
`;
}

View File

@@ -92,7 +92,7 @@ export class HaBigNumber extends LitElement {
}
.value .unit {
font-size: 0.33em;
line-height: var(--ha-line-height-condensed);
line-height: 1.26;
}
/* Accessibility */
.visually-hidden {

View File

@@ -43,7 +43,7 @@ export class HaCard extends LitElement {
font-family: var(--ha-card-header-font-family, inherit);
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em;
line-height: var(--ha-line-height-expanded);
line-height: 48px;
padding: 12px 16px 16px;
display: block;
margin-block-start: 0px;

View File

@@ -21,12 +21,12 @@ export class HaComboBoxItem extends HaMdListItem {
--state-icon-color: var(--secondary-text-color);
}
[slot="headline"] {
line-height: var(--ha-line-height-normal);
line-height: 22px;
font-size: var(--ha-font-size-m);
white-space: nowrap;
}
[slot="supporting-text"] {
line-height: var(--ha-line-height-normal);
line-height: 18px;
font-size: var(--ha-font-size-s);
white-space: nowrap;
}

View File

@@ -1,24 +0,0 @@
import type { PropertyValues } from "lit";
import { customElement, property } from "lit/decorators";
import { HaTextField } from "./ha-textfield";
@customElement("ha-combo-box-textfield")
export class HaComboBoxTextField extends HaTextField {
@property({ type: Boolean, attribute: "disable-set-value" })
public disableSetValue = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value")) {
if (this.disableSetValue) {
this.value = changedProps.get("value") as string;
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-combo-box-textfield": HaComboBoxTextField;
}
}

View File

@@ -12,12 +12,11 @@ import type {
import { registerStyles } from "@vaadin/vaadin-themable-mixin/register-styles";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import "./ha-combo-box-textfield";
import "./ha-icon-button";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -109,14 +108,9 @@ export class HaComboBox extends LitElement {
@property({ type: Boolean, attribute: "hide-clear-icon" })
public hideClearIcon = false;
@property({ type: Boolean, attribute: "clear-initial-value" })
public clearInitialValue = false;
@query("vaadin-combo-box-light", true) private _comboBox!: ComboBoxLight;
@query("ha-combo-box-textfield", true) private _inputElement!: HaTextField;
@state({ type: Boolean }) private _disableSetValue = false;
@query("ha-textfield", true) private _inputElement!: HaTextField;
private _overlayMutationObserver?: MutationObserver;
@@ -177,7 +171,7 @@ export class HaComboBox extends LitElement {
@value-changed=${this._valueChanged}
attr-for-value="value"
>
<ha-combo-box-textfield
<ha-textfield
label=${ifDefined(this.label)}
placeholder=${ifDefined(this.placeholder)}
?disabled=${this.disabled}
@@ -197,10 +191,9 @@ export class HaComboBox extends LitElement {
.invalid=${this.invalid}
.helper=${this.helper}
helperPersistent
.disableSetValue=${this._disableSetValue}
>
<slot name="icon" slot="leadingIcon"></slot>
</ha-combo-box-textfield>
</ha-textfield>
${this.value && !this.hideClearIcon
? html`<ha-svg-icon
role="button"
@@ -253,20 +246,8 @@ export class HaComboBox extends LitElement {
// delay this so we can handle click event for toggle button before setting _opened
setTimeout(() => {
this.opened = opened;
fireEvent(this, "opened-changed", { value: ev.detail.value });
}, 0);
if (this.clearInitialValue) {
this.setTextFieldValue("");
if (opened) {
// Wait 100ms to be sure vaddin-combo-box-light already tried to set the value
setTimeout(() => {
this._disableSetValue = false;
}, 100);
} else {
this._disableSetValue = true;
}
}
fireEvent(this, "opened-changed", { value: ev.detail.value });
if (opened) {
const overlay = document.querySelector<HTMLElement>(
@@ -345,10 +326,8 @@ export class HaComboBox extends LitElement {
// @ts-ignore
this._comboBox._closeOnBlurIsPrevented = true;
}
if (!this.opened) {
return;
}
const newValue = ev.detail.value;
if (newValue !== this.value) {
fireEvent(this, "value-changed", { value: newValue || undefined });
}
@@ -363,10 +342,10 @@ export class HaComboBox extends LitElement {
position: relative;
--vaadin-combo-box-overlay-max-height: calc(45vh - 56px);
}
ha-combo-box-textfield {
ha-textfield {
width: 100%;
}
ha-combo-box-textfield > ha-icon-button {
ha-textfield > ha-icon-button {
--mdc-icon-button-size: 24px;
padding: 2px;
color: var(--secondary-text-color);

View File

@@ -54,7 +54,7 @@ export class HaDialogHeader extends LitElement {
}
.header-title {
font-size: var(--ha-font-size-xl);
line-height: var(--ha-line-height-condensed);
line-height: 28px;
font-weight: var(--ha-font-weight-normal);
}
.header-subtitle {

View File

@@ -90,7 +90,7 @@ export class HaDialog extends DialogBase {
}
.mdc-dialog__actions {
justify-content: var(--justify-action-buttons, flex-end);
padding: 12px 24px max(var(--safe-area-inset-bottom), 12px) 24px;
padding: 12px 24px max(env(safe-area-inset-bottom), 12px) 24px;
}
.mdc-dialog__actions span:nth-child(1) {
flex: var(--secondary-action-button-flex, unset);
@@ -117,7 +117,7 @@ export class HaDialog extends DialogBase {
:host([hideactions]) .mdc-dialog .mdc-dialog__content {
padding-bottom: max(
var(--dialog-content-padding, 24px),
var(--safe-area-inset-bottom)
env(safe-area-inset-bottom)
);
}
.mdc-dialog .mdc-dialog__surface {

View File

@@ -52,11 +52,11 @@ class HaDurationInput extends LitElement {
.milliseconds=${this._milliseconds}
@value-changed=${this._durationChanged}
no-hours-limit
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
dayLabel="dd"
hourLabel="hh"
minLabel="mm"
secLabel="ss"
millisecLabel="ms"
></ha-base-time-input>
`;
}

View File

@@ -202,7 +202,6 @@ export class HaExpansionPanel extends LitElement {
.header,
::slotted([slot="header"]) {
flex: 1;
overflow-wrap: anywhere;
}
.container {

View File

@@ -211,7 +211,7 @@ export class HaFilterBlueprints extends LitElement {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -306,7 +306,7 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -235,7 +235,7 @@ export class HaFilterDevices extends LitElement {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -192,7 +192,7 @@ export class HaFilterDomains extends LitElement {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -249,7 +249,7 @@ export class HaFilterEntities extends LitElement {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -306,7 +306,7 @@ export class HaFilterFloorAreas extends LitElement {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -198,7 +198,7 @@ export class HaFilterIntegrations extends LitElement {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -236,7 +236,7 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -180,7 +180,7 @@ export class HaFilterStates extends LitElement {
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);
line-height: 16px;
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);

View File

@@ -1,13 +1,14 @@
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import type { AreaRegistryEntry } from "../data/area_registry";
import { updateAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
@@ -15,29 +16,33 @@ import type {
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import type { FloorRegistryEntry } from "../data/floor_registry";
import {
createFloorRegistryEntry,
getFloorAreaLookup,
type FloorRegistryEntry,
} from "../data/floor_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showFloorRegistryDetailDialog } from "../panels/config/areas/show-dialog-floor-registry-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
type ScorableFloorRegistryEntry = ScorableTextItem & FloorRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___";
const NO_FLOORS_ID = "___NO_FLOORS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
interface FloorComboBoxItem extends PickerComboBoxItem {
floor?: FloorRegistryEntry;
}
const rowRenderer: ComboBoxLitRenderer<FloorRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
<ha-floor-icon slot="start" .floor=${item}></ha-floor-icon>
${item.name}
</ha-combo-box-item>
`;
@customElement("ha-floor-picker")
export class HaFloorPicker extends LitElement {
@@ -83,7 +88,7 @@ export class HaFloorPicker extends LitElement {
* @type {Array}
* @attr exclude-floors
*/
@property({ type: Array, attribute: "exclude-floors" })
@property({ type: Array, attribute: "exclude-floor" })
public excludeFloors?: string[];
@property({ attribute: false })
@@ -96,53 +101,38 @@ export class HaFloorPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this._picker?.open();
await this.comboBox?.open();
}
// Recompute value renderer when the areas change
private _computeValueRenderer = memoizeOne(
(_haAreas: HomeAssistant["floors"]): PickerValueRenderer =>
(value) => {
const floor = this.hass.floors[value];
if (!floor) {
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${floor}</span>
`;
}
const floorName = floor ? computeFloorName(floor) : undefined;
return html`
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
<span slot="headline">${floorName}</span>
`;
}
);
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getFloors = memoizeOne(
(
haFloors: HomeAssistant["floors"],
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeFloors: this["excludeFloors"]
): FloorComboBoxItem[] => {
const floors = Object.values(haFloors);
const areas = Object.values(haAreas);
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
): FloorRegistryEntry[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
@@ -279,169 +269,216 @@ export class HaFloorPicker extends LitElement {
);
}
const items = outputFloors.map<FloorComboBoxItem>((floor) => {
const floorName = computeFloorName(floor);
return {
id: floor.floor_id,
primary: floorName,
floor: floor,
sorting_label: floor.level?.toString() || "zzzzz",
search_labels: [floorName, floor.floor_id, ...floor.aliases].filter(
(v): v is string => Boolean(v)
),
};
});
if (!outputFloors.length) {
outputFloors = [
{
floor_id: NO_FLOORS_ID,
name: this.hass.localize("ui.components.floor-picker.no_floors"),
icon: null,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
];
}
return items;
}
);
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (item) => html`
<ha-combo-box-item type="button" compact>
${item.icon_path
? html`
<ha-svg-icon
slot="start"
style="margin: 0 4px"
.path=${item.icon_path}
></ha-svg-icon>
`
: html`
<ha-floor-icon
slot="start"
.floor=${item.floor}
style="margin: 0 4px"
></ha-floor-icon>
`}
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
private _getItems = () =>
this._getFloors(
this.hass.floors,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeFloors
);
private _allFloorNames = memoizeOne(
(floors: HomeAssistant["floors"]) =>
Object.values(floors)
.map((floor) => computeFloorName(floor)?.toLowerCase())
.filter(Boolean) as string[]
);
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
}
const allFloors = this._allFloorNames(this.hass.floors);
if (searchString && !allFloors.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.floor-picker.add_new_sugestion",
return noAdd
? outputFloors
: [
...outputFloors,
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
floor_id: ADD_NEW_ID,
name: this.hass.localize("ui.components.floor-picker.add_new"),
icon: "mdi:plus",
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
];
}
);
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.floor-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const floors = this._getFloors(
Object.values(this.hass.floors),
Object.values(this.hass.areas),
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeFloors
).map((floor) => ({
...floor,
strings: [floor.floor_id, floor.name, ...floor.aliases],
}));
this.comboBox.items = floors;
this.comboBox.filteredItems = floors;
}
}
protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.floor-picker.floor");
const valueRenderer = this._computeValueRenderer(this.hass.floors);
return html`
<ha-generic-picker
<ha-combo-box
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.floor-picker.no_match"
)}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
.helper=${this.helper}
item-value-path="floor_id"
item-id-path="floor_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.floor-picker.floor")
: this.label}
.placeholder=${this.placeholder
? this.hass.floors[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._floorChanged}
>
</ha-generic-picker>
</ha-combo-box>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
const filteredItems = fuzzyFilterSort<ScorableFloorRegistryEntry>(
filterString,
target.items?.filter(
(item) => ![NO_FLOORS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
floor_id: NO_FLOORS_ID,
name: this.hass.localize("ui.components.floor-picker.no_match"),
icon: null,
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
] as FloorRegistryEntry[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
floor_id: ADD_NEW_SUGGESTION_ID,
name: this.hass.localize(
"ui.components.floor-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
level: null,
aliases: [],
created_at: 0,
modified_at: 0,
},
] as FloorRegistryEntry[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
const suggestedName = value.substring(ADD_NEW_ID.length);
private get _value() {
return this.value || "";
}
showFloorRegistryDetailDialog(this, {
suggestedName: suggestedName,
createEntry: async (values, addedAreas) => {
try {
const floor = await createFloorRegistryEntry(this.hass, values);
addedAreas.forEach((areaId) => {
updateAreaRegistryEntry(this.hass, areaId, {
floor_id: floor.floor_id,
});
});
this._setValue(floor.floor_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.floor-picker.failed_create_floor"
),
text: err.message,
});
}
},
});
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _floorChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_FLOORS_ID) {
newValue = "";
this.comboBox.setInputValue("");
return;
}
this._setValue(value);
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(ev.target as any).value = this._value;
this.hass.loadFragmentTranslation("config");
showFloorRegistryDetailDialog(this, {
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values, addedAreas) => {
try {
const floor = await createFloorRegistryEntry(this.hass, values);
addedAreas.forEach((areaId) => {
updateAreaRegistryEntry(this.hass, areaId, {
floor_id: floor.floor_id,
});
});
const floors = [...Object.values(this.hass.floors), floor];
this.comboBox.filteredItems = this._getFloors(
floors,
Object.values(this.hass.areas)!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeFloors
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(floor.floor_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.floor-picker.failed_create_floor"
),
text: err.message,
});
}
},
});
this._suggestion = undefined;
this.comboBox.setInputValue("");
}
private _setValue(value?: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}

View File

@@ -34,8 +34,6 @@ const getWarning = (obj, item) => (obj && item.name ? obj[item.name] : null);
export class HaForm extends LitElement implements HaFormElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public data!: HaFormDataContainer;
@property({ attribute: false }) public schema!: readonly HaFormSchema[];
@@ -137,7 +135,6 @@ export class HaForm extends LitElement implements HaFormElement {
? html`<ha-selector
.schema=${item}
.hass=${this.hass}
.narrow=${this.narrow}
.name=${item.name}
.selector=${item.selector}
.value=${getValue(this.data, item)}

View File

@@ -1,192 +0,0 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-picker-combo-box";
import type {
HaPickerComboBox,
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "./ha-picker-combo-box";
import "./ha-picker-field";
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
import "./ha-svg-icon";
@customElement("ha-generic-picker")
export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@query("ha-picker-field") private _field?: HaPickerField;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@state() private _opened = false;
protected render() {
return html`
${this.label
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
: nothing}
<div class="container">
${!this._opened
? html`
<ha-picker-field
type="button"
compact
aria-label=${ifDefined(this.label)}
@click=${this.open}
@clear=${this._clear}
.placeholder=${this.placeholder}
.value=${this.value}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${this.valueRenderer}
>
</ha-picker-field>
`
: html`
<ha-picker-combo-box
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ??
this.hass.localize("ui.common.search")}
.value=${this.value}
hide-clear-icon
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer}
.notFoundLabel=${this.notFoundLabel}
.getItems=${this.getItems}
.getAdditionalItems=${this.getAdditionalItems}
.searchFn=${this.searchFn}
></ha-picker-combo-box>
`}
</div>
${this._renderHelper()}
`;
}
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
: nothing;
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
return;
}
fireEvent(this, "value-changed", { value });
}
private _clear(e) {
e.stopPropagation();
this._setValue(undefined);
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
}
public async open() {
if (this.disabled) {
return;
}
this._opened = true;
await this.updateComplete;
this._comboBox?.focus();
this._comboBox?.open();
}
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
const opened = ev.detail.value;
if (this._opened && !opened) {
this._opened = false;
await this.updateComplete;
this._field?.focus();
}
}
static get styles(): CSSResultGroup {
return [
css`
.container {
position: relative;
display: block;
}
label[disabled] {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
label {
display: block;
margin: 0 0 8px;
}
ha-input-helper-text {
display: block;
margin: 8px 0 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-generic-picker": HaGenericPicker;
}
}

View File

@@ -2,9 +2,34 @@ import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { loadIcon } from "../data/load_icon";
import { debounce } from "../common/util/debounce";
import type { CustomIcon } from "../data/custom_icons";
import { customIcons } from "../data/custom_icons";
import type { Chunks, Icons } from "../data/iconsets";
import {
MDI_PREFIXES,
findIconChunk,
getIcon,
writeCache,
} from "../data/iconsets";
import "./ha-svg-icon";
type DeprecatedIcon = Record<
string,
{
removeIn: string;
newName?: string;
}
>;
const mdiDeprecatedIcons: DeprecatedIcon = {};
const chunks: Chunks = {};
const debouncedWriteCache = debounce(() => writeCache(chunks), 2000);
const cachedIcons: Record<string, string> = {};
@customElement("ha-icon")
export class HaIcon extends LitElement {
@property() public icon?: string;
@@ -46,24 +71,118 @@ export class HaIcon extends LitElement {
if (!this.icon) {
return;
}
const result = await loadIcon(this.icon, this._handleWarning);
const requestedIcon = this.icon;
const [iconPrefix, origIconName] = this.icon.split(":", 2);
if (result.icon !== this.icon) {
// The icon was changed while we were loading it, so we don't update the state
let iconName = origIconName;
if (!iconPrefix || !iconName) {
return;
}
this._legacy = result.legacy || false;
this._path = result.path;
this._secondaryPath = result.secondaryPath;
this._viewBox = result.viewBox;
if (!MDI_PREFIXES.includes(iconPrefix)) {
const customIcon = customIcons[iconPrefix];
if (customIcon) {
if (customIcon && typeof customIcon.getIcon === "function") {
this._setCustomPath(customIcon.getIcon(iconName), requestedIcon);
}
return;
}
this._legacy = true;
return;
}
this._legacy = false;
if (iconName in mdiDeprecatedIcons) {
const deprecatedIcon = mdiDeprecatedIcons[iconName];
let message: string;
if (deprecatedIcon.newName) {
message = `Icon ${iconPrefix}:${iconName} was renamed to ${iconPrefix}:${deprecatedIcon.newName}, please change your config, it will be removed in version ${deprecatedIcon.removeIn}.`;
iconName = deprecatedIcon.newName!;
} else {
message = `Icon ${iconPrefix}:${iconName} was removed from MDI, please replace this icon with an other icon in your config, it will be removed in version ${deprecatedIcon.removeIn}.`;
}
// eslint-disable-next-line no-console
console.warn(message);
fireEvent(this, "write_log", {
level: "warning",
message,
});
}
if (iconName in cachedIcons) {
this._path = cachedIcons[iconName];
return;
}
if (iconName === "home-assistant") {
const icon = (await import("../resources/home-assistant-logo-svg"))
.mdiHomeAssistant;
if (this.icon === requestedIcon) {
this._path = icon;
}
cachedIcons[iconName] = icon;
return;
}
let databaseIcon: string | undefined;
try {
databaseIcon = await getIcon(iconName);
} catch (_err) {
// Firefox in private mode doesn't support IDB
// iOS Safari sometimes doesn't open the DB
databaseIcon = undefined;
}
if (databaseIcon) {
if (this.icon === requestedIcon) {
this._path = databaseIcon;
}
cachedIcons[iconName] = databaseIcon;
return;
}
const chunk = findIconChunk(iconName);
if (chunk in chunks) {
this._setPath(chunks[chunk], iconName, requestedIcon);
return;
}
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
response.json()
);
chunks[chunk] = iconPromise;
this._setPath(iconPromise, iconName, requestedIcon);
debouncedWriteCache();
}
private _handleWarning = (message: string) => {
fireEvent(this, "write_log", {
level: "warning",
message,
});
};
private async _setCustomPath(
promise: Promise<CustomIcon>,
requestedIcon: string
) {
const icon = await promise;
if (this.icon !== requestedIcon) {
return;
}
this._path = icon.path;
this._secondaryPath = icon.secondaryPath;
this._viewBox = icon.viewBox;
}
private async _setPath(
promise: Promise<Icons>,
iconName: string,
requestedIcon: string
) {
const iconPack = await promise;
if (this.icon === requestedIcon) {
this._path = iconPack[iconName];
}
cachedIcons[iconName] = iconPack[iconName];
}
static styles = css`
:host {

View File

@@ -1,11 +1,9 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement } from "lit/decorators";
@customElement("ha-input-helper-text")
class InputHelperText extends LitElement {
@property({ type: Boolean, reflect: true }) disabled = false;
protected render(): TemplateResult {
return html`<slot></slot>`;
}
@@ -20,9 +18,6 @@ class InputHelperText extends LitElement {
padding-inline-start: 16px;
padding-inline-end: 16px;
}
:host([disabled]) {
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
}
`;
}

View File

@@ -2,7 +2,7 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
@@ -25,7 +25,6 @@ export interface DisplayItem {
value: string;
label: string;
description?: string;
disableSorting?: boolean;
}
export interface DisplayValue {
@@ -51,9 +50,6 @@ export class HaItemDisplayEditor extends LitElement {
@property({ type: Boolean, attribute: "show-navigation-button" })
public showNavigationButton = false;
@property({ type: Boolean, attribute: "dont-sort-visible" })
public dontSortVisible = false;
@property({ attribute: false })
public value: DisplayValue = {
order: [],
@@ -64,15 +60,86 @@ export class HaItemDisplayEditor extends LitElement {
item: DisplayItem
) => TemplateResult<1> | typeof nothing;
/**
* Used to sort items by keyboard navigation.
*/
@state() private _dragIndex: number | null = null;
private _showIcon = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 450,
});
private _toggle(ev) {
ev.stopPropagation();
const value = ev.currentTarget.value;
const hiddenItems = this._hiddenItems(this.items, this.value.hidden);
const newHidden = hiddenItems.map((item) => item.value);
if (newHidden.includes(value)) {
newHidden.splice(newHidden.indexOf(value), 1);
} else {
newHidden.push(value);
}
const newVisibleItems = this._visibleItems(
this.items,
newHidden,
this.value.order
);
const newOrder = newVisibleItems.map((a) => a.value);
this.value = {
hidden: newHidden,
order: newOrder,
};
fireEvent(this, "value-changed", { value: this.value });
}
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const visibleItems = this._visibleItems(
this.items,
this.value.hidden,
this.value.order
);
const newOrder = visibleItems.map((item) => item.value);
const movedItem = newOrder.splice(oldIndex, 1)[0];
newOrder.splice(newIndex, 0, movedItem);
this.value = {
...this.value,
order: newOrder,
};
fireEvent(this, "value-changed", { value: this.value });
}
private _navigate(ev) {
const value = ev.currentTarget.value;
fireEvent(this, "item-display-navigate-clicked", { value });
ev.stopPropagation();
}
private _visibleItems = memoizeOne(
(items: DisplayItem[], hidden: string[], order: string[]) => {
const compare = orderCompare(order);
return items
.filter((item) => !hidden.includes(item.value))
.sort((a, b) => compare(a.value, b.value));
}
);
private _allItems = memoizeOne(
(items: DisplayItem[], hidden: string[], order: string[]) => {
const visibleItems = this._visibleItems(items, hidden, order);
const hiddenItems = this._hiddenItems(items, hidden);
return [...visibleItems, ...hiddenItems];
}
);
private _hiddenItems = memoizeOne((items: DisplayItem[], hidden: string[]) =>
items.filter((item) => hidden.includes(item.value))
);
protected render() {
const allItems = this._allItems(
this.items,
@@ -91,47 +158,30 @@ export class HaItemDisplayEditor extends LitElement {
${repeat(
allItems,
(item) => item.value,
(item: DisplayItem, idx) => {
(item: DisplayItem, _idx) => {
const isVisible = !this.value.hidden.includes(item.value);
const {
label,
value,
description,
icon,
iconPath,
disableSorting,
} = item;
const { label, value, description, icon, iconPath } = item;
return html`
<ha-md-list-item
type="button"
type=${ifDefined(
this.showNavigationButton ? "button" : undefined
)}
@click=${this.showNavigationButton
? this._navigate
: undefined}
.value=${value}
class=${classMap({
hidden: !isVisible,
draggable: isVisible && !disableSorting,
"drag-selected": this._dragIndex === idx,
draggable: isVisible,
})}
@keydown=${isVisible && !disableSorting
? this._listElementKeydown
: undefined}
.idx=${idx}
>
<span slot="headline">${label}</span>
${description
? html`<span slot="supporting-text">${description}</span>`
: nothing}
${isVisible && !disableSorting
${isVisible
? html`
<ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle"
.path=${mdiDrag}
slot="start"
@@ -186,180 +236,6 @@ export class HaItemDisplayEditor extends LitElement {
`;
}
private _toggle(ev) {
ev.stopPropagation();
this._dragIndex = null;
const value = ev.currentTarget.value;
const hiddenItems = this._hiddenItems(this.items, this.value.hidden);
const newHidden = hiddenItems.map((item) => item.value);
if (newHidden.includes(value)) {
newHidden.splice(newHidden.indexOf(value), 1);
} else {
newHidden.push(value);
}
const newVisibleItems = this._visibleItems(
this.items,
newHidden,
this.value.order
);
const newOrder = newVisibleItems.map((a) => a.value);
this.value = {
hidden: newHidden,
order: newOrder,
};
fireEvent(this, "value-changed", { value: this.value });
}
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
this._moveItem(oldIndex, newIndex);
}
private _moveItem(oldIndex, newIndex) {
if (oldIndex === newIndex) {
return;
}
const visibleItems = this._visibleItems(
this.items,
this.value.hidden,
this.value.order
);
const newOrder = visibleItems.map((item) => item.value);
const movedItem = newOrder.splice(oldIndex, 1)[0];
newOrder.splice(newIndex, 0, movedItem);
this.value = {
...this.value,
order: newOrder,
};
fireEvent(this, "value-changed", { value: this.value });
}
private _navigate(ev) {
const value = ev.currentTarget.value;
fireEvent(this, "item-display-navigate-clicked", { value });
ev.stopPropagation();
}
private _visibleItems = memoizeOne(
(items: DisplayItem[], hidden: string[], order: string[]) => {
const compare = orderCompare(order);
const visibleItems = items.filter((item) => !hidden.includes(item.value));
if (this.dontSortVisible) {
return [
...visibleItems.filter((item) => !item.disableSorting),
...visibleItems.filter((item) => item.disableSorting),
];
}
return visibleItems.sort((a, b) =>
a.disableSorting && !b.disableSorting ? -1 : compare(a.value, b.value)
);
}
);
private _allItems = memoizeOne(
(items: DisplayItem[], hidden: string[], order: string[]) => {
const visibleItems = this._visibleItems(items, hidden, order);
const hiddenItems = this._hiddenItems(items, hidden);
return [...visibleItems, ...hiddenItems];
}
);
private _hiddenItems = memoizeOne((items: DisplayItem[], hidden: string[]) =>
items.filter((item) => hidden.includes(item.value))
);
private _maxSortableIndex = memoizeOne(
(items: DisplayItem[], hidden: string[]) =>
items.filter(
(item) => !item.disableSorting && !hidden.includes(item.value)
).length - 1
);
private _keyActivatedMove = (ev: KeyboardEvent, clearDragIndex = false) => {
const oldIndex = this._dragIndex;
if (ev.key === "ArrowUp") {
this._dragIndex = Math.max(0, this._dragIndex! - 1);
} else {
this._dragIndex = Math.min(
this._maxSortableIndex(this.items, this.value.hidden),
this._dragIndex! + 1
);
}
this._moveItem(oldIndex, this._dragIndex);
// refocus the item after the sort
setTimeout(async () => {
await this.updateComplete;
const selectedElement = this.shadowRoot?.querySelector(
`ha-md-list-item:nth-child(${this._dragIndex! + 1})`
) as HTMLElement | null;
selectedElement?.focus();
if (clearDragIndex) {
this._dragIndex = null;
}
});
};
private _sortKeydown = (ev: KeyboardEvent) => {
if (
this._dragIndex !== null &&
(ev.key === "ArrowUp" || ev.key === "ArrowDown")
) {
ev.preventDefault();
this._keyActivatedMove(ev);
} else if (this._dragIndex !== null && ev.key === "Escape") {
ev.preventDefault();
ev.stopPropagation();
this._dragIndex = null;
this.removeEventListener("keydown", this._sortKeydown);
}
};
private _listElementKeydown = (ev: KeyboardEvent) => {
if (ev.altKey && (ev.key === "ArrowUp" || ev.key === "ArrowDown")) {
ev.preventDefault();
this._dragIndex = (ev.target as any).idx;
this._keyActivatedMove(ev, true);
} else if (
(!this.showNavigationButton && ev.key === "Enter") ||
ev.key === " "
) {
this._dragHandleKeydown(ev);
}
};
private _dragHandleKeydown(ev: KeyboardEvent): void {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
ev.stopPropagation();
if (this._dragIndex === null) {
this._dragIndex = (ev.target as any).idx;
this.addEventListener("keydown", this._sortKeydown);
} else {
this.removeEventListener("keydown", this._sortKeydown);
this._dragIndex = null;
}
}
}
disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("keydown", this._sortKeydown);
}
static styles = css`
:host {
display: block;
@@ -380,12 +256,6 @@ export class HaItemDisplayEditor extends LitElement {
--md-list-item-two-line-container-height: 48px;
--md-list-item-one-line-container-height: 48px;
}
ha-md-list-item.drag-selected {
box-shadow:
0px 0px 8px 4px rgba(var(--rgb-accent-color), 0.8),
inset 0px 2px 8px 4px rgba(var(--rgb-accent-color), 0.4);
border-radius: 8px;
}
ha-md-list-item ha-icon-button {
margin-left: -12px;
margin-right: -12px;

View File

@@ -1,11 +1,13 @@
import { mdiLabel, mdiPlus } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
@@ -17,19 +19,30 @@ import {
createLabelRegistryEntry,
subscribeLabelRegistry,
} from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-svg-icon";
type ScorableLabelItem = ScorableTextItem & LabelRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___";
const NO_LABELS = "___NO_LABELS___";
const NO_LABELS_ID = "___NO_LABELS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
const rowRenderer: ComboBoxLitRenderer<LabelRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-combo-box-item>
`;
@customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) {
@@ -88,13 +101,24 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this._picker?.open();
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
@@ -105,64 +129,20 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
];
}
private _labelMap = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined
): Map<string, LabelRegistryEntry> => {
if (!labels) {
return new Map();
}
return new Map(labels.map((label) => [label.label_id, label]));
}
);
private _computeValueRenderer = memoizeOne(
(labels: LabelRegistryEntry[] | undefined): PickerValueRenderer =>
(value) => {
const label = this._labelMap(labels).get(value);
if (!label) {
return html`
<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
}
return html`
${label.icon
? html`<ha-icon slot="start" .icon=${label.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiLabel}></ha-svg-icon>`}
<span slot="headline">${label.name}</span>
`;
}
);
private _getLabels = memoizeOne(
(
labels: LabelRegistryEntry[] | undefined,
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
labels: LabelRegistryEntry[],
areas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"]
): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [
{
id: NO_LABELS,
primary: this.hass.localize("ui.components.label-picker.no_labels"),
icon_path: mdiLabel,
},
];
}
const devices = Object.values(haDevices);
const entities = Object.values(haEntities);
): LabelRegistryEntry[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
@@ -294,7 +274,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
if (areaIds) {
areaIds.forEach((areaId) => {
const area = haAreas[areaId];
const area = areas[areaId];
area.labels.forEach((label) => usedLabels.add(label));
});
}
@@ -311,146 +291,192 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
);
}
const items = outputLabels.map<PickerComboBoxItem>((label) => ({
id: label.label_id,
primary: label.name,
icon: label.icon || undefined,
icon_path: label.icon ? undefined : mdiLabel,
sorting_label: label.name,
search_labels: [label.name, label.label_id, label.description].filter(
(v): v is string => Boolean(v)
),
}));
if (!outputLabels.length) {
outputLabels = [
{
label_id: NO_LABELS_ID,
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
description: null,
created_at: 0,
modified_at: 0,
},
];
}
return items;
return noAdd
? outputLabels
: [
...outputLabels,
{
label_id: ADD_NEW_ID,
name: this.hass.localize("ui.components.label-picker.add_new"),
icon: "mdi:plus",
color: null,
description: null,
created_at: 0,
modified_at: 0,
},
];
}
);
private _getItems = () =>
this._getLabels(
this._labels,
this.hass.areas,
this.hass.devices,
this.hass.entities,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.excludeLabels
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass && this._labels) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const items = this._getLabels(
this._labels!,
this.hass.areas,
Object.values(this.hass.devices),
Object.values(this.hass.entities),
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
).map((label) => ({
...label,
strings: [label.label_id, label.name],
}));
private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => {
if (!labels) {
return [];
this.comboBox.items = items;
this.comboBox.filteredItems = items;
}
return [
...new Set(
labels
.map((label) => label.name.toLowerCase())
.filter(Boolean) as string[]
),
];
});
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (this.noAdd) {
return [];
}
const allLabelNames = this._allLabelNames(this._labels);
if (searchString && !allLabelNames.includes(searchString.toLowerCase())) {
return [
{
id: ADD_NEW_ID + searchString,
primary: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{
name: searchString,
}
),
icon_path: mdiPlus,
},
];
}
return [
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.label-picker.add_new"),
icon_path: mdiPlus,
},
];
};
}
protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
const valueRenderer = this._computeValueRenderer(this._labels);
return html`
<ha-generic-picker
<ha-combo-box
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.label-picker.no_match"
)}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.valueRenderer=${valueRenderer}
@value-changed=${this._valueChanged}
.helper=${this.helper}
item-value-path="label_id"
item-id-path="label_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.label")
: this.label}
.placeholder=${this.placeholder
? this._labels?.find((label) => label.label_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._labelChanged}
>
</ha-generic-picker>
</ha-combo-box>
`;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
private _filterChanged(ev: CustomEvent): void {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableLabelItem>(
filterString,
target.items?.filter(
(item) => ![NO_LABELS_ID, ADD_NEW_ID].includes(item.label_id)
) || []
);
if (filteredItems.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
label_id: NO_LABELS_ID,
name: this.hass.localize("ui.components.label-picker.no_match"),
icon: null,
color: null,
},
] as ScorableLabelItem[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
label_id: ADD_NEW_SUGGESTION_ID,
name: this.hass.localize(
"ui.components.label-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
color: null,
},
] as ScorableLabelItem[];
}
} else {
this.comboBox.filteredItems = filteredItems;
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _labelChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
const value = ev.detail.value;
if (value === NO_LABELS) {
if (newValue === NO_LABELS_ID) {
newValue = "";
this.comboBox.setInputValue("");
return;
}
if (!value) {
this._setValue(undefined);
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
if (value.startsWith(ADD_NEW_ID)) {
this.hass.loadFragmentTranslation("config");
(ev.target as any).value = this._value;
const suggestedName = value.substring(ADD_NEW_ID.length);
this.hass.loadFragmentTranslation("config");
showLabelDetailDialog(this, {
suggestedName: suggestedName,
createEntry: async (values) => {
try {
const label = await createLabelRegistryEntry(this.hass, values);
this._setValue(label.label_id);
} catch (err: any) {
showAlertDialog(this, {
title: this.hass.localize(
"ui.components.label-picker.failed_create_label"
),
text: err.message,
});
}
},
});
return;
}
showLabelDetailDialog(this, {
entry: undefined,
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values);
const labels = [...this._labels!, label];
this.comboBox.filteredItems = this._getLabels(
labels,
this.hass.areas!,
Object.values(this.hass.devices)!,
Object.values(this.hass.entities)!,
this.includeDomains,
this.excludeDomains,
this.includeDeviceClasses,
this.deviceFilter,
this.entityFilter,
this.noAdd,
this.excludeLabels
);
await this.updateComplete;
await this.comboBox.updateComplete;
this._setValue(label.label_id);
return label;
},
});
this._setValue(value);
this._suggestion = undefined;
this.comboBox.setInputValue("");
}
private _setValue(value?: string) {

View File

@@ -34,7 +34,7 @@ class HaLabel extends LitElement {
align-items: center;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed);
line-height: 16px;
letter-spacing: 0.1px;
vertical-align: middle;
height: 32px;

View File

@@ -122,7 +122,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
this.hass.locale.language
);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${labels?.length
? html`<ha-chip-set>
${repeat(
@@ -158,6 +157,9 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder}
.excludeLabels=${this.value}
@value-changed=${this._labelChanged}
@@ -180,7 +182,12 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
showLabelDetailDialog(this, {
entry: label,
updateEntry: async (values) => {
await updateLabelRegistryEntry(this.hass, label.label_id, values);
const updated = await updateLabelRegistryEntry(
this.hass,
label.label_id,
values
);
return updated;
},
});
}
@@ -212,10 +219,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
--ha-input-chip-selected-container-opacity: 0.5;
--md-input-chip-selected-outline-width: 1px;
}
label {
display: block;
margin: 0 0 8px;
}
`;
}

View File

@@ -26,9 +26,6 @@ class HaMarkdownElement extends ReactiveElement {
@property({ attribute: "allow-svg", type: Boolean }) public allowSvg = false;
@property({ attribute: "allow-data-url", type: Boolean })
public allowDataUrl = false;
@property({ type: Boolean }) public breaks = false;
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
@@ -69,7 +66,6 @@ class HaMarkdownElement extends ReactiveElement {
return hash({
content: this.content,
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks,
});
}
@@ -83,7 +79,6 @@ class HaMarkdownElement extends ReactiveElement {
},
{
allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
}
);

View File

@@ -8,9 +8,6 @@ export class HaMarkdown extends LitElement {
@property({ attribute: "allow-svg", type: Boolean }) public allowSvg = false;
@property({ attribute: "allow-data-url", type: Boolean })
public allowDataUrl = false;
@property({ type: Boolean }) public breaks = false;
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
@@ -26,7 +23,6 @@ export class HaMarkdown extends LitElement {
return html`<ha-markdown-element
.content=${this.content}
.allowSvg=${this.allowSvg}
.allowDataUrl=${this.allowDataUrl}
.breaks=${this.breaks}
.lazyImages=${this.lazyImages}
.cache=${this.cache}
@@ -77,7 +73,7 @@ export class HaMarkdown extends LitElement {
pre {
padding: 16px;
overflow: auto;
line-height: var(--ha-line-height-condensed);
line-height: 1.45;
font-family: var(--ha-font-family-code);
}
h1,

View File

@@ -155,10 +155,10 @@ export class HaMdDialog extends Dialog {
--md-dialog-supporting-text-color: var(--primary-text-color);
--md-sys-color-scrim: #000000;
--md-dialog-headline-weight: var(--ha-font-weight-normal);
--md-dialog-headline-size: var(--ha-font-size-xl);
--md-dialog-supporting-text-size: var(--ha-font-size-m);
--md-dialog-supporting-text-line-height: var(--ha-line-height-normal);
--md-dialog-headline-weight: 400;
--md-dialog-headline-size: 1.574rem;
--md-dialog-supporting-text-size: 1rem;
--md-dialog-supporting-text-line-height: 1.5rem;
}
:host([type="alert"]) {
@@ -168,10 +168,10 @@ export class HaMdDialog extends Dialog {
@media all and (max-width: 450px), all and (max-height: 500px) {
:host(:not([type="alert"])) {
min-width: calc(
100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
max-width: calc(
100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left)
100vw - env(safe-area-inset-right) - env(safe-area-inset-left)
);
min-height: 100%;
max-height: 100%;

View File

@@ -85,9 +85,7 @@ class HaMultiTextField extends LitElement {
</ha-button>
</div>
${this.helper
? html`<ha-input-helper-text .disabled=${this.disabled}
>${this.helper}</ha-input-helper-text
>`
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing}
`;
}

View File

@@ -1,280 +0,0 @@
import { mdiMagnify } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import Fuse from "fuse.js";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import { HaFuse } from "../resources/fuse";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-icon";
export interface PickerComboBoxItem {
id: string;
primary: string;
a11y_label?: string;
secondary?: string;
search_labels?: string[];
sorting_label?: string;
icon_path?: string;
icon?: string;
}
// Hack to force empty label to always display empty value by default in the search field
export interface PickerComboBoxItemWithLabel extends PickerComboBoxItem {
a11y_label: string;
}
const NO_MATCHING_ITEMS_FOUND_ID = "___no_matching_items_found___";
const DEFAULT_ROW_RENDERER: ComboBoxLitRenderer<PickerComboBoxItem> = (
item
) => html`
<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
search: string,
filteredItems: T[],
allItems: T[]
) => T[];
@customElement("ha-picker-combo-box")
export class HaPickerComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property({ type: Boolean, attribute: "allow-custom-value" })
public allowCustomValue;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property({ attribute: false, type: Array })
public getItems?: () => PickerComboBoxItem[];
@property({ attribute: false, type: Array })
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
@property({ attribute: false })
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string;
@property({ attribute: false })
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
@state() private _opened = false;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _initialItems = false;
private _items: PickerComboBoxItemWithLabel[] = [];
private _defaultNotFoundItem = memoizeOne(
(
label: this["notFoundLabel"],
localize: LocalizeFunc
): PickerComboBoxItemWithLabel => ({
id: NO_MATCHING_ITEMS_FOUND_ID,
primary: label || localize("ui.components.combo-box.no_match"),
icon_path: mdiMagnify,
a11y_label: label || localize("ui.components.combo-box.no_match"),
})
);
private _getAdditionalItems = (searchString?: string) => {
const items = this.getAdditionalItems?.(searchString) || [];
return items.map<PickerComboBoxItemWithLabel>((item) => ({
...item,
a11y_label: item.a11y_label || item.primary,
}));
};
private _getItems = (): PickerComboBoxItemWithLabel[] => {
const items = this.getItems ? this.getItems() : [];
const sortedItems = items
.map<PickerComboBoxItemWithLabel>((item) => ({
...item,
a11y_label: item.a11y_label || item.primary,
}))
.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
this.hass.locale.language
)
);
if (!sortedItems.length) {
sortedItems.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize)
);
}
const additionalItems = this._getAdditionalItems();
sortedItems.push(...additionalItems);
return sortedItems;
};
protected shouldUpdate(changedProps: PropertyValues) {
if (
changedProps.has("value") ||
changedProps.has("label") ||
changedProps.has("disabled")
) {
return true;
}
return !(!changedProps.has("_opened") && this._opened);
}
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("_opened") && this._opened) {
this._items = this._getItems();
if (this._initialItems) {
this.comboBox.filteredItems = this._items;
}
this._initialItems = true;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="a11y_label"
clear-initial-value
.hass=${this.hass}
.value=${this._value}
.label=${this.label}
.helper=${this.helper}
.allowCustomValue=${this.allowCustomValue}
.filteredItems=${this._items}
.renderer=${this.rowRenderer || DEFAULT_ROW_RENDERER}
.required=${this.required}
.disabled=${this.disabled}
.hideClearIcon=${this.hideClearIcon}
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
@filter-changed=${this._filterChanged}
>
</ha-combo-box>
`;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
ev.stopPropagation();
if (ev.detail.value !== this._opened) {
this._opened = ev.detail.value;
fireEvent(this, "opened-changed", { value: this._opened });
}
}
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
// Clear the input field to prevent showing the old value next time
this.comboBox.setTextFieldValue("");
const newValue = ev.detail.value?.trim();
if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
return;
}
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _fuseIndex = memoizeOne((states: PickerComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], states)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const searchString = ev.detail.value.trim() as string;
const index = this._fuseIndex(this._items);
const fuse = new HaFuse(this._items, { shouldSort: false }, index);
const results = fuse.multiTermsSearch(searchString);
let filteredItems = this._items as PickerComboBoxItem[];
if (results) {
const items = results.map((result) => result.item);
if (items.length === 0) {
items.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize)
);
}
const additionalItems = this._getAdditionalItems(searchString);
items.push(...additionalItems);
filteredItems = items;
}
if (this.searchFn) {
filteredItems = this.searchFn(searchString, filteredItems, this._items);
}
target.filteredItems = filteredItems;
}
private _setValue(value: string | undefined) {
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-picker-combo-box": HaPickerComboBox;
}
}

View File

@@ -1,168 +0,0 @@
import { mdiClose, mdiMenuDown } from "@mdi/js";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type TemplateResult,
} from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon-button";
declare global {
interface HASSDomEvents {
clear: undefined;
}
}
export type PickerValueRenderer = (value: string) => TemplateResult<1>;
@customElement("ha-picker-field")
export class HaPickerField extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: false })
public valueRenderer?: PickerValueRenderer;
@query("ha-combo-box-item", true) public item!: HaComboBoxItem;
public async focus() {
await this.updateComplete;
await this.item?.focus();
}
protected render() {
const showClearIcon =
!!this.value && !this.required && !this.disabled && !this.hideClearIcon;
return html`
<ha-combo-box-item .disabled=${this.disabled} type="button" compact>
${this.value
? this.valueRenderer
? this.valueRenderer(this.value)
: html`<slot name="headline">${this.value}</slot>`
: html`
<span slot="headline" class="placeholder">
${this.placeholder}
</span>
`}
${showClearIcon
? html`
<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>
`
: nothing}
<ha-svg-icon
class="arrow"
slot="end"
.path=${mdiMenuDown}
></ha-svg-icon>
</ha-combo-box-item>
`;
}
private _clear(e) {
e.stopPropagation();
fireEvent(this, "clear");
}
static get styles(): CSSResultGroup {
return [
css`
ha-combo-box-item[disabled] {
background-color: var(
--mdc-text-field-disabled-fill-color,
whitesmoke
);
}
ha-combo-box-item {
background-color: var(--mdc-text-field-fill-color, whitesmoke);
border-radius: 4px;
border-end-end-radius: 0;
border-end-start-radius: 0;
--md-list-item-one-line-container-height: 56px;
--md-list-item-two-line-container-height: 56px;
--md-list-item-top-space: 0px;
--md-list-item-bottom-space: 0px;
--md-list-item-leading-space: 8px;
--md-list-item-trailing-space: 8px;
--ha-md-list-item-gap: 8px;
/* Remove the default focus ring */
--md-focus-ring-width: 0px;
--md-focus-ring-duration: 0s;
}
/* Add Similar focus style as the text field */
ha-combo-box-item[disabled]:after {
background-color: var(
--mdc-text-field-disabled-line-color,
rgba(0, 0, 0, 0.42)
);
}
ha-combo-box-item:after {
display: block;
content: "";
position: absolute;
pointer-events: none;
bottom: 0;
left: 0;
right: 0;
height: 1px;
width: 100%;
background-color: var(
--mdc-text-field-idle-line-color,
rgba(0, 0, 0, 0.42)
);
transform:
height 180ms ease-in-out,
background-color 180ms ease-in-out;
}
ha-combo-box-item:focus:after {
height: 2px;
background-color: var(--mdc-theme-primary);
}
.clear {
margin: 0 -8px;
--mdc-icon-button-size: 32px;
--mdc-icon-size: 20px;
}
.arrow {
--mdc-icon-size: 20px;
width: 32px;
}
.placeholder {
color: var(--secondary-text-color);
padding: 0 8px;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-picker-field": HaPickerField;
}
}

View File

@@ -156,16 +156,16 @@ export class HaSelectBox extends LitElement {
color: var(--primary-text-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
line-height: 20px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.option .content .text .description {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-size: 13px;
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
line-height: 16px;
}
img {
position: relative;

View File

@@ -1,26 +1,16 @@
import { ContextProvider, consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { fullEntitiesContext } from "../../data/context";
import type { Action } from "../../data/script";
import { migrateAutomationAction } from "../../data/script";
import type { ActionSelector } from "../../data/selector";
import "../../panels/config/automation/action/ha-automation-action";
import type { HomeAssistant } from "../../types";
import {
subscribeEntityRegistry,
type EntityRegistryEntry,
} from "../../data/entity_registry";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
@customElement("ha-selector-action")
export class HaActionSelector extends SubscribeMixin(LitElement) {
export class HaActionSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public selector!: ActionSelector;
@property({ attribute: false }) public value?: Action;
@@ -29,14 +19,6 @@ export class HaActionSelector extends SubscribeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public disabled = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] | undefined;
@state() private _entitiesContext;
protected hassSubscribeRequiredHostProps = ["_entitiesContext"];
private _actions = memoizeOne((action: Action | undefined) => {
if (!action) {
return [];
@@ -44,23 +26,6 @@ export class HaActionSelector extends SubscribeMixin(LitElement) {
return migrateAutomationAction(action);
});
protected firstUpdated() {
if (!this._entityReg) {
this._entitiesContext = new ContextProvider(this, {
context: fullEntitiesContext,
initialValue: [],
});
}
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entitiesContext.setValue(entities);
}),
];
}
protected render() {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
@@ -68,7 +33,6 @@ export class HaActionSelector extends SubscribeMixin(LitElement) {
.disabled=${this.disabled}
.actions=${this._actions(this.value)}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-action>
`;
}

View File

@@ -9,8 +9,6 @@ import type { HomeAssistant } from "../../types";
export class HaConditionSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public selector!: ConditionSelector;
@property({ attribute: false }) public value?: Condition;
@@ -26,7 +24,6 @@ export class HaConditionSelector extends LitElement {
.disabled=${this.disabled}
.conditions=${this.value || []}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-condition>
`;
}

View File

@@ -11,8 +11,6 @@ import type { HomeAssistant } from "../../types";
export class HaTriggerSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public selector!: TriggerSelector;
@property({ attribute: false }) public value?: Trigger;
@@ -35,7 +33,6 @@ export class HaTriggerSelector extends LitElement {
.disabled=${this.disabled}
.triggers=${this._triggers(this.value)}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-trigger>
`;
}

View File

@@ -69,8 +69,6 @@ const LEGACY_UI_SELECTORS = new Set(["ui-action", "ui-color"]);
export class HaSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public narrow = false;
@property() public name?: string;
@property({ attribute: false }) public selector!: Selector;
@@ -129,7 +127,6 @@ export class HaSelector extends LitElement {
return html`
${dynamicElement(`ha-selector-${this._type}`, {
hass: this.hass,
narrow: this.narrow,
name: this.name,
selector: this._handleLegacySelector(this.selector),
value: this.value,

View File

@@ -85,11 +85,8 @@ export class HaServiceControl extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: "show-advanced", type: Boolean })
public showAdvanced = false;
@property({ attribute: "show-service-id", type: Boolean })
public showServiceId = false;
@property({ attribute: "show-advanced", type: Boolean }) public showAdvanced =
false;
@property({ attribute: "hide-picker", type: Boolean, reflect: true })
public hidePicker = false;
@@ -438,7 +435,6 @@ export class HaServiceControl extends LitElement {
.value=${this._value?.action}
.disabled=${this.disabled}
@value-changed=${this._serviceChanged}
.showServiceId=${this.showServiceId}
></ha-service-picker>`}
${this.hideDescription
? nothing

View File

@@ -1,25 +1,15 @@
import { mdiRoomService } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement, nothing, type TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { isValidServiceId } from "../common/entity/valid_service_id";
import type { LocalizeFunc } from "../common/translations/localize";
import { getServiceIcons } from "../data/icons";
import { domainToName } from "../data/integration";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import "./ha-combo-box";
import "./ha-combo-box-item";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { PickerValueRenderer } from "./ha-picker-field";
import "./ha-service-icon";
interface ServiceComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
service_id?: string;
}
import { getServiceIcons } from "../data/icons";
@customElement("ha-service-picker")
class HaServicePicker extends LitElement {
@@ -27,121 +17,66 @@ class HaServicePicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() public placeholder?: string;
@property() public value?: string;
@property({ attribute: "show-service-id", type: Boolean })
public showServiceId = false;
@state() private _filter?: string;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
public async open() {
await this.updateComplete;
await this._picker?.open();
}
protected firstUpdated(props) {
super.firstUpdated(props);
this.hass.loadBackendTranslation("services");
getServiceIcons(this.hass);
}
private _rowRenderer: ComboBoxLitRenderer<ServiceComboBoxItem> = (
item,
{ index }
) => html`
<ha-combo-box-item type="button" border-top .borderTop=${index !== 0}>
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${item.id}
></ha-service-icon>
<span slot="headline">${item.primary}</span>
<span slot="supporting-text">${item.secondary}</span>
${item.service_id && this.showServiceId
? html`<span slot="supporting-text" class="code">
${item.service_id}
</span>`
: nothing}
${item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing}
</ha-combo-box-item>
`;
private _valueRenderer: PickerValueRenderer = (value) => {
const serviceId = value;
const [domain, service] = serviceId.split(".");
if (!this.hass.services[domain]?.[service]) {
return html`
<ha-svg-icon slot="start" .path=${mdiRoomService}></ha-svg-icon>
<span slot="headline">${value}</span>
`;
protected willUpdate() {
if (!this.hasUpdated) {
this.hass.loadBackendTranslation("services");
getServiceIcons(this.hass);
}
const serviceName =
this.hass.localize(`component.${domain}.services.${service}.name`) ||
this.hass.services[domain][service].name ||
service;
return html`
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code">${serviceId}</span>`
: nothing}
`;
};
protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.service-picker.action");
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
allow-custom-value
.notFoundLabel=${this.hass.localize(
"ui.components.service-picker.no_match"
)}
.label=${this.label}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
}
private _getItems = () =>
this._services(this.hass.localize, this.hass.services);
private _rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> =
(item) => html`
<ha-combo-box-item type="button">
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${item.service}
></ha-service-icon>
<span slot="headline">${item.name}</span>
<span slot="supporting-text"
>${item.name === item.service ? "" : item.service}</span
>
</ha-combo-box-item>
`;
protected render() {
return html`
<ha-combo-box
.hass=${this.hass}
.label=${this.hass.localize("ui.components.service-picker.action")}
.filteredItems=${this._filteredServices(
this.hass.localize,
this.hass.services,
this._filter
)}
.value=${this.value}
.disabled=${this.disabled}
.renderer=${this._rowRenderer}
item-value-path="service"
item-label-path="name"
allow-custom-value
@filter-changed=${this._filterChanged}
@value-changed=${this._valueChanged}
></ha-combo-box>
`;
}
private _services = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): ServiceComboBoxItem[] => {
): {
service: string;
name: string;
}[] => {
if (!services) {
return [];
}
const items: ServiceComboBoxItem[] = [];
const result: { service: string; name: string }[] = [];
Object.keys(services)
.sort()
@@ -149,60 +84,56 @@ class HaServicePicker extends LitElement {
const services_keys = Object.keys(services[domain]).sort();
for (const service of services_keys) {
const serviceId = `${domain}.${service}`;
const domainName = domainToName(localize, domain);
const name =
this.hass.localize(
`component.${domain}.services.${service}.name`
) ||
services[domain][service].name ||
service;
const description =
this.hass.localize(
`component.${domain}.services.${service}.description`
) || services[domain][service].description;
items.push({
id: serviceId,
primary: name,
secondary: description,
domain_name: domainName,
service_id: serviceId,
search_labels: [serviceId, domainName, name, description].filter(
Boolean
),
sorting_label: serviceId,
result.push({
service: `${domain}.${service}`,
name: `${domainToName(localize, domain)}: ${
this.hass.localize(
`component.${domain}.services.${service}.name`
) ||
services[domain][service].name ||
service
}`,
});
}
});
return items;
return result;
}
);
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
private _filteredServices = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"],
filter?: string
) => {
if (!services) {
return [];
}
const processedServices = this._services(localize, services);
if (!value) {
this._setValue(undefined);
return;
if (!filter) {
return processedServices;
}
const split_filter = filter.split(" ");
return processedServices.filter((service) => {
const lower_service_name = service.name.toLowerCase();
const lower_service = service.service.toLowerCase();
return split_filter.every(
(f) => lower_service_name.includes(f) || lower_service.includes(f)
);
});
}
);
if (!isValidServiceId(value)) {
return;
}
this._setValue(value);
private _filterChanged(ev: CustomEvent): void {
this._filter = ev.detail.value.toLowerCase();
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
private _valueChanged(ev) {
this.value = ev.detail.value;
fireEvent(this, "change");
fireEvent(this, "value-changed", { value: this.value });
}
}

View File

@@ -1,9 +1,11 @@
import "@material/mwc-button/mwc-button";
import {
mdiBell,
mdiCalendar,
mdiCellphoneCog,
mdiChartBox,
mdiClipboardList,
mdiClose,
mdiCog,
mdiFormatListBulletedType,
mdiHammer,
@@ -11,10 +13,12 @@ import {
mdiMenu,
mdiMenuOpen,
mdiPlayBoxMultiple,
mdiPlus,
mdiTooltipAccount,
mdiViewDashboard,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResult, CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import {
customElement,
@@ -25,29 +29,28 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { storage } from "../common/decorators/storage";
import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare";
import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs";
import type { UpdateEntity } from "../data/update";
import { updateCanInstall } from "../data/update";
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types";
import "./ha-fade-in";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-md-list";
import "./ha-md-list-item";
import type { HaMdListItem } from "./ha-md-list-item";
import "./ha-spinner";
import "./ha-menu-button";
import "./ha-sortable";
import "./ha-svg-icon";
import "./user/ha-user-badge";
@@ -64,7 +67,7 @@ const SORT_VALUE_URL_PATHS = {
config: 11,
};
export const PANEL_ICONS = {
const PANEL_ICONS = {
calendar: mdiCalendar,
"developer-tools": mdiHammer,
energy: mdiLightningBolt,
@@ -137,7 +140,7 @@ const defaultPanelSorter = (
return stringCompare(a.title!, b.title!, language);
};
export const computePanels = memoizeOne(
const computePanels = memoizeOne(
(
panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["defaultPanel"],
@@ -189,57 +192,53 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@property({ attribute: "always-expand", type: Boolean })
public alwaysExpand = false;
@property({ attribute: "edit-mode", type: Boolean })
public editMode = false;
@state() private _notifications?: PersistentNotification[];
@state() private _updatesCount = 0;
@state() private _issuesCount = 0;
@state() private _panelOrder?: string[];
@state() private _hiddenPanels?: string[];
private _mouseLeaveTimeout?: number;
private _tooltipHideTimeout?: number;
private _recentKeydownActiveUntil = 0;
private _editStyleLoaded = false;
private _unsubPersistentNotifications: UnsubscribeFunc | undefined;
@state()
@storage({
key: "sidebarPanelOrder",
state: true,
subscribe: true,
})
private _panelOrder: string[] = [];
@state()
@storage({
key: "sidebarHiddenPanels",
state: true,
subscribe: true,
})
private _hiddenPanels: string[] = [];
@query(".tooltip") private _tooltip!: HTMLDivElement;
public hassSubscribe() {
return [
subscribeFrontendUserData(
this.hass.connection,
"sidebar",
({ value }) => {
this._panelOrder = value?.panelOrder;
this._hiddenPanels = value?.hiddenPanels;
// fallback to old localStorage values
if (!this._panelOrder) {
const storedOrder = localStorage.getItem("sidebarPanelOrder");
this._panelOrder = storedOrder ? JSON.parse(storedOrder) : [];
}
if (!this._hiddenPanels) {
const storedHidden = localStorage.getItem("sidebarHiddenPanels");
this._hiddenPanels = storedHidden ? JSON.parse(storedHidden) : [];
}
}
),
subscribeNotifications(this.hass.connection, (notifications) => {
this._notifications = notifications;
}),
...(this.hass.user?.is_admin
? [
subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => {
this._issuesCount = repairs.issues.filter(
(issue) => !issue.ignored
).length;
}),
]
: []),
];
public hassSubscribe(): UnsubscribeFunc[] {
return this.hass.user?.is_admin
? [
subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => {
this._issuesCount = repairs.issues.filter(
(issue) => !issue.ignored
).length;
}),
]
: [];
}
protected render() {
@@ -271,6 +270,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
changedProps.has("expanded") ||
changedProps.has("narrow") ||
changedProps.has("alwaysExpand") ||
changedProps.has("editMode") ||
changedProps.has("_externalConfig") ||
changedProps.has("_updatesCount") ||
changedProps.has("_issuesCount") ||
@@ -300,17 +300,45 @@ class HaSidebar extends SubscribeMixin(LitElement) {
);
}
protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps);
this._subscribePersistentNotifications();
}
private _subscribePersistentNotifications(): void {
if (this._unsubPersistentNotifications) {
this._unsubPersistentNotifications();
}
this._unsubPersistentNotifications = subscribeNotifications(
this.hass.connection,
(notifications) => {
this._notifications = notifications;
}
);
}
protected updated(changedProps) {
super.updated(changedProps);
if (changedProps.has("alwaysExpand")) {
toggleAttribute(this, "expanded", this.alwaysExpand);
}
if (changedProps.has("editMode") && this.editMode) {
this._editModeActivated();
}
if (!changedProps.has("hass")) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
this.hass &&
oldHass?.connected === false &&
this.hass.connected === true
) {
this._subscribePersistentNotifications();
}
this._calculateCounts();
if (!SUPPORT_SCROLL_IF_NEEDED) {
@@ -346,7 +374,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
class="menu"
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: true,
hasHold: !this.editMode,
disabled: this.editMode,
})}
>
${!this.narrow
@@ -360,19 +389,15 @@ class HaSidebar extends SubscribeMixin(LitElement) {
></ha-icon-button>
`
: ""}
<div class="title">Home Assistant</div>
${this.editMode
? html`<mwc-button outlined @click=${this._closeEditMode}>
${this.hass.localize("ui.sidebar.done")}
</mwc-button>`
: html`<div class="title">Home Assistant</div>`}
</div>`;
}
private _renderAllPanels(selectedPanel: string) {
if (!this._panelOrder || !this._hiddenPanels) {
return html`
<ha-fade-in .delay=${500}
><ha-spinner size="small"></ha-spinner
></ha-fade-in>
`;
}
const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
@@ -383,6 +408,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
// prettier-ignore
return html`
<ha-sortable .disabled=${!this.editMode} draggable-selector=".draggable" @item-moved=${this._panelMoved}>
<ha-md-list
class="ha-scrollbar"
@focusin=${this._listboxFocusIn}
@@ -390,15 +416,22 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown}
>
${this._renderPanels(beforeSpacer, selectedPanel)}
${this.editMode
? this._renderPanelsEdit(beforeSpacer, selectedPanel)
: this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()}
</ha-md-list>
</ha-sortable>
`;
}
private _renderPanels(panels: PanelInfo[], selectedPanel: string) {
private _renderPanels(
panels: PanelInfo[],
selectedPanel: string,
sortable = false
) {
return panels.map((panel) =>
this._renderPanel(
panel.url_path,
@@ -411,26 +444,36 @@ class HaSidebar extends SubscribeMixin(LitElement) {
: panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path]
: undefined,
selectedPanel
selectedPanel,
sortable
)
);
}
private _renderPanelsEdit(beforeSpacer: PanelInfo[], selectedPanel: string) {
return html`
${this._renderPanels(beforeSpacer, selectedPanel, true)}
${this._renderSpacer()}${this._renderHiddenPanels()}
`;
}
private _renderPanel(
urlPath: string,
title: string | null,
icon: string | null | undefined,
iconPath: string | null | undefined,
selectedPanel: string
selectedPanel: string,
sortable = false
) {
return urlPath === "config"
? this._renderConfiguration(title, selectedPanel)
: html`
<ha-md-list-item
.href=${`/${urlPath}`}
.href=${this.editMode ? undefined : `/${urlPath}`}
type="link"
class=${classMap({
selected: selectedPanel === urlPath,
draggable: this.editMode && sortable,
})}
@mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave}
@@ -439,10 +482,81 @@ class HaSidebar extends SubscribeMixin(LitElement) {
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span>
${this.editMode
? html`<ha-icon-button
.label=${this.hass.localize("ui.sidebar.hide_panel")}
.path=${mdiClose}
class="hide-panel"
.panel=${urlPath}
@click=${this._hidePanel}
slot="end"
></ha-icon-button>`
: nothing}
</ha-md-list-item>
`;
}
private _panelMoved(ev: CustomEvent) {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const [beforeSpacer] = computePanels(
this.hass.panels,
this.hass.defaultPanel,
this._panelOrder,
this._hiddenPanels,
this.hass.locale
);
const panelOrder = beforeSpacer.map((panel) => panel.url_path);
const panel = panelOrder.splice(oldIndex, 1)[0];
panelOrder.splice(newIndex, 0, panel);
this._panelOrder = panelOrder;
}
private _renderHiddenPanels() {
return html`${this._hiddenPanels.length
? html`${this._hiddenPanels.map((url) => {
const panel = this.hass.panels[url];
if (!panel) {
return "";
}
return html`<ha-md-list-item
@click=${this._unhidePanel}
class="hidden-panel"
.panel=${url}
type="button"
>
${panel.url_path === this.hass.defaultPanel && !panel.icon
? html`<ha-svg-icon
slot="start"
.path=${PANEL_ICONS.lovelace}
></ha-svg-icon>`
: panel.url_path in PANEL_ICONS
? html`<ha-svg-icon
slot="start"
.path=${PANEL_ICONS[panel.url_path]}
></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${panel.icon}></ha-icon>`}
<span class="item-text" slot="headline"
>${panel.url_path === this.hass.defaultPanel
? this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) ||
panel.title}</span
>
<ha-icon-button
.label=${this.hass.localize("ui.sidebar.show_panel")}
.path=${mdiPlus}
class="show-panel"
slot="end"
></ha-icon-button>
</ha-md-list-item>`;
})}
${this._renderSpacer()}`
: ""}`;
}
private _renderDivider() {
return html`<div class="divider"></div>`;
}
@@ -563,7 +677,47 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return;
}
showEditSidebarDialog(this);
fireEvent(this, "hass-edit-sidebar", { editMode: true });
}
private async _editModeActivated() {
await this._loadEditStyle();
}
private async _loadEditStyle() {
if (this._editStyleLoaded) return;
const editStylesImport = await import("../resources/ha-sidebar-edit-style");
const style = document.createElement("style");
style.innerHTML = (editStylesImport.sidebarEditStyle as CSSResult).cssText;
this.shadowRoot!.appendChild(style);
await this.updateComplete;
}
private _closeEditMode() {
fireEvent(this, "hass-edit-sidebar", { editMode: false });
}
private async _hidePanel(ev: Event) {
ev.preventDefault();
const panel = (ev.currentTarget as any).panel;
if (this._hiddenPanels.includes(panel)) {
return;
}
// Make a copy for Memoize
this._hiddenPanels = [...this._hiddenPanels, panel];
// Remove it from the panel order
this._panelOrder = this._panelOrder.filter((order) => order !== panel);
}
private async _unhidePanel(ev: Event) {
ev.preventDefault();
const panel = (ev.currentTarget as any).panel;
this._hiddenPanels = this._hiddenPanels.filter(
(hidden) => hidden !== panel
);
}
private _itemMouseEnter(ev: MouseEvent) {
@@ -626,15 +780,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this._tooltipHideTimeout = undefined;
}
const tooltip = this._tooltip;
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!;
const listbox = [...allListbox].find((lb) => lb.contains(item));
const top =
item.offsetTop +
11 +
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
const listbox = this.shadowRoot!.querySelector("ha-md-list")!;
let top = item.offsetTop + 11;
if (listbox.contains(item)) {
top += listbox.offsetTop;
top -= listbox.scrollTop;
}
tooltip.innerText = (
item.querySelector(".item-text") as HTMLElement
).innerText;
@@ -700,12 +851,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
);
font-size: var(--ha-font-size-xl);
align-items: center;
padding-left: calc(4px + var(--safe-area-inset-left));
padding-inline-start: calc(4px + var(--safe-area-inset-left));
padding-left: calc(4px + env(safe-area-inset-left));
padding-inline-start: calc(4px + env(safe-area-inset-left));
padding-inline-end: initial;
}
:host([expanded]) .menu {
width: calc(256px + var(--safe-area-inset-left));
width: calc(256px + env(safe-area-inset-left));
}
.menu ha-icon-button {
color: var(--sidebar-icon-color);
@@ -724,29 +875,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
:host([expanded]) .title {
display: initial;
}
:host([expanded]) .menu mwc-button {
margin: 0 8px;
}
.menu mwc-button {
width: 100%;
}
.hidden-panel {
display: none;
}
ha-fade-in,
ha-md-list {
height: calc(
100% - var(--header-height) - 132px - var(--safe-area-inset-bottom)
);
}
ha-fade-in {
display: flex;
justify-content: center;
align-items: center;
}
ha-md-list {
padding: 4px 0;
box-sizing: border-box;
height: calc(100% - var(--header-height) - 132px);
height: calc(
100% - var(--header-height) - 132px - env(safe-area-inset-bottom)
);
overflow-x: hidden;
background: none;
margin-left: var(--safe-area-inset-left);
margin-left: env(safe-area-inset-left);
}
ha-md-list-item {
@@ -766,7 +914,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
:host([expanded]) ha-md-list-item {
width: 248px;
width: calc(248px - var(--safe-area-inset-left));
width: calc(248px - env(safe-area-inset-left));
}
ha-md-list-item.selected {
@@ -801,6 +949,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
}
ha-md-list-item .item-text {
font-family: var(--ha-font-family-body);
display: none;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
@@ -838,7 +987,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
left: 26px;
border-radius: 10px;
font-size: 0.65em;
line-height: var(--ha-line-height-expanded);
line-height: 2;
padding: 0 4px;
}

View File

@@ -39,13 +39,12 @@ import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import "./device/ha-device-picker";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./entity/ha-entity-picker";
import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker";
import "./entity/ha-entity-combo-box";
import type { HaEntityComboBoxEntityFilterFunc } from "./entity/ha-entity-combo-box";
import "./ha-area-floor-picker";
import { floorDefaultIconPath } from "./ha-floor-icon";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-label-picker";
import "./ha-svg-icon";
import "./ha-tooltip";
@@ -81,7 +80,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc;
public entityFilter?: HaEntityComboBoxEntityFilterFunc;
@property({ type: Boolean, reflect: true }) public disabled = false;
@@ -385,12 +384,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
if (!this._addMode) {
return nothing;
}
return html`<mwc-menu-surface
open
.anchor=${this._addContainer}
@closed=${this._onClosed}
@opened=${this._onOpened}
@opened-changed=${this._openedChanged}
@input=${stopPropagation}
>${this._addMode === "area_id"
? html`
@@ -398,12 +397,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
id="input"
.type=${"area_id"}
.placeholder=${this.hass.localize(
"ui.components.target-picker.add_area_id"
)}
.searchLabel=${this.hass.localize(
.label=${this.hass.localize(
"ui.components.target-picker.add_area_id"
)}
no-add
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.includeDeviceClasses=${this.includeDeviceClasses}
@@ -411,7 +408,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.excludeAreas=${ensureArray(this.value?.area_id)}
.excludeFloors=${ensureArray(this.value?.floor_id)}
@value-changed=${this._targetPicked}
@opened-changed=${this._openedChanged}
@click=${this._preventDefault}
></ha-area-floor-picker>
`
@@ -421,10 +417,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
id="input"
.type=${"device_id"}
.placeholder=${this.hass.localize(
"ui.components.target-picker.add_device_id"
)}
.searchLabel=${this.hass.localize(
.label=${this.hass.localize(
"ui.components.target-picker.add_device_id"
)}
.deviceFilter=${this.deviceFilter}
@@ -433,7 +426,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.excludeDevices=${ensureArray(this.value?.device_id)}
@value-changed=${this._targetPicked}
@opened-changed=${this._openedChanged}
@click=${this._preventDefault}
></ha-device-picker>
`
@@ -443,10 +435,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
id="input"
.type=${"label_id"}
.placeholder=${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}
.searchLabel=${this.hass.localize(
.label=${this.hass.localize(
"ui.components.target-picker.add_label_id"
)}
no-add
@@ -456,19 +445,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.includeDomains=${this.includeDomains}
.excludeLabels=${ensureArray(this.value?.label_id)}
@value-changed=${this._targetPicked}
@opened-changed=${this._openedChanged}
@click=${this._preventDefault}
></ha-label-picker>
`
: html`
<ha-entity-picker
<ha-entity-combo-box
.hass=${this.hass}
id="input"
.type=${"entity_id"}
.placeholder=${this.hass.localize(
"ui.components.target-picker.add_entity_id"
)}
.searchLabel=${this.hass.localize(
.label=${this.hass.localize(
"ui.components.target-picker.add_entity_id"
)}
.entityFilter=${this.entityFilter}
@@ -477,12 +462,11 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.excludeEntities=${ensureArray(this.value?.entity_id)}
.createDomains=${this.createDomains}
@value-changed=${this._targetPicked}
@opened-changed=${this._openedChanged}
@click=${this._preventDefault}
allow-custom-entity
></ha-entity-picker>
></ha-entity-combo-box>
`}</mwc-menu-surface
> `;
>`;
}
private _targetPicked(ev) {
@@ -855,7 +839,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
ha-entity-picker,
ha-entity-combo-box,
ha-device-picker,
ha-area-floor-picker {
display: block;

View File

@@ -30,7 +30,7 @@ export class HaTextArea extends TextAreaBase {
content: attr(data-value);
margin-top: 23px;
margin-bottom: 9px;
line-height: var(--ha-line-height-normal);
line-height: 1.5rem;
min-height: 42px;
padding: 0px 32px 0 16px;
letter-spacing: var(

View File

@@ -28,30 +28,22 @@ export class HaTimeInput extends LitElement {
protected render() {
const useAMPM = useAmPm(this.locale);
let hours = NaN;
let minutes = NaN;
let seconds = NaN;
let numberHours = 0;
if (this.value) {
const parts = this.value?.split(":") || [];
minutes = parts[1] ? Number(parts[1]) : 0;
seconds = parts[2] ? Number(parts[2]) : 0;
hours = parts[0] ? Number(parts[0]) : 0;
numberHours = hours;
if (numberHours && useAMPM && numberHours > 12 && numberHours < 24) {
hours = numberHours - 12;
}
if (useAMPM && numberHours === 0) {
hours = 12;
}
const parts = this.value?.split(":") || [];
let hours = parts[0];
const numberHours = Number(parts[0]);
if (numberHours && useAMPM && numberHours > 12 && numberHours < 24) {
hours = String(numberHours - 12).padStart(2, "0");
}
if (useAMPM && numberHours === 0) {
hours = "12";
}
return html`
<ha-base-time-input
.label=${this.label}
.hours=${hours}
.minutes=${minutes}
.seconds=${seconds}
.hours=${Number(hours)}
.minutes=${Number(parts[1])}
.seconds=${Number(parts[2])}
.format=${useAMPM ? 12 : 24}
.amPm=${useAMPM && numberHours >= 12 ? "PM" : "AM"}
.disabled=${this.disabled}
@@ -60,11 +52,6 @@ export class HaTimeInput extends LitElement {
.required=${this.required}
.clearable=${this.clearable && this.value !== undefined}
.helper=${this.helper}
day-label="dd"
hour-label="hh"
min-label="mm"
sec-label="ss"
ms-label="ms"
></ha-base-time-input>
`;
}

View File

@@ -14,9 +14,9 @@ export class HaToast extends Snackbar {
.mdc-snackbar {
margin: 8px;
right: calc(8px + var(--safe-area-inset-right));
bottom: calc(8px + var(--safe-area-inset-bottom));
left: calc(8px + var(--safe-area-inset-left));
right: calc(8px + env(safe-area-inset-right));
bottom: calc(8px + env(safe-area-inset-bottom));
left: calc(8px + env(safe-area-inset-left));
}
.mdc-snackbar__surface {
@@ -37,9 +37,9 @@ export class HaToast extends Snackbar {
@media all and (max-width: 450px), all and (max-height: 500px) {
.mdc-snackbar {
right: var(--safe-area-inset-right);
bottom: var(--safe-area-inset-bottom);
left: var(--safe-area-inset-left);
right: env(safe-area-inset-right);
bottom: env(safe-area-inset-bottom);
left: env(safe-area-inset-left);
}
.mdc-snackbar__surface {
min-width: 100%;

View File

@@ -12,8 +12,6 @@ class HaEntityMarker extends LitElement {
@property({ attribute: "entity-name" }) public entityName?: string;
@property({ attribute: "entity-unit" }) public entityUnit?: string;
@property({ attribute: "entity-picture" }) public entityPicture?: string;
@property({ attribute: "entity-color" }) public entityColor?: string;
@@ -39,16 +37,7 @@ class HaEntityMarker extends LitElement {
.hass=${this.hass}
.stateObj=${this.hass?.states[this.entityId]}
></ha-state-icon>`
: !this.entityUnit
? this.entityName
: html`
${this.entityName}
<span
class="unit"
style="display: ${this.entityUnit ? "initial" : "none"}"
>${this.entityUnit}</span
>
`}
: this.entityName}
</div>
`;
}
@@ -83,9 +72,6 @@ class HaEntityMarker extends LitElement {
height: 100%;
width: 100%;
}
.unit {
margin-left: 2px;
}
`;
}

View File

@@ -56,7 +56,6 @@ export interface HaMapEntity {
color: string;
label_mode?: "name" | "state" | "attribute" | "icon";
attribute?: string;
unit?: string;
name?: string;
focus?: boolean;
}
@@ -550,12 +549,6 @@ export class HaMap extends ReactiveElement {
typeof entity !== "string" && entity.label_mode === "icon";
entityMarker.entityId = getEntityId(entity);
entityMarker.entityName = entityName;
entityMarker.entityUnit =
typeof entity !== "string" &&
entity.unit &&
entity.label_mode === "attribute"
? entity.unit
: "";
entityMarker.entityPicture =
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this.hass.hassUrl(entityPicture)
@@ -699,7 +692,7 @@ export class HaMap extends ReactiveElement {
}
.marker-cluster span {
line-height: var(--ha-line-height-expanded);
line-height: 30px;
}
`;
}

View File

@@ -1,228 +0,0 @@
import type { CSSResultGroup } from "lit";
import { mdiClose } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { HassEntity } from "home-assistant-js-websocket";
import { fireEvent } from "../../common/dom/fire_event";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-dialog";
import "../ha-button";
import "../ha-dialog-header";
import "./ha-media-player-toggle";
import type { JoinMediaPlayersDialogParams } from "./show-join-media-players-dialog";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
type MediaPlayerEntity,
MediaPlayerEntityFeature,
mediaPlayerJoin,
mediaPlayerUnjoin,
} from "../../data/media-player";
import { extractApiErrorMessage } from "../../data/hassio/common";
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import { computeDomain } from "../../common/entity/compute_domain";
@customElement("dialog-join-media-players")
class DialogJoinMediaPlayers extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _entityId?: string;
@state() private _groupMembers!: string[];
@state() private _selectedEntities!: string[];
@state() private _submitting?: boolean;
@state() private _error?: string;
public showDialog(params: JoinMediaPlayersDialogParams): void {
this._entityId = params.entityId;
const stateObj = this.hass.states[params.entityId] as
| MediaPlayerEntity
| undefined;
this._groupMembers =
stateObj?.attributes.group_members?.filter(
(entityId) => entityId !== params.entityId
) || [];
this._selectedEntities = this._groupMembers;
}
public closeDialog() {
this._entityId = undefined;
this._selectedEntities = [];
this._groupMembers = [];
this._submitting = false;
this._error = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._entityId) {
return nothing;
}
const entityId = this._entityId;
const stateObj = this.hass.states[entityId] as HassEntity | undefined;
const name = (stateObj && computeStateName(stateObj)) || entityId;
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
flexContent
.heading=${name}
@closed=${this.closeDialog}
>
<ha-dialog-header show-border slot="heading">
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
dialogAction="close"
slot="navigationIcon"
></ha-icon-button>
<span slot="title"
>${this.hass.localize("ui.card.media_player.media_players")}</span
>
<ha-button slot="actionItems" @click=${this._selectAll}>
${this.hass.localize("ui.card.media_player.select_all")}
</ha-button>
</ha-dialog-header>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="content">
<ha-media-player-toggle
.hass=${this.hass}
.entityId=${entityId}
checked
disabled
></ha-media-player-toggle>
${this._mediaPlayerEntities(this.hass.entities).map(
(entity) =>
html`<ha-media-player-toggle
.hass=${this.hass}
.entityId=${entity.entity_id}
.checked=${this._selectedEntities.includes(entity.entity_id)}
@change=${this._handleSelectedChange}
></ha-media-player-toggle>`
)}
</div>
<ha-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
.disabled=${this._submitting}
slot="primaryAction"
@click=${this._submit}
>
${this.hass.localize("ui.common.apply")}
</ha-button>
</ha-dialog>
`;
}
private _mediaPlayerEntities = (
entities: Record<string, EntityRegistryDisplayEntry>
) => {
if (!this._entityId) {
return [];
}
const currentPlatform = this.hass.entities[this._entityId]?.platform;
if (!currentPlatform) {
return [];
}
return Object.values(entities).filter((entity) => {
if (entity.entity_id === this._entityId) {
return false;
}
if (computeDomain(entity.entity_id) !== "media_player") {
return false;
}
if (this.hass.entities[entity.entity_id]?.platform !== currentPlatform) {
return false;
}
if (
!this.hass.states[entity.entity_id] ||
!supportsFeature(
this.hass.states[entity.entity_id],
MediaPlayerEntityFeature.GROUPING
)
) {
return false;
}
return true;
});
};
private _selectAll() {
this._selectedEntities = this._mediaPlayerEntities(this.hass.entities).map(
(entity) => entity.entity_id
);
}
private _handleSelectedChange(ev) {
const selectedEntities = this._selectedEntities.filter(
(entityId) => entityId !== ev.target.entityId
);
if (ev.target.checked) {
selectedEntities.push(ev.target.entityId);
}
this._selectedEntities = selectedEntities;
}
private async _submit(): Promise<void> {
if (!this._entityId) {
return;
}
this._error = undefined;
this._submitting = true;
try {
// If media is already playing
await mediaPlayerJoin(this.hass, this._entityId, this._selectedEntities);
await Promise.all(
this._groupMembers
.filter((entityId) => !this._selectedEntities.includes(entityId))
.map((entityId) => mediaPlayerUnjoin(this.hass, entityId))
);
this.closeDialog();
} catch (err) {
this._error = extractApiErrorMessage(err);
}
this._submitting = false;
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
.content {
display: flex;
flex-direction: column;
row-gap: 16px;
}
ha-dialog-header ha-button {
margin: 6px;
display: block;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-join-media-players": DialogJoinMediaPlayers;
}
}

View File

@@ -214,7 +214,6 @@ class BrowseMediaTTS extends LitElement {
item.media_content_id = `${
item.media_content_id.split("?")[0]
}?${query.toString()}`;
item.media_content_type = "audio/mp3";
item.can_play = true;
item.title = message;
fireEvent(this, "tts-picked", { item });

View File

@@ -966,7 +966,7 @@ export class HaMediaPlayerBrowse extends LitElement {
}
.breadcrumb .title {
font-size: var(--ha-font-size-4xl);
line-height: var(--ha-line-height-condensed);
line-height: 1.2;
font-weight: var(--ha-font-weight-bold);
margin: 0;
overflow: hidden;

View File

@@ -1,96 +0,0 @@
import { type CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { mdiSpeaker } from "@mdi/js";
import type { HomeAssistant } from "../../types";
import { computeStateName } from "../../common/entity/compute_state_name";
import { fireEvent } from "../../common/dom/fire_event";
import "../ha-switch";
import "../ha-svg-icon";
import type { MediaPlayerEntity } from "../../data/media-player";
@customElement("ha-media-player-toggle")
class HaMediaPlayerToggle extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId!: string;
@property({ type: Boolean }) public checked = false;
@property({ type: Boolean }) public disabled = false;
protected render() {
const stateObj = this.hass.states[this.entityId];
return html`<div class="list-item">
<ha-svg-icon .path=${mdiSpeaker}></ha-svg-icon>
<div class="info">
<div class="main-text">${computeStateName(stateObj)}</div>
<div class="secondary-text">
${this._formatSecondaryText(stateObj as MediaPlayerEntity)}
</div>
</div>
<ha-switch
.disabled=${this.disabled}
.checked=${this.checked}
@change=${this._handleChange}
></ha-switch>
</div>`;
}
private _formatSecondaryText(stateObj: MediaPlayerEntity): string {
if (stateObj.state !== "playing") {
return this.hass.localize("ui.card.media_player.idle");
}
return [stateObj.attributes.media_title, stateObj.attributes.media_artist]
.filter((segment) => segment)
.join(" · ");
}
static get styles(): CSSResultGroup {
return [
css`
.list-item {
display: grid;
grid-template-columns: auto 1fr auto;
column-gap: 16px;
align-items: center;
width: 100%;
}
.info {
min-width: 0;
}
.main-text {
color: var(--primary-text-color);
}
.main-text[take-height] {
line-height: 40px;
}
.secondary-text {
color: var(--secondary-text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
`,
];
}
private _handleChange(ev) {
ev.stopPropagation();
this.checked = ev.target.checked;
fireEvent(this, "change");
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-player-toggle": HaMediaPlayerToggle;
}
}

View File

@@ -1,16 +0,0 @@
import { fireEvent } from "../../common/dom/fire_event";
export interface JoinMediaPlayersDialogParams {
entityId: string;
}
export const showJoinMediaPlayersDialog = (
element: HTMLElement,
dialogParams: JoinMediaPlayersDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-join-media-players",
dialogImport: () => import("./dialog-join-media-players"),
dialogParams,
});
};

View File

@@ -36,14 +36,14 @@ export class HaTileInfo extends LitElement {
.primary {
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal);
line-height: 20px;
letter-spacing: 0.1px;
color: var(--primary-text-color);
}
.secondary {
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed);
line-height: 16px;
letter-spacing: 0.4px;
color: var(--primary-text-color);
}

View File

@@ -1,30 +1,21 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { css, html, LitElement } from "lit";
import { property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user";
import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-select";
import "./ha-user-badge";
import "../ha-list-item";
interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
@customElement("ha-user-picker")
class HaUserPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
public hass?: HomeAssistant;
@property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: false }) public noUserLabel?: string;
@property() public value = "";
@@ -33,124 +24,78 @@ class HaUserPicker extends LitElement {
@property({ type: Boolean }) public disabled = false;
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (!this.users) {
this._fetchUsers();
}
}
private async _fetchUsers() {
this.users = await fetchUsers(this.hass);
}
private usersMap = memoizeOne((users?: User[]): Map<string, User> => {
if (!users) {
return new Map();
}
return new Map(users.map((user) => [user.id, user]));
});
private _valueRenderer: PickerValueRenderer = (value) => {
const user = this.usersMap(this.users).get(value);
if (!user) {
return html` <span slot="headline">${value}</span> `;
}
return html`
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${user}
></ha-user-badge>
<span slot="headline">${user.name}</span>
`;
};
private _rowRenderer: ComboBoxLitRenderer<UserComboBoxItem> = (item) => {
const user = item.user;
if (!user) {
return html`<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>`;
}
return html`
<ha-combo-box-item type="button" compact>
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${item.user}
></ha-user-badge>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
};
private _getUsers = memoizeOne((users?: User[]) => {
private _sortedUsers = memoizeOne((users?: User[]) => {
if (!users) {
return [];
}
return users
.filter((user) => !user.system_generated)
.map<UserComboBoxItem>((user) => ({
id: user.id,
primary: user.name,
domain_name: user.name,
search_labels: [user.name, user.id, user.username].filter(
Boolean
) as string[],
sorting_label: user.name,
user,
}));
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass!.locale.language)
);
});
private _getItems = () => this._getUsers(this.users);
protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.user-picker.user");
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
<ha-select
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.user-picker.no_match"
)}
.placeholder=${placeholder}
.disabled=${this.disabled}
.value=${this.value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
@selected=${this._userChanged}
>
</ha-generic-picker>
${this.users?.length === 0
? html`<ha-list-item value="">
${this.noUserLabel ||
this.hass?.localize("ui.components.user-picker.no_user")}
</ha-list-item>`
: ""}
${this._sortedUsers(this.users).map(
(user) => html`
<ha-list-item graphic="avatar" .value=${user.id}>
<ha-user-badge
.hass=${this.hass}
.user=${user}
slot="graphic"
></ha-user-badge>
${user.name}
</ha-list-item>
`
)}
</ha-select>
`;
}
private _valueChanged(ev) {
const value = ev.detail.value;
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (this.users === undefined) {
fetchUsers(this.hass!).then((users) => {
this.users = users;
});
}
}
private _userChanged(ev) {
const newValue = ev.target.value;
if (newValue !== this.value) {
this.value = newValue;
setTimeout(() => {
fireEvent(this, "value-changed", { value: newValue });
fireEvent(this, "change");
}, 0);
}
}
static styles = css`
:host {
display: inline-block;
}
`;
}
customElements.define("ha-user-picker", HaUserPicker);
declare global {
interface HTMLElementTagNameMap {
"ha-user-picker": HaUserPicker;

View File

@@ -1,3 +1,4 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { guard } from "lit/directives/guard";
@@ -5,15 +6,13 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import type { ValueChangedEvent, HomeAssistant } from "../../types";
import "../ha-icon-button";
import "./ha-user-picker";
@customElement("ha-users-picker")
class HaUsersPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
class HaUsersPickerLight extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public value?: string[];
@@ -30,15 +29,13 @@ class HaUsersPicker extends LitElement {
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (!this.users) {
this._fetchUsers();
if (this.users === undefined) {
fetchUsers(this.hass!).then((users) => {
this.users = users;
});
}
}
private async _fetchUsers() {
this.users = await fetchUsers(this.hass);
}
protected render() {
if (!this.hass || !this.users) {
return nothing;
@@ -46,13 +43,15 @@ class HaUsersPicker extends LitElement {
const notSelectedUsers = this._notSelectedUsers(this.users, this.value);
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${guard([notSelectedUsers], () =>
this.value?.map(
(user_id, idx) => html`
<div>
<ha-user-picker
.placeholder=${this.pickedUserLabel}
.label=${this.pickedUserLabel}
.noUserLabel=${this.hass!.localize(
"ui.components.user-picker.remove_user"
)}
.index=${idx}
.hass=${this.hass}
.value=${user_id}
@@ -64,20 +63,28 @@ class HaUsersPicker extends LitElement {
.disabled=${this.disabled}
@value-changed=${this._userChanged}
></ha-user-picker>
<ha-icon-button
.userId=${user_id}
.label=${this.hass!.localize(
"ui.components.user-picker.remove_user"
)}
.path=${mdiClose}
@click=${this._removeUser}
>
></ha-icon-button
>
</div>
`
)
)}
<div>
<ha-user-picker
.placeholder=${this.pickUserLabel ||
this.hass!.localize("ui.components.user-picker.add_user")}
.hass=${this.hass}
.users=${notSelectedUsers}
.disabled=${this.disabled || !notSelectedUsers?.length}
@value-changed=${this._addUser}
></ha-user-picker>
</div>
<ha-user-picker
.label=${this.pickUserLabel ||
this.hass!.localize("ui.components.user-picker.add_user")}
.hass=${this.hass}
.users=${notSelectedUsers}
.disabled=${this.disabled || !notSelectedUsers?.length}
@value-changed=${this._addUser}
></ha-user-picker>
`;
}
@@ -113,12 +120,12 @@ class HaUsersPicker extends LitElement {
});
}
private _userChanged(ev: ValueChangedEvent<string | undefined>) {
ev.stopPropagation();
const index = (ev.currentTarget as any).index;
const newValue = ev.detail.value;
private _userChanged(event: ValueChangedEvent<string>) {
event.stopPropagation();
const index = (event.currentTarget as any).index;
const newValue = event.detail.value;
const newUsers = [...this._currentUsers];
if (!newValue) {
if (newValue === "") {
newUsers.splice(index, 1);
} else {
newUsers.splice(index, 1, newValue);
@@ -141,15 +148,24 @@ class HaUsersPicker extends LitElement {
this._updateUsers([...currentUsers, toAdd]);
}
static override styles = css`
private _removeUser(event) {
const userId = (event.currentTarget as any).userId;
this._updateUsers(this._currentUsers.filter((user) => user !== userId));
}
static styles = css`
:host {
display: block;
}
div {
margin-top: 8px;
display: flex;
align-items: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-users-picker": HaUsersPicker;
"ha-users-picker": HaUsersPickerLight;
}
}

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