Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Bottein
37b79c67c9 Fix search 2025-05-09 17:26:54 +02:00
Paul Bottein
0715835e0d Improve area picker UI and search 2025-05-09 17:08:38 +02:00
415 changed files with 8051 additions and 10830 deletions

View File

@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Send bundle stats and build information to RelativeCI - 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: with:
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }} key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
token: ${{ github.token }} token: ${{ github.token }}

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,7 @@
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass"; import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
let changeFunction;
export const mockFrontend = (hass: MockHomeAssistant) => { export const mockFrontend = (hass: MockHomeAssistant) => {
hass.mockWS("frontend/get_user_data", () => ({ hass.mockWS("frontend/get_user_data", () => ({
value: null, 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 { .title {
font-size: 42px; font-size: 42px;
line-height: var(--ha-line-height-condensed); line-height: 56px;
padding-bottom: 8px; padding-bottom: 8px;
} }
.subtitle { .subtitle {
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
line-height: var(--ha-line-height-normal); line-height: 24px;
} }
.root { .root {
max-width: 800px; max-width: 800px;

View File

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

View File

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

View File

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

View File

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

View File

@@ -610,7 +610,7 @@ export class DialogHassioNetwork
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
padding: 8px; 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); background-color: var(--mdc-theme-surface, #fff);
} }
.warning { .warning {

View File

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

View File

@@ -4,7 +4,7 @@ export default {
"prettier --cache --write", "prettier --cache --write",
"lit-analyzer --quiet", "lit-analyzer --quiet",
], ],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write", "*.{json,css,md,markdown,html,y?aml}": "prettier --cache --write",
"translations/*/*.json": (files) => "translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' + 'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") + files.join(" ") +

View File

@@ -26,15 +26,15 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@babel/runtime": "7.27.4", "@babel/runtime": "7.27.1",
"@braintree/sanitize-url": "7.1.1", "@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6", "@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1", "@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.0", "@codemirror/language": "6.11.0",
"@codemirror/legacy-modes": "6.5.1", "@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11", "@codemirror/search": "6.5.10",
"@codemirror/state": "6.5.2", "@codemirror/state": "6.5.2",
"@codemirror/view": "6.37.1", "@codemirror/view": "6.36.7",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.18.0", "@formatjs/intl-datetimeformat": "6.18.0",
"@formatjs/intl-displaynames": "6.8.11", "@formatjs/intl-displaynames": "6.8.11",
@@ -89,14 +89,14 @@
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.8.1", "@tsparticles/engine": "3.8.1",
"@tsparticles/preset-links": "3.2.0", "@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.7.7", "@vaadin/combo-box": "24.7.5",
"@vaadin/vaadin-themable-mixin": "24.7.7", "@vaadin/vaadin-themable-mixin": "24.7.5",
"@vibrant/color": "4.0.0", "@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0", "@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10", "@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0", "@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1", "app-datepicker": "5.1.1",
"barcode-detector": "3.0.4", "barcode-detector": "3.0.1",
"color-name": "2.0.0", "color-name": "2.0.0",
"comlink": "4.4.2", "comlink": "4.4.2",
"core-js": "3.42.0", "core-js": "3.42.0",
@@ -111,9 +111,9 @@
"fuse.js": "7.1.0", "fuse.js": "7.1.0",
"google-timezones-json": "1.2.0", "google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2", "gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.4", "hls.js": "1.6.2",
"home-assistant-js-websocket": "9.5.0", "home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2", "idb-keyval": "6.2.1",
"intl-messageformat": "10.7.16", "intl-messageformat": "10.7.16",
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"leaflet": "1.9.4", "leaflet": "1.9.4",
@@ -122,7 +122,7 @@
"lit": "3.3.0", "lit": "3.3.0",
"lit-html": "3.3.0", "lit-html": "3.3.0",
"luxon": "3.6.1", "luxon": "3.6.1",
"marked": "15.0.12", "marked": "15.0.11",
"memoize-one": "6.0.0", "memoize-one": "6.0.0",
"node-vibrant": "4.0.3", "node-vibrant": "4.0.3",
"object-hash": "3.0.0", "object-hash": "3.0.0",
@@ -137,6 +137,7 @@
"tinykeys": "3.0.0", "tinykeys": "3.0.0",
"ua-parser-js": "2.0.3", "ua-parser-js": "2.0.3",
"vis-data": "7.1.9", "vis-data": "7.1.9",
"vis-network": "9.1.9",
"vue": "2.7.16", "vue": "2.7.16",
"vue2-daterange-picker": "0.6.8", "vue2-daterange-picker": "0.6.8",
"weekstart": "2.0.0", "weekstart": "2.0.0",
@@ -149,26 +150,26 @@
"xss": "1.0.15" "xss": "1.0.15"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.27.4", "@babel/core": "7.27.1",
"@babel/helper-define-polyfill-provider": "0.6.4", "@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", "@babel/preset-env": "7.27.1",
"@bundle-stats/plugin-webpack-filter": "4.20.2", "@bundle-stats/plugin-webpack-filter": "4.20.0",
"@lokalise/node-api": "14.8.0", "@lokalise/node-api": "14.5.2",
"@octokit/auth-oauth-device": "8.0.1", "@octokit/auth-oauth-device": "7.1.5",
"@octokit/plugin-retry": "8.0.1", "@octokit/plugin-retry": "7.2.1",
"@octokit/rest": "22.0.0", "@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "1.1.2", "@rsdoctor/rspack-plugin": "1.1.0",
"@rspack/cli": "1.3.12", "@rspack/cli": "1.3.8",
"@rspack/core": "1.3.12", "@rspack/core": "1.3.8",
"@types/babel__plugin-transform-runtime": "7.9.5", "@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/chromecast-caf-sender": "1.0.11",
"@types/color-name": "2.0.0", "@types/color-name": "2.0.0",
"@types/glob": "8.1.0", "@types/glob": "8.1.0",
"@types/html-minifier-terser": "7.0.2", "@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.18", "@types/leaflet": "1.9.17",
"@types/leaflet-draw": "1.0.12", "@types/leaflet-draw": "1.0.12",
"@types/leaflet.markercluster": "1.5.5", "@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9", "@types/lodash.merge": "4.6.9",
@@ -179,14 +180,14 @@
"@types/tar": "6.1.13", "@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.1.4", "@vitest/coverage-v8": "3.1.3",
"babel-loader": "10.0.0", "babel-loader": "10.0.0",
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0", "del": "8.0.0",
"eslint": "9.28.0", "eslint": "9.26.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5", "eslint-config-prettier": "10.1.2",
"eslint-import-resolver-webpack": "0.13.10", "eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-lit": "2.1.1", "eslint-plugin-lit": "2.1.1",
@@ -196,7 +197,7 @@
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.3.0", "fs-extra": "11.3.0",
"glob": "11.0.2", "glob": "11.0.2",
"gulp": "5.0.1", "gulp": "5.0.0",
"gulp-brotli": "3.0.0", "gulp-brotli": "3.0.0",
"gulp-json-transform": "0.5.0", "gulp-json-transform": "0.5.0",
"gulp-rename": "2.0.0", "gulp-rename": "2.0.0",
@@ -204,7 +205,7 @@
"husky": "9.1.7", "husky": "9.1.7",
"jsdom": "26.1.0", "jsdom": "26.1.0",
"jszip": "3.10.1", "jszip": "3.10.1",
"lint-staged": "16.1.0", "lint-staged": "15.5.2",
"lit-analyzer": "2.0.3", "lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2", "lodash.merge": "4.6.2",
"lodash.template": "4.5.0", "lodash.template": "4.5.0",
@@ -218,9 +219,9 @@
"terser-webpack-plugin": "5.3.14", "terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.8.3", "typescript": "5.8.3",
"typescript-eslint": "8.33.0", "typescript-eslint": "8.32.0",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "3.1.4", "vitest": "3.1.3",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@@ -232,7 +233,7 @@
"clean-css": "5.3.3", "clean-css": "5.3.3",
"@lit/reactive-element": "2.1.0", "@lit/reactive-element": "2.1.0",
"@fullcalendar/daygrid": "6.1.17", "@fullcalendar/daygrid": "6.1.17",
"globals": "16.2.0", "globals": "16.0.0",
"tslib": "2.8.1", "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" "@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"
}, },

View File

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

View File

@@ -59,7 +59,7 @@ export class HaPickAuthProvider extends LitElement {
text-align: center; text-align: center;
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal); line-height: 20px;
} }
h3:before { h3:before {
border-top: 1px solid var(--divider-color); 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 { computeStateDomain } from "./compute_state_domain";
import { updateIcon } from "./update_icon"; import { updateIcon } from "./update_icon";
import { deviceTrackerIcon } from "./device_tracker_icon"; import { deviceTrackerIcon } from "./device_tracker_icon";
import { batteryIcon } from "./battery_icon";
export const stateIcon = ( export const stateIcon = (
stateObj: HassEntity, stateObj: HassEntity,
@@ -9,10 +10,17 @@ export const stateIcon = (
): string | undefined => { ): string | undefined => {
const domain = computeStateDomain(stateObj); const domain = computeStateDomain(stateObj);
const compareState = state ?? stateObj.state; const compareState = state ?? stateObj.state;
const dc = stateObj.attributes.device_class;
switch (domain) { switch (domain) {
case "update": case "update":
return updateIcon(stateObj, compareState); return updateIcon(stateObj, compareState);
case "sensor":
if (dc === "battery") {
return batteryIcon(stateObj, compareState);
}
break;
case "device_tracker": case "device_tracker":
return deviceTrackerIcon(stateObj, compareState); 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 // https://gist.github.com/hagemann/382adfc57adbd5af078dc93feef01fe1
export const slugify = (value: string, delimiter = "_") => { export const slugify = (value: string, delimiter = "_") => {
const a = const a =
"àáâäæãåāăąабçćčđďдèéêëēėęěеёэфğǵгḧхîïíīįìıİийкłлḿмñńǹňнôöòóœøōõőоṕпŕřрßśšşșсťțтûüùúūǘůűųувẃẍÿýыžźżз·"; "àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·";
const b = `aaaaaaaaaaabcccdddeeeeeeeeeeefggghhiiiiiiiiijkllmmnnnnnoooooooooopprrrsssssstttuuuuuuuuuuvwxyyyzzzz${delimiter}`; const b = `aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz${delimiter}`;
const p = new RegExp(a.split("").join("|"), "g"); const p = new RegExp(a.split("").join("|"), "g");
const complex_cyrillic = {
ж: "zh",
х: "kh",
ц: "ts",
ч: "ch",
ш: "sh",
щ: "shch",
ю: "iu",
я: "ia",
};
let slugified; let slugified;
@@ -24,7 +14,6 @@ export const slugify = (value: string, delimiter = "_") => {
.toString() .toString()
.toLowerCase() .toLowerCase()
.replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters .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(/(\d),(?=\d)/g, "$1") // Remove Commas between numbers
.replace(/[^a-z0-9]+/g, delimiter) // Replace all non-word characters .replace(/[^a-z0-9]+/g, delimiter) // Replace all non-word characters
.replace(new RegExp(`(${delimiter})\\1+`, "g"), "$1") // Replace multiple delimiters with single delimiter .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 { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array"; import { ensureArray } from "../../common/array/ensure-array";
import "../chips/ha-assist-chip"; import "../chips/ha-assist-chip";
import { downSampleLineData } from "./down-sample";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10; const LEGEND_OVERFLOW_LIMIT = 10;
@@ -49,8 +48,7 @@ export class HaChartBase extends LitElement {
@property({ attribute: "expand-legend", type: Boolean }) @property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean; public expandLegend?: boolean;
// extraComponents is not reactive and should not trigger updates @property({ attribute: false }) public extraComponents?: any[];
public extraComponents?: any[];
@state() @state()
@consume({ context: themesContext, subscribe: true }) @consume({ context: themesContext, subscribe: true })
@@ -108,49 +106,48 @@ export class HaChartBase extends LitElement {
}) })
); );
if (!this.options?.dataZoom) { // Add keyboard event listeners
// Add keyboard event listeners const handleKeyDown = (ev: KeyboardEvent) => {
const handleKeyDown = (ev: KeyboardEvent) => { if (
if ( !this._modifierPressed &&
!this._modifierPressed && ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) ) {
) { this._modifierPressed = true;
this._modifierPressed = true; if (!this.options?.dataZoom) {
if (!this.options?.dataZoom) { this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
// drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
} }
}; // drag to zoom
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: true,
});
}
};
const handleKeyUp = (ev: KeyboardEvent) => { const handleKeyUp = (ev: KeyboardEvent) => {
if ( if (
this._modifierPressed && this._modifierPressed &&
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
) { ) {
this._modifierPressed = false; this._modifierPressed = false;
if (!this.options?.dataZoom) { if (!this.options?.dataZoom) {
this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
}
this.chart?.dispatchAction({
type: "takeGlobalCursor",
key: "dataZoomSelect",
dataZoomSelectActive: false,
});
} }
}; this.chart?.dispatchAction({
window.addEventListener("keydown", handleKeyDown); type: "takeGlobalCursor",
window.addEventListener("keyup", handleKeyUp); key: "dataZoomSelect",
this._listeners.push( dataZoomSelectActive: false,
() => window.removeEventListener("keydown", handleKeyDown), });
() => window.removeEventListener("keyup", handleKeyUp) }
); };
}
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keyup", handleKeyUp);
this._listeners.push(
() => window.removeEventListener("keydown", handleKeyDown),
() => window.removeEventListener("keyup", handleKeyUp)
);
} }
protected firstUpdated() { protected firstUpdated() {
@@ -194,19 +191,16 @@ export class HaChartBase extends LitElement {
<div class="chart"></div> <div class="chart"></div>
</div> </div>
${this._renderLegend()} ${this._renderLegend()}
<div class="chart-controls"> ${this._isZoomed
${this._isZoomed ? html`<ha-icon-button
? html`<ha-icon-button class="zoom-reset"
class="zoom-reset" .path=${mdiRestart}
.path=${mdiRestart} @click=${this._handleZoomReset}
@click=${this._handleZoomReset} title=${this.hass.localize(
title=${this.hass.localize( "ui.components.history_charts.zoom_reset"
"ui.components.history_charts.zoom_reset" )}
)} ></ha-icon-button>`
></ha-icon-button>` : nothing}
: nothing}
<slot name="button"></slot>
</div>
</div> </div>
`; `;
} }
@@ -216,15 +210,15 @@ export class HaChartBase extends LitElement {
return nothing; return nothing;
} }
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption; const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
if (!legend.show || legend.type !== "custom") { if (!legend.show) {
return nothing; return nothing;
} }
const datasets = ensureArray(this.data); const datasets = ensureArray(this.data);
const items: LegendComponentOption["data"] = const items = (legend.data ||
legend.data || datasets
((datasets
.filter((d) => (d.data as any[])?.length && (d.id || d.name)) .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( const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)" "all and (max-width: 450px), all and (max-height: 500px)"
@@ -239,32 +233,20 @@ export class HaChartBase extends LitElement {
})} })}
> >
<ul> <ul>
${items.map((item, index) => { ${items.map((item: string, index: number) => {
if (!this.expandLegend && index >= overflowLimit) { if (!this.expandLegend && index >= overflowLimit) {
return nothing; return nothing;
} }
let itemStyle: Record<string, any> = {}; const dataset = datasets.find(
let name = ""; (d) => d.id === item || d.name === item
if (typeof item === "string") { );
name = item; const color = dataset?.color as string;
const dataset = datasets.find( const borderColor = dataset?.itemStyle?.borderColor as string;
(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;
return html`<li return html`<li
.name=${name} .name=${item}
@click=${this._legendClick} @click=${this._legendClick}
class=${classMap({ hidden: this._hiddenDatasets.has(name) })} class=${classMap({ hidden: this._hiddenDatasets.has(item) })}
.title=${name} .title=${item}
> >
<div <div
class="bullet" class="bullet"
@@ -273,7 +255,7 @@ export class HaChartBase extends LitElement {
borderColor: borderColor || color, borderColor: borderColor || color,
})} })}
></div> ></div>
<div class="label">${name}</div> <div class="label">${item}</div>
</li>`; </li>`;
})} })}
${items.length > overflowLimit ${items.length > overflowLimit
@@ -333,9 +315,7 @@ export class HaChartBase extends LitElement {
this.chart.on("click", (e: ECElementEvent) => { this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e); 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) { if (this._isTouchDevice) {
this.chart.getZr().on("click", (e: ECElementEvent) => { this.chart.getZr().on("click", (e: ECElementEvent) => {
if (!e.zrByTouch) { if (!e.zrByTouch) {
@@ -400,9 +380,9 @@ export class HaChartBase extends LitElement {
if (axis.type !== "time" || axis.show === false) { if (axis.type !== "time" || axis.show === false) {
return axis; return axis;
} }
if (axis.min) { if (axis.max && axis.min) {
this._minutesDifference = differenceInMinutes( this._minutesDifference = differenceInMinutes(
(axis.max as Date) || new Date(), axis.max as Date,
axis.min as Date axis.min as Date
); );
} }
@@ -430,12 +410,6 @@ export class HaChartBase extends LitElement {
} as XAXisOption; } as XAXisOption;
}); });
} }
let legend = this.options?.legend;
if (legend) {
legend = ensureArray(legend).map((l) =>
l.type === "custom" ? { show: false } : l
);
}
const options = { const options = {
animation: !this._reducedMotion, animation: !this._reducedMotion,
darkMode: this._themes.darkMode ?? false, darkMode: this._themes.darkMode ?? false,
@@ -450,7 +424,7 @@ export class HaChartBase extends LitElement {
iconStyle: { opacity: 0 }, iconStyle: { opacity: 0 },
}, },
...this.options, ...this.options,
legend, legend: { show: false },
xAxis, xAxis,
}; };
@@ -494,13 +468,6 @@ export class HaChartBase extends LitElement {
smooth: false, smooth: false,
}, },
bar: { itemStyle: { barBorderWidth: 1.5 } }, bar: { itemStyle: { barBorderWidth: 1.5 } },
graph: {
label: {
color: style.getPropertyValue("--primary-text-color"),
textBorderColor: style.getPropertyValue("--primary-background-color"),
textBorderWidth: 2,
},
},
categoryAxis: { categoryAxis: {
axisLine: { show: false }, axisLine: { show: false },
axisTick: { show: false }, axisTick: { show: false },
@@ -633,21 +600,19 @@ export class HaChartBase extends LitElement {
} }
private _getSeries() { private _getSeries() {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as const series = ensureArray(this.data).filter(
| XAXisOption (d) => !this._hiddenDatasets.has(String(d.name ?? d.id))
| undefined; );
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption | YAXisOption
| undefined; | undefined;
const series = ensureArray(this.data) if (yAxis?.type === "log") {
.filter((d) => !this._hiddenDatasets.has(String(d.name ?? d.id))) // set <=0 values to null so they render as gaps on a log graph
.map((s) => { return series.map((d) =>
if (s.type === "line") { d.type === "line"
if (yAxis?.type === "log") { ? {
// set <=0 values to null so they render as gaps on a log graph ...d,
return { data: d.data?.map((v) =>
...s,
data: s.data?.map((v) =>
Array.isArray(v) Array.isArray(v)
? [ ? [
v[0], v[0],
@@ -656,26 +621,10 @@ export class HaChartBase extends LitElement {
] ]
: v : v
), ),
}; }
} : d
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;
});
return series; return series;
} }
@@ -776,26 +725,16 @@ export class HaChartBase extends LitElement {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.chart-controls { .zoom-reset {
position: absolute; position: absolute;
top: 16px; top: 16px;
right: 4px; 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); background: var(--card-background-color);
border-radius: 4px; border-radius: 4px;
--mdc-icon-button-size: 32px; --mdc-icon-button-size: 32px;
color: var(--primary-color); color: var(--primary-color);
border: 1px solid var(--divider-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 { .chart-legend {
max-height: 60%; max-height: 60%;
overflow-y: auto; 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 _chartTime: Date = new Date();
private _previousYAxisLabelValue = 0;
protected render() { protected render() {
return html` return html`
<ha-chart-base <ha-chart-base
@@ -260,11 +258,35 @@ export class StateHistoryChartLine extends LitElement {
}, },
axisLabel: { axisLabel: {
margin: 5, 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, } as YAXisOption,
legend: { legend: {
type: "custom",
show: this.showNames, show: this.showNames,
}, },
grid: { grid: {
@@ -722,33 +744,6 @@ export class StateHistoryChartLine extends LitElement {
this._visualMap = visualMap.length > 0 ? visualMap : undefined; 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)) { private _clampYAxis(value?: number | ((values: any) => number)) {
if (this.logarithmicScale) { if (this.logarithmicScale) {
// log(0) is -Infinity, so we need to set a minimum value // log(0) is -Infinity, so we need to set a minimum value

View File

@@ -308,7 +308,6 @@ export class StatisticsChart extends LitElement {
}, },
}, },
legend: { legend: {
type: "custom",
show: !this.hideLegend, show: !this.hideLegend,
data: this._legendData, data: this._legendData,
}, },

View File

@@ -164,8 +164,6 @@ export class HaDataTable extends LitElement {
@state() private _collapsedGroups: string[] = []; @state() private _collapsedGroups: string[] = [];
@state() private _lastSelectedRowId: string | null = null;
private _checkableRowsCount?: number; private _checkableRowsCount?: number;
private _checkedRows: string[] = []; private _checkedRows: string[] = [];
@@ -189,7 +187,6 @@ export class HaDataTable extends LitElement {
public clearSelection(): void { public clearSelection(): void {
this._checkedRows = []; this._checkedRows = [];
this._lastSelectedRowId = null;
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
@@ -197,7 +194,6 @@ export class HaDataTable extends LitElement {
this._checkedRows = this._filteredData this._checkedRows = this._filteredData
.filter((data) => data.selectable !== false) .filter((data) => data.selectable !== false)
.map((data) => data[this.id]); .map((data) => data[this.id]);
this._lastSelectedRowId = null;
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
@@ -211,7 +207,6 @@ export class HaDataTable extends LitElement {
this._checkedRows.push(id); this._checkedRows.push(id);
} }
}); });
this._lastSelectedRowId = null;
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
@@ -222,7 +217,6 @@ export class HaDataTable extends LitElement {
this._checkedRows.splice(index, 1); this._checkedRows.splice(index, 1);
} }
}); });
this._lastSelectedRowId = null;
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
@@ -267,7 +261,6 @@ export class HaDataTable extends LitElement {
if (this.columns[columnId].direction) { if (this.columns[columnId].direction) {
this.sortDirection = this.columns[columnId].direction!; this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId; this.sortColumn = columnId;
this._lastSelectedRowId = null;
fireEvent(this, "sorting-changed", { fireEvent(this, "sorting-changed", {
column: columnId, column: columnId,
@@ -293,7 +286,6 @@ export class HaDataTable extends LitElement {
if (properties.has("filter")) { if (properties.has("filter")) {
this._debounceSearch(this.filter); this._debounceSearch(this.filter);
this._lastSelectedRowId = null;
} }
if (properties.has("data")) { if (properties.has("data")) {
@@ -304,11 +296,9 @@ export class HaDataTable extends LitElement {
if (!this.hasUpdated && this.initialCollapsedGroups) { if (!this.hasUpdated && this.initialCollapsedGroups) {
this._collapsedGroups = this.initialCollapsedGroups; this._collapsedGroups = this.initialCollapsedGroups;
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups }); fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} else if (properties.has("groupColumn")) { } else if (properties.has("groupColumn")) {
this._collapsedGroups = []; this._collapsedGroups = [];
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups }); fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} }
@@ -322,14 +312,6 @@ export class HaDataTable extends LitElement {
this._sortFilterData(); this._sortFilterData();
} }
if (
properties.has("_filter") ||
properties.has("sortColumn") ||
properties.has("sortDirection")
) {
this._lastSelectedRowId = null;
}
if (properties.has("selectable") || properties.has("hiddenColumns")) { if (properties.has("selectable") || properties.has("hiddenColumns")) {
this._filteredData = [...this._filteredData]; this._filteredData = [...this._filteredData];
} }
@@ -560,7 +542,7 @@ export class HaDataTable extends LitElement {
> >
<ha-checkbox <ha-checkbox
class="mdc-data-table__row-checkbox" class="mdc-data-table__row-checkbox"
@click=${this._handleRowCheckboxClicked} @change=${this._handleRowCheckboxClick}
.rowId=${row[this.id]} .rowId=${row[this.id]}
.disabled=${row.selectable === false} .disabled=${row.selectable === false}
.checked=${this._checkedRows.includes(String(row[this.id]))} .checked=${this._checkedRows.includes(String(row[this.id]))}
@@ -740,10 +722,8 @@ export class HaDataTable extends LitElement {
}, {}); }, {});
const groupedItems: DataTableRowData[] = []; const groupedItems: DataTableRowData[] = [];
Object.entries(sorted).forEach(([groupName, rows]) => { Object.entries(sorted).forEach(([groupName, rows]) => {
const collapsed = collapsedGroups.includes(groupName);
groupedItems.push({ groupedItems.push({
append: true, append: true,
selectable: false,
content: html`<div content: html`<div
class="mdc-data-table__cell group-header" class="mdc-data-table__cell group-header"
role="cell" role="cell"
@@ -752,10 +732,9 @@ export class HaDataTable extends LitElement {
> >
<ha-icon-button <ha-icon-button
.path=${mdiChevronUp} .path=${mdiChevronUp}
.label=${this.hass.localize( class=${collapsedGroups.includes(groupName)
`ui.components.data-table.${collapsed ? "expand" : "collapse"}` ? "collapsed"
)} : ""}
class=${collapsed ? "collapsed" : ""}
> >
</ha-icon-button> </ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY ${groupName === UNDEFINED_GROUP_KEY
@@ -771,7 +750,7 @@ export class HaDataTable extends LitElement {
} }
if (appendRow) { if (appendRow) {
items.push({ append: true, selectable: false, content: appendRow }); items.push({ append: true, content: appendRow });
} }
if (hasFab) { if (hasFab) {
@@ -821,84 +800,23 @@ export class HaDataTable extends LitElement {
this._checkedRows = []; this._checkedRows = [];
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
this._lastSelectedRowId = null;
} }
private _handleRowCheckboxClicked = (ev: Event) => { private _handleRowCheckboxClick = (ev: Event) => {
const checkbox = ev.currentTarget as HaCheckbox; const checkbox = ev.currentTarget as HaCheckbox;
const rowId = (checkbox as any).rowId; const rowId = (checkbox as any).rowId;
const groupedData = this._groupData( if (checkbox.checked) {
this._filteredData, if (this._checkedRows.includes(rowId)) {
this.localizeFunc || this.hass.localize, return;
this.appendRow,
this.hasFab,
this.groupColumn,
this.groupOrder,
this._collapsedGroups
);
if (
groupedData.find((data) => data[this.id] === rowId)?.selectable === false
) {
return;
}
const rowIndex = groupedData.findIndex((data) => data[this.id] === rowId);
if (
ev instanceof MouseEvent &&
ev.shiftKey &&
this._lastSelectedRowId !== null
) {
const lastSelectedRowIndex = groupedData.findIndex(
(data) => data[this.id] === this._lastSelectedRowId
);
if (lastSelectedRowIndex > -1 && rowIndex > -1) {
this._checkedRows = [
...this._checkedRows,
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
];
}
} else if (!checkbox.checked) {
if (!this._checkedRows.includes(rowId)) {
this._checkedRows = [...this._checkedRows, rowId];
} }
this._checkedRows = [...this._checkedRows, rowId];
} else { } else {
this._checkedRows = this._checkedRows.filter((row) => row !== rowId); this._checkedRows = this._checkedRows.filter((row) => row !== rowId);
} }
if (rowIndex > -1) {
this._lastSelectedRowId = rowId;
}
this._checkedRowsChanged(); this._checkedRowsChanged();
}; };
private _selectRange(
groupedData: DataTableRowData[],
startIndex: number,
endIndex: number
) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const checkedRows: string[] = [];
for (let i = start; i <= end; i++) {
const row = groupedData[i];
if (
row &&
row.selectable !== false &&
!this._checkedRows.includes(row[this.id])
) {
checkedRows.push(row[this.id]);
}
}
return checkedRows;
}
private _handleRowClick = (ev: Event) => { private _handleRowClick = (ev: Event) => {
if ( if (
ev ev
@@ -940,7 +858,6 @@ export class HaDataTable extends LitElement {
if (this.filter) { if (this.filter) {
return; return;
} }
this._lastSelectedRowId = null;
this._debounceSearch(ev.detail.value); this._debounceSearch(ev.detail.value);
} }
@@ -977,13 +894,11 @@ export class HaDataTable extends LitElement {
} else { } else {
this._collapsedGroups = [...this._collapsedGroups, groupName]; this._collapsedGroups = [...this._collapsedGroups, groupName];
} }
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups }); fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}; };
public expandAllGroups() { public expandAllGroups() {
this._collapsedGroups = []; this._collapsedGroups = [];
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups }); fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} }
@@ -1001,7 +916,6 @@ export class HaDataTable extends LitElement {
delete grouped.undefined; delete grouped.undefined;
} }
this._collapsedGroups = Object.keys(grouped); this._collapsedGroups = Object.keys(grouped);
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups }); fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} }
@@ -1018,7 +932,7 @@ export class HaDataTable extends LitElement {
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing); -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing); -webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem; font-size: 0.875rem;
line-height: var(--ha-line-height-condensed); line-height: 1.25rem;
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em; letter-spacing: 0.0178571429em;
text-decoration: inherit; text-decoration: inherit;
@@ -1138,7 +1052,7 @@ export class HaDataTable extends LitElement {
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing); -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing); -webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem; font-size: 0.875rem;
line-height: var(--ha-line-height-condensed); line-height: 1.25rem;
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em; letter-spacing: 0.0178571429em;
text-decoration: inherit; text-decoration: inherit;
@@ -1259,8 +1173,8 @@ export class HaDataTable extends LitElement {
font-family: var(--ha-font-family-body); font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing); -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: var(--ha-font-smoothing); -webkit-font-smoothing: var(--ha-font-smoothing);
font-size: var(--ha-font-size-s); font-size: 0.875rem;
line-height: var(--ha-line-height-normal); line-height: 1.375rem;
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
letter-spacing: 0.0071428571em; letter-spacing: 0.0071428571em;
text-decoration: inherit; text-decoration: inherit;

View File

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

View File

@@ -1,28 +1,33 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; 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 { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain"; import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { stringCompare } from "../../common/string/compare";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
import { import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching";
getDeviceEntityDisplayLookup, import type {
type DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
type DeviceRegistryEntry, DeviceRegistryEntry,
} from "../../data/device_registry"; } from "../../data/device_registry";
import { domainToName } from "../../data/integration"; import { getDeviceEntityDisplayLookup } from "../../data/device_registry";
import type { HomeAssistant } from "../../types"; import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
import { brandsUrl } from "../../util/brands-url"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker"; import "../ha-combo-box";
import type { HaGenericPicker } from "../ha-generic-picker"; import type { HaComboBox } from "../ha-combo-box";
import type { PickerComboBoxItem } from "../ha-picker-combo-box"; import "../ha-combo-box-item";
interface Device {
name: string;
area: string;
id: string;
}
type ScorableDevice = ScorableTextItem & Device;
export type HaDevicePickerDeviceFilterFunc = ( export type HaDevicePickerDeviceFilterFunc = (
device: DeviceRegistryEntry device: DeviceRegistryEntry
@@ -30,35 +35,25 @@ export type HaDevicePickerDeviceFilterFunc = (
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
interface DevicePickerItem extends PickerComboBoxItem { const rowRenderer: ComboBoxLitRenderer<Device> = (item) => html`
domain?: string; <ha-combo-box-item type="button">
domain_name?: string; <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") @customElement("ha-device-picker")
export class HaDevicePicker extends LitElement { export class HaDevicePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @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 label?: string;
@property() public value?: string; @property() public value?: string;
@property() public helper?: 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. * Show only devices with entities from specific domains.
* @type {Array} * @type {Array}
@@ -97,52 +92,38 @@ export class HaDevicePicker extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public entityFilter?: HaDevicePickerEntityFilterFunc; public entityFilter?: HaDevicePickerEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean }) @property({ type: Boolean }) public disabled = false;
public hideClearIcon = 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 { @query("ha-combo-box", true) public comboBox!: HaComboBox;
super.firstUpdated(_changedProperties);
this._loadConfigEntries();
}
private async _loadConfigEntries() { private _init = false;
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 _getDevices = memoizeOne( private _getDevices = memoizeOne(
( (
haDevices: HomeAssistant["devices"], devices: DeviceRegistryEntry[],
haEntities: HomeAssistant["entities"], areas: HomeAssistant["areas"],
configEntryLookup: Record<string, ConfigEntry>, entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
excludeDevices: this["excludeDevices"] excludeDevices: this["excludeDevices"]
): DevicePickerItem[] => { ): ScorableDevice[] => {
const devices = Object.values(haDevices); if (!devices.length) {
const entities = Object.values(haEntities); return [
{
id: "no_devices",
area: "",
name: this.hass.localize("ui.components.device-picker.no_devices"),
strings: [],
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
@@ -233,158 +214,133 @@ export class HaDevicePicker extends LitElement {
); );
} }
const outputDevices = inputDevices.map<DevicePickerItem>((device) => { const outputDevices = inputDevices.map((device) => {
const deviceName = computeDeviceNameDisplay( const name = computeDeviceNameDisplay(
device, device,
this.hass, this.hass,
deviceEntityLookup[device.id] 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 { return {
id: device.id, id: device.id,
label: "", name:
primary: name ||
deviceName ||
this.hass.localize("ui.components.device-picker.unnamed_device"), this.hass.localize("ui.components.device-picker.unnamed_device"),
secondary: areaName, area:
domain: configEntry?.domain, device.area_id && areas[device.area_id]
domain_name: domainName, ? areas[device.area_id].name
search_labels: [deviceName, areaName, domain, domainName].filter( : this.hass.localize("ui.components.device-picker.no_area"),
Boolean strings: [name || ""],
) as string[],
sorting_label: deviceName || "zzz",
}; };
}); });
if (!outputDevices.length) {
return outputDevices; return [
} {
); id: "no_devices",
area: "",
private _valueRenderer = memoizeOne( name: this.hass.localize("ui.components.device-picker.no_match"),
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => { strings: [],
const deviceId = value; },
const device = this.hass.devices[deviceId]; ];
if (!device) {
return html`<span slot="headline">${deviceId}</span>`;
} }
if (outputDevices.length === 1) {
const { area } = getDeviceContext(device, this.hass); return outputDevices;
}
const deviceName = device ? computeDeviceName(device) : undefined; return outputDevices.sort((a, b) =>
const areaName = area ? computeAreaName(area) : undefined; stringCompare(a.name || "", b.name || "", this.hass.locale.language)
);
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>
`;
} }
); );
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() { public async open() {
await this.updateComplete; 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(); 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; 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 { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { ValueChangedEvent, HomeAssistant } from "../../types";
import "./ha-device-picker"; import "./ha-device-picker";
import type { import type {
HaDevicePickerDeviceFilterFunc, HaDevicePickerDeviceFilterFunc,

View File

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

View File

@@ -0,0 +1,510 @@
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">${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-value-path="id"
.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,27 @@
import { mdiPlus, mdiShape } from "@mdi/js"; import { mdiClose, mdiMenuDown, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import type { HassEntity } from "home-assistant-js-websocket"; import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_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 { 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 { getEntityContext } from "../../common/entity/context/get_entity_context";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { domainToName } from "../../data/integration"; import { debounce } from "../../common/util/debounce";
import {
isHelperDomain,
type HelperDomain,
} from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item"; import "../ha-combo-box-item";
import "../ha-generic-picker"; import "../ha-icon-button";
import type { HaGenericPicker } from "../ha-generic-picker"; import type { HaMdListItem } from "../ha-md-list-item";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon"; import "../ha-svg-icon";
import "./ha-entity-combo-box";
import type {
HaEntityComboBox,
HaEntityComboBoxEntityFilterFunc,
} from "./ha-entity-combo-box";
import "./state-badge"; 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") @customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement { export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -54,9 +36,6 @@ export class HaEntityPicker extends LitElement {
@property({ type: Boolean, attribute: "allow-custom-entity" }) @property({ type: Boolean, attribute: "allow-custom-entity" })
public allowCustomEntity; public allowCustomEntity;
@property({ type: Boolean, attribute: "show-entity-id" })
public showEntityId = false;
@property() public label?: string; @property() public label?: string;
@property() public value?: string; @property() public value?: string;
@@ -65,9 +44,6 @@ export class HaEntityPicker extends LitElement {
@property() public placeholder?: string; @property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
@property({ attribute: false, type: Array }) public createDomains?: string[]; @property({ attribute: false, type: Array }) public createDomains?: string[];
/** /**
@@ -119,32 +95,50 @@ export class HaEntityPicker extends LitElement {
public excludeEntities?: string[]; public excludeEntities?: string[];
@property({ attribute: false }) @property({ attribute: false })
public entityFilter?: HaEntityPickerEntityFilterFunc; public entityFilter?: HaEntityComboBoxEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean }) @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false; public hideClearIcon = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker; @query("#anchor") private _anchor?: HaMdListItem;
protected firstUpdated(changedProperties: PropertyValues): void { @query("#input") private _input?: HaEntityComboBox;
super.firstUpdated(changedProperties);
// Load title translations so it is available when the combo-box opens
this.hass.loadBackendTranslation("title");
}
private _valueRenderer: PickerValueRenderer = (value) => { @state() private _opened = false;
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 stateObj = this.hass.states[entityId];
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
if (!stateObj) { if (!stateObj) {
return html` return html`
<ha-svg-icon <ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
slot="start"
.path=${mdiShape}
style="margin: 0 4px"
></ha-svg-icon>
<span slot="headline">${entityId}</span> <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 +163,170 @@ export class HaEntityPicker extends LitElement {
></state-badge> ></state-badge>
<span slot="headline">${primary}</span> <span slot="headline">${primary}</span>
<span slot="supporting-text">${secondary}</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() { 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` return html`
<ha-generic-picker ${this.label ? html`<label>${this.label}</label>` : nothing}
.hass=${this.hass} <div class="container">
.disabled=${this.disabled} ${!this._opened
.autofocus=${this.autofocus} ? html`<ha-combo-box-item
.allowCustomValue=${this.allowCustomEntity} .disabled=${this.disabled}
.label=${this.label} id="anchor"
.helper=${this.helper} type="button"
.searchLabel=${this.searchLabel} compact
.notFoundLabel=${notFoundLabel} @click=${this._showPicker}
.placeholder=${placeholder} >
.value=${this.value} ${this._renderContent()}
.rowRenderer=${this._rowRenderer} </ha-combo-box-item>`
.getItems=${this._getItems} : html`<ha-entity-combo-box
.getAdditionalItems=${this._getAdditionalItems} id="input"
.hideClearIcon=${this.hideClearIcon} .hass=${this.hass}
.searchFn=${this._searchFn} .autofocus=${this.autofocus}
.valueRenderer=${this._valueRenderer} .allowCustomEntity=${this.allowCustomEntity}
@value-changed=${this._valueChanged} .label=${this.hass.localize("ui.common.search")}
> .value=${this.value}
</ha-generic-picker> .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> = ( private _renderHelper() {
search, return this.helper
filteredItems ? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
) => { : nothing;
// 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 _valueChanged(ev) { private _clear(e) {
ev.stopPropagation(); e.stopPropagation();
const value = ev.detail.value; this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
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 });
fireEvent(this, "change"); 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 { 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
.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}
item-value-path="id"
item-id-path="id"
item-label-path="label"
@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 { mdiChartLine, mdiClose, mdiMenuDown, mdiShape } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing, type PropertyValues } from "lit"; import {
import { customElement, property, query } from "lit/decorators"; css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name"; import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeEntityName } from "../../common/entity/compute_entity_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 { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl"; import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import { domainToName } from "../../data/integration"; import { domainToName } from "../../data/integration";
import { import {
getStatisticIds, getStatisticIds,
getStatisticLabel, getStatisticLabel,
type StatisticsMetaData, type StatisticsMetaData,
} from "../../data/recorder"; } from "../../data/recorder";
import type { HomeAssistant, ValueChangedEvent } from "../../types"; import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
import "../ha-combo-box-item"; import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-icon-button"; import "../ha-icon-button";
import "../ha-input-helper-text"; import type { HaMdListItem } from "../ha-md-list-item";
import type {
PickerComboBoxItem,
PickerComboBoxSearchFn,
} from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon"; 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"; import "./state-badge";
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[]; interface StatisticItem {
primary: string;
const MISSING_ID = "___missing-entity___"; secondary?: string;
iconPath?: string;
type StatisticItemType = "entity" | "external" | "no_state";
interface StatisticComboBoxItem extends PickerComboBoxItem {
statistic_id?: string;
stateObj?: HassEntity; stateObj?: HassEntity;
type?: StatisticItemType;
} }
@customElement("ha-statistic-picker") @customElement("ha-statistic-picker")
@@ -73,9 +70,6 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: false, type: Array }) @property({ attribute: false, type: Array })
public statisticIds?: StatisticsMetaData[]; public statisticIds?: StatisticsMetaData[];
@property({ attribute: false }) public helpMissingEntityUrl =
"/more-info/statistics/";
/** /**
* Show only statistics natively stored with these units of measurements. * Show only statistics natively stored with these units of measurements.
* @type {Array} * @type {Array}
@@ -120,7 +114,11 @@ export class HaStatisticPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean }) @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false; 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) { public willUpdate(changedProps: PropertyValues) {
if ( if (
@@ -135,167 +133,6 @@ export class HaStatisticPicker extends LitElement {
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes); 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( private _statisticMetaData = memoizeOne(
(statisticId: string, statisticIds: StatisticsMetaData[]) => { (statisticId: string, statisticIds: StatisticsMetaData[]) => {
if (!statisticIds) { if (!statisticIds) {
@@ -307,11 +144,26 @@ export class HaStatisticPicker extends LitElement {
} }
); );
private _valueRenderer: PickerValueRenderer = (value) => { private _renderContent() {
const statisticId = value; 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 item = this._computeItem(statisticId);
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
return html` return html`
${item.stateObj ${item.stateObj
? html` ? html`
@@ -321,19 +173,29 @@ export class HaStatisticPicker extends LitElement {
slot="start" slot="start"
></state-badge> ></state-badge>
` `
: item.icon_path : item.iconPath
? html` ? html`<ha-svg-icon
<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon> slot="start"
` .path=${item.iconPath}
></ha-svg-icon>`
: nothing} : nothing}
<span slot="headline">${item.primary}</span> <span slot="headline">${item.primary}</span>
${item.secondary ${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>` ? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing} : 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]; const stateObj = this.hass.states[statisticId];
if (stateObj) { if (stateObj) {
@@ -349,24 +211,11 @@ export class HaStatisticPicker extends LitElement {
const secondary = [areaName, entityName ? deviceName : undefined] const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean) .filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ "); .join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return { return {
id: statisticId,
statistic_id: statisticId,
primary, primary,
secondary, secondary,
stateObj: stateObj, stateObj,
type: "entity",
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
search_labels: [
entityName,
deviceName,
areaName,
friendlyName,
statisticId,
].filter(Boolean) as string[],
}; };
} }
@@ -381,143 +230,175 @@ export class HaStatisticPicker extends LitElement {
: "no_state"; : "no_state";
if (type === "external") { if (type === "external") {
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic); const label = getStatisticLabel(this.hass, statisticId, statistic);
const domain = statisticId.split(":")[0]; const domain = statisticId.split(":")[0];
const domainName = domainToName(this.hass.localize, domain); const domainName = domainToName(this.hass.localize, domain);
return { return {
id: statisticId,
statistic_id: statisticId,
primary: label, primary: label,
secondary: domainName, secondary: domainName,
type: "external", iconPath: mdiChartLine,
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, domainName, statisticId],
icon_path: mdiChartLine,
}; };
} }
} }
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
const label = getStatisticLabel(this.hass, statisticId, statistic);
return { return {
id: statisticId, primary: statisticId,
primary: label, iconPath: mdiShape,
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,
}; };
} }
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() { 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` return html`
<ha-generic-picker ${this.label ? html`<label>${this.label}</label>` : nothing}
.hass=${this.hass} <div class="container">
.autofocus=${this.autofocus} ${!this._opened
.allowCustomValue=${this.allowCustomEntity} ? html`
.label=${this.label} <ha-combo-box-item
.notFoundLabel=${notFoundLabel} .disabled=${this.disabled}
.placeholder=${placeholder} id="anchor"
.value=${this.value} type="button"
.rowRenderer=${this._rowRenderer} compact
.getItems=${this._getItems} @click=${this._showPicker}
.getAdditionalItems=${this._getAdditionalItems} >
.hideClearIcon=${this.hideClearIcon} ${this._renderContent()}
.searchFn=${this._searchFn} </ha-combo-box-item>
.valueRenderer=${this._valueRenderer} `
@value-changed=${this._valueChanged} : html`
> <ha-statistic-combo-box
</ha-generic-picker> 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> = ( private _renderHelper() {
search, return this.helper
filteredItems ? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
) => { : nothing;
// 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;
}
const [exactMatch] = filteredItems.splice(index, 1); private _clear(e) {
filteredItems.unshift(exactMatch); e.stopPropagation();
return filteredItems; this.value = undefined;
}; fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
private _valueChanged(ev: ValueChangedEvent<string>) { private async _showPicker() {
ev.stopPropagation(); if (this.disabled) {
const value = ev.detail.value;
if (value === MISSING_ID) {
window.open(
documentationUrl(this.hass, this.helpMissingEntityUrl),
"_blank"
);
return; return;
} }
this._opened = true;
this.value = value; await this.updateComplete;
fireEvent(this, "value-changed", { value }); this._input?.focus();
this._input?.open();
} }
public async open() { // Multiple calls to _openedChanged can be triggered in quick succession
await this.updateComplete; // when the menu is opened
await this._picker?.open(); 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, .name.in-dialog,
:host([secondary-line]) .name { :host([secondary-line]) .name {
line-height: var(--ha-line-height-condensed); line-height: 20px;
} }
.time-ago, .time-ago,

View File

@@ -0,0 +1,508 @@
import { mdiTextureBox } 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 { 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 { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { AreaRegistryEntry } from "../data/area_registry";
import { createAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import { HaFuse } from "../resources/fuse";
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-icon-button";
import "./ha-svg-icon";
interface AreaComboBoxItem {
// Force empty label to always display empty value by default in the search field
id: string;
label: "";
primary: string;
secondary?: string;
icon?: string;
search_labels?: string[];
sorting_label?: string;
}
const rowRenderer: ComboBoxLitRenderer<AreaComboBoxItem> = (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>`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</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-combo-box")
export class HaAreaComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only areas with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no areas with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only areas with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@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.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getItems = memoizeOne(
(
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"]
): AreaComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
let items = outputAreas
.map<AreaComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {
label: "",
id: area.area_id,
primary: areaName || area.area_id,
secondary: floorName,
icon: area.icon || undefined,
sorting_label: areaName,
search_labels: [
areaName,
floorName,
area.area_id,
...area.aliases,
].filter((v): v is string => Boolean(v)),
};
})
.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
this.hass.locale.language
)
);
if (!items.length) {
items = [
{
label: "",
id: NO_ITEMS_ID,
primary: this.hass.localize("ui.components.area-picker.no_areas"),
icon: "mdi:magnify",
},
];
}
return noAdd
? items
: [
...items,
{
label: "",
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon: "mdi:plus",
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const items = this._getItems(
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
);
this.comboBox.items = items;
this.comboBox.filteredItems = items;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="label"
.hass=${this.hass}
.helper=${this.helper}
.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-combo-box>
`;
}
private _fuseIndex = memoizeOne((items: AreaComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], items)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const items = target.items as AreaComboBoxItem[];
const filterString = ev.detail.value.trim().toLowerCase() as string;
const index = this._fuseIndex(items);
const fuse = new HaFuse(items, {}, index);
const results = fuse.multiTermsSearch(filterString);
if (results) {
if (results.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
id: NO_ITEMS_ID,
primary: this.hass.localize("ui.components.area-picker.no_match"),
icon: "mdi:magnify",
},
] as AreaComboBoxItem[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
id: ADD_NEW_SUGGESTION_ID,
primary: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
},
] as AreaComboBoxItem[];
}
} else {
target.filteredItems = results.map((result) => result.item);
}
} else {
this.comboBox.filteredItems = target.items as AreaComboBoxItem[];
}
}
private get _value() {
return this.value || "";
}
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;
}
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._getItems(
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;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-combo-box": HaAreaComboBox;
}
}

View File

@@ -1,16 +1,16 @@
import { mdiTextureBox } from "@mdi/js"; import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { HassEntity } from "home-assistant-js-websocket"; 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 { 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 { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain"; import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { stringCompare } from "../common/string/compare"; 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 { computeRTL } from "../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../data/area_registry"; import type { AreaRegistryEntry } from "../data/area_registry";
import type { import type {
@@ -19,33 +19,29 @@ import type {
} from "../data/device_registry"; } from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import { import type { FloorRegistryEntry } from "../data/floor_registry";
getFloorAreaLookup, import { getFloorAreaLookup } from "../data/floor_registry";
type FloorRegistryEntry,
} from "../data/floor_registry";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; 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-combo-box-item";
import "./ha-floor-icon"; import "./ha-floor-icon";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button"; 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-svg-icon";
import "./ha-tree-indicator"; import "./ha-tree-indicator";
const SEPARATOR = "________"; type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry;
interface FloorComboBoxItem extends PickerComboBoxItem { interface FloorAreaEntry {
type: "floor" | "area"; id: string | null;
floor?: FloorRegistryEntry; name: string;
area?: AreaRegistryEntry; icon: string | null;
} strings: string[];
interface AreaFloorValue {
id: string;
type: "floor" | "area"; type: "floor" | "area";
level: number | null;
hasFloor?: boolean;
lastArea?: boolean;
} }
@customElement("ha-area-floor-picker") @customElement("ha-area-floor-picker")
@@ -54,15 +50,12 @@ export class HaAreaFloorPicker extends LitElement {
@property() public label?: string; @property() public label?: string;
@property({ attribute: false }) public value?: AreaFloorValue; @property() public value?: string;
@property() public helper?: string; @property() public helper?: string;
@property() public placeholder?: string; @property() public placeholder?: string;
@property({ type: String, attribute: "search-label" })
public searchLabel?: string;
/** /**
* Show only areas with entities from specific domains. * Show only areas with entities from specific domains.
* @type {Array} * @type {Array}
@@ -113,53 +106,66 @@ export class HaAreaFloorPicker extends LitElement {
@property({ type: Boolean }) public required = false; @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() { public async open() {
await this.updateComplete; await this.updateComplete;
await this._picker?.open(); await this.comboBox?.open();
} }
private _valueRenderer: PickerValueRenderer = (value: string) => { public async focus() {
const item = this._parseValue(value); await this.updateComplete;
await this.comboBox?.focus();
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>
`;
}
private _rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) => {
const rtl = computeRTL(this.hass);
return html` return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon> <ha-combo-box-item
<span slot="headline">${value}</span> 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"], floors: FloorRegistryEntry[],
haAreas: HomeAssistant["areas"], areas: AreaRegistryEntry[],
haDevices: HomeAssistant["devices"], devices: DeviceRegistryEntry[],
haEntities: HomeAssistant["entities"], entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
@@ -167,11 +173,19 @@ export class HaAreaFloorPicker extends LitElement {
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"], excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"] excludeFloors: this["excludeFloors"]
): FloorComboBoxItem[] => { ): FloorAreaEntry[] => {
const floors = Object.values(haFloors); if (!areas.length && !floors.length) {
const areas = Object.values(haAreas); return [
const devices = Object.values(haDevices); {
const entities = Object.values(haEntities); id: "no_areas",
type: "area",
name: this.hass.localize("ui.components.area-picker.no_areas"),
icon: null,
strings: [],
level: null,
},
];
}
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; 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 floorAreaLookup = getFloorAreaLookup(outputAreas);
const unassisgnedAreas = Object.values(outputAreas).filter( const unassisgnedAreas = Object.values(outputAreas).filter(
(area) => !area.floor_id || !floorAreaLookup[area.floor_id] (area) => !area.floor_id || !floorAreaLookup[area.floor_id]
@@ -333,186 +360,151 @@ export class HaAreaFloorPicker extends LitElement {
return stringCompare(floorA.name, floorB.name); return stringCompare(floorA.name, floorB.name);
}); });
const items: FloorComboBoxItem[] = []; const output: FloorAreaEntry[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => { floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) { if (floor) {
const floorName = computeFloorName(floor); output.push({
id: floor.floor_id,
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" }),
type: "floor", type: "floor",
primary: floorName, name: floor.name,
floor: floor, icon: floor.icon,
search_labels: [ strings: [floor.floor_id, ...floor.aliases, floor.name],
floor.floor_id, level: floor.level,
floorName,
...floor.aliases,
...areaSearchLabels,
],
}); });
} }
items.push( output.push(
...floorAreas.map((area) => { ...floorAreas.map((area, index, array) => ({
const areaName = computeAreaName(area) || area.area_id; id: area.area_id,
return { type: "area" as const,
id: this._formatValue({ id: area.area_id, type: "area" }), name: area.name,
type: "area" as const, icon: area.icon,
primary: areaName, strings: [area.area_id, ...area.aliases, area.name],
area: area, hasFloor: true,
icon: area.icon || undefined, level: null,
search_labels: [area.area_id, areaName, ...area.aliases], lastArea: index === array.length - 1,
}; }))
})
); );
}); });
items.push( if (!output.length && !unassisgnedAreas.length) {
...unassisgnedAreas.map((area) => { output.push({
const areaName = computeAreaName(area) || area.area_id; id: "no_areas",
return { type: "area",
id: this._formatValue({ id: area.area_id, type: "area" }), name: this.hass.localize(
type: "area" as const, "ui.components.area-picker.unassigned_areas"
primary: areaName, ),
icon: area.icon || undefined, icon: null,
search_labels: [area.area_id, areaName, ...area.aliases], 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> = ( protected updated(changedProps: PropertyValues) {
item, if (
{ index }, (!this._init && this.hass) ||
combobox (this._init && changedProps.has("_opened") && this._opened)
) => { ) {
const nextItem = combobox.filteredItems?.[index + 1]; this._init = true;
const isLastArea = const areas = this._getAreas(
!nextItem || Object.values(this.hass.floors),
nextItem.type === "floor" || Object.values(this.hass.areas),
(nextItem.type === "area" && !nextItem.area?.floor_id); Object.values(this.hass.devices),
Object.values(this.hass.entities),
const rtl = computeRTL(this.hass); this.includeDomains,
this.excludeDomains,
const hasFloor = item.type === "area" && item.area?.floor_id; this.includeDeviceClasses,
this.deviceFilter,
return html` this.entityFilter,
<ha-combo-box-item this.excludeAreas,
type="button" this.excludeFloors
style=${item.type === "area" && hasFloor );
? "--md-list-item-leading-space: 48px;" this.comboBox.items = areas;
: ""} this.comboBox.filteredItems = areas;
> }
${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 render(): TemplateResult { 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` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .helper=${this.helper}
.label=${this.label} item-value-path="id"
.searchLabel=${this.searchLabel} item-id-path="id"
.notFoundLabel=${this.hass.localize( item-label-path="name"
"ui.components.area-picker.no_match" .value=${this._value}
)} .disabled=${this.disabled}
.placeholder=${placeholder} .required=${this.required}
.value=${value} .label=${this.label === undefined && this.hass
.getItems=${this._getItems} ? this.hass.localize("ui.components.area-picker.area")
.valueRenderer=${this._valueRenderer} : this.label}
.rowRenderer=${this._rowRenderer} .placeholder=${this.placeholder
@value-changed=${this._valueChanged} ? 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>) { private _filterChanged(ev: CustomEvent): void {
ev.stopPropagation(); const target = ev.target as HaComboBox;
const value = ev.detail.value; const filterString = ev.detail.value;
if (!filterString) {
if (!value) { this.comboBox.filteredItems = this.comboBox.items;
this._setValue(undefined);
return; return;
} }
const selected = this._parseValue(value); const filteredItems = fuzzyFilterSort<ScorableAreaFloorEntry>(
this._setValue(selected); filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
} }
private _setValue(value?: AreaFloorValue) { private get _value() {
this.value = value; return this.value || "";
fireEvent(this, "value-changed", { value }); }
fireEvent(this, "change");
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,35 +1,24 @@
import { mdiPlus, mdiTextureBox } from "@mdi/js"; import { mdiClose, mdiMenuDown, mdiShape, mdiTextureBox } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import type { HassEntity } from "home-assistant-js-websocket"; import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit"; import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
import { LitElement, html, nothing } from "lit"; import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { computeAreaName } from "../common/entity/compute_area_name"; import { computeAreaName } from "../common/entity/compute_area_name";
import { computeDomain } from "../common/entity/compute_domain";
import { computeFloorName } from "../common/entity/compute_floor_name"; import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context"; import { getAreaContext } from "../common/entity/context/get_area_context";
import { createAreaRegistryEntry } from "../data/area_registry"; import { debounce } from "../common/util/debounce";
import type { import type { HomeAssistant } from "../types";
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
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 type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-area-combo-box";
import type { HaAreaComboBox } from "./ha-area-combo-box";
import "./ha-combo-box";
import "./ha-combo-box-item"; import "./ha-combo-box-item";
import "./ha-generic-picker"; import type { HaComboBoxItem } from "./ha-combo-box-item";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-icon-button"; 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-svg-icon";
const ADD_NEW_ID = "___ADD_NEW___";
@customElement("ha-area-picker") @customElement("ha-area-picker")
export class HaAreaPicker extends LitElement { export class HaAreaPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@@ -87,339 +76,234 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker; @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
public async open() { @query("#anchor") private _anchor?: HaComboBoxItem;
await this.updateComplete;
await this._picker?.open();
}
// Recompute value renderer when the areas change @query("#input") private _input?: HaAreaComboBox;
private _computeValueRenderer = memoizeOne(
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
(value) => {
const area = this.hass.areas[value];
if (!area) { @state() private _opened = false;
return html`
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
<span slot="headline">${area}</span>
`;
}
const { floor } = getAreaContext(area, this.hass); private _renderContent() {
const areaId = this.value || "";
const areaName = area ? computeAreaName(area) : undefined; if (!areaId) {
const floorName = floor ? computeFloorName(floor) : undefined; return html`
<span slot="headline" class="placeholder"
const icon = area.icon; >${this.placeholder ??
this.hass.localize("ui.components.area-picker.placeholder")}</span
return html` >
${icon <ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-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}
`;
}
);
private _getAreas = memoizeOne(
(
haAreas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"],
haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"]
): PickerComboBoxItem[] => {
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 ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
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)),
};
});
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); const area = this.hass.areas[areaId];
if (searchString && !allAreas.includes(searchString.toLowerCase())) { const showClearIcon =
return [ !this.required && !this.disabled && !this.hideClearIcon;
{
id: ADD_NEW_ID + searchString, if (!area) {
primary: this.hass.localize( return html`
"ui.components.area-picker.add_new_sugestion", <ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
{ <span slot="headline">${area}</span>
name: searchString, ${showClearIcon
} ? html`<ha-icon-button
), class="clear"
icon_path: mdiPlus, slot="end"
}, @click=${this._clear}
]; .path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
} }
return [ const { floor } = getAreaContext(area, this.hass);
{
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon_path: mdiPlus,
},
];
};
protected render(): TemplateResult { const areaName = area ? computeAreaName(area) : undefined;
const placeholder = const floorName = floor ? computeFloorName(floor) : undefined;
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
const valueRenderer = this._computeValueRenderer(this.hass.areas); const icon = area.icon;
return html` return html`
<ha-generic-picker ${icon
.hass=${this.hass} ? html`<ha-icon slot="start" .icon=${icon}></ha-icon>`
.autofocus=${this.autofocus} : html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
.label=${this.label} <span slot="headline">${areaName}</span>
.notFoundLabel=${this.hass.localize( ${floorName
"ui.components.area-picker.no_match" ? html`<span slot="supporting-text">${floorName}</span>`
)} : nothing}
.placeholder=${placeholder} ${showClearIcon
.value=${this.value} ? html`<ha-icon-button
.getItems=${this._getItems} class="clear"
.getAdditionalItems=${this._getAdditionalItems} slot="end"
.valueRenderer=${valueRenderer} @click=${this._clear}
@value-changed=${this._valueChanged} .path=${mdiClose}
> ></ha-icon-button>`
</ha-generic-picker> : nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`; `;
} }
private _valueChanged(ev: ValueChangedEvent<string>) { protected render() {
ev.stopPropagation(); return html`
const value = ev.detail.value; ${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
if (!value) { ${!this._opened
this._setValue(undefined); ? html`<ha-combo-box-item
return; .disabled=${this.disabled}
} id="anchor"
type="button"
if (value.startsWith(ADD_NEW_ID)) { compact
this.hass.loadFragmentTranslation("config"); @click=${this._showPicker}
>
const suggestedName = value.substring(ADD_NEW_ID.length); ${this._renderContent()}
</ha-combo-box-item>`
showAreaRegistryDetailDialog(this, { : html`<ha-area-combo-box
suggestedName: suggestedName, id="input"
createEntry: async (values) => { .hass=${this.hass}
try { .autofocus=${this.autofocus}
const area = await createAreaRegistryEntry(this.hass, values); .label=${this.hass.localize("ui.common.search")}
this._setValue(area.area_id); .value=${this.value}
} catch (err: any) { .noAdd=${this.noAdd}
showAlertDialog(this, { .includeDomains=${this.includeDomains}
title: this.hass.localize( .excludeDomains=${this.excludeDomains}
"ui.components.area-picker.failed_create_area" .includeDeviceClasses=${this.includeDeviceClasses}
), .entityFilter=${this.entityFilter}
text: err.message, .excludeAreas=${this.excludeAreas}
}); hide-clear-icon
} @opened-changed=${this._debounceOpenedChanged}
}, @value-changed=${this._valueChanged}
}); @input=${stopPropagation}
} ></ha-area-combo-box>`}
${this._renderHelper()}
this._setValue(value); </div>
`;
} }
private _setValue(value?: string) { private _renderHelper() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
private _clear(e) {
e.stopPropagation();
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
private _valueChanged(e) {
e.stopPropagation();
const value = e.detail.value;
this.value = value; this.value = value;
fireEvent(this, "value-changed", { value }); fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); 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 { declare global {

View File

@@ -5,11 +5,8 @@ import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import { import {
type PipelineRunEvent,
runAssistPipeline, runAssistPipeline,
type AssistPipeline, type AssistPipeline,
type ConversationChatLogAssistantDelta,
type ConversationChatLogToolResultDelta,
} from "../data/assist_pipeline"; } from "../data/assist_pipeline";
import { supportsFeature } from "../common/entity/supports-feature"; import { supportsFeature } from "../common/entity/supports-feature";
import { ConversationEntityFeature } from "../data/conversation"; import { ConversationEntityFeature } from "../data/conversation";
@@ -93,7 +90,7 @@ export class HaAssistChat extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
this._audioRecorder?.close(); this._audioRecorder?.close();
this._audioRecorder = undefined; this._audioRecorder = undefined;
this._unloadAudio(); this._audio?.pause();
this._conversation = []; this._conversation = [];
this._conversationId = null; this._conversationId = null;
} }
@@ -112,24 +109,25 @@ export class HaAssistChat extends LitElement {
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech; const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
return html` return html`
<div class="messages" id="scroll-container"> ${controlHA
${controlHA ? nothing
? nothing : html`
: html` <ha-alert>
<ha-alert> ${this.hass.localize(
${this.hass.localize( "ui.dialogs.voice_command.conversation_no_control"
"ui.dialogs.voice_command.conversation_no_control" )}
)} </ha-alert>
</ha-alert> `}
`} <div class="messages">
<div class="spacer"></div> <div class="messages-container" id="scroll-container">
${this._conversation!.map( ${this._conversation!.map(
// New lines matter for messages // New lines matter for messages
// prettier-ignore // prettier-ignore
(message) => html` (message) => html`
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div> <div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
` `
)} )}
</div>
</div> </div>
<div class="input" slot="primaryAction"> <div class="input" slot="primaryAction">
<ha-textfield <ha-textfield
@@ -275,8 +273,8 @@ export class HaAssistChat extends LitElement {
} }
private async _startListening() { private async _startListening() {
this._unloadAudio();
this._processing = true; this._processing = true;
this._audio?.pause();
if (!this._audioRecorder) { if (!this._audioRecorder) {
this._audioRecorder = new AudioRecorder((audio) => { this._audioRecorder = new AudioRecorder((audio) => {
if (this._audioBuffer) { if (this._audioBuffer) {
@@ -295,36 +293,27 @@ export class HaAssistChat extends LitElement {
await this._audioRecorder.start(); await this._audioRecorder.start();
this._addMessage(userMessage); 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 { try {
const unsub = await runAssistPipeline( const unsub = await runAssistPipeline(
this.hass, this.hass,
(event: PipelineRunEvent) => { (event) => {
if (event.type === "run-start") { if (event.type === "run-start") {
this._stt_binary_handler_id = this._stt_binary_handler_id =
event.data.runner_data.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 // 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. // Send the buffer over the WS to the STT engine.
for (const buffer of this._audioBuffer) { for (const buffer of this._audioBuffer) {
this._sendAudioChunk(buffer); this._sendAudioChunk(buffer);
@@ -333,26 +322,91 @@ export class HaAssistChat extends LitElement {
} }
// Stop recording if the server is done with STT stage // 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._stt_binary_handler_id = undefined;
this._stopListening(); this._stopListening();
userMessage.text = event.data.stt_output.text; userMessage.text = event.data.stt_output.text;
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
// Add the response message placeholder to the chat when we know the STT is done // To make sure the answer is placed at the right user text, we add it before we process it
hassMessageProcesser.addMessage(); this._addMessage(hassMessage);
} else if (event.type.startsWith("intent-")) { }
hassMessageProcesser.processEvent(event);
} else if (event.type === "run-end") { 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; this._stt_binary_handler_id = undefined;
unsub(); unsub();
} else if (event.type === "error") { }
this._unloadAudio();
if (event.type === "error") {
this._stt_binary_handler_id = undefined; this._stt_binary_handler_id = undefined;
if (userMessage.text === "…") { if (userMessage.text === "…") {
userMessage.text = event.data.message; userMessage.text = event.data.message;
userMessage.error = true; userMessage.error = true;
} else { } else {
hassMessageProcesser.setError(event.data.message); hassMessage.text = event.data.message;
hassMessage.error = true;
} }
this._stopListening(); this._stopListening();
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
@@ -410,33 +464,90 @@ export class HaAssistChat extends LitElement {
this.hass.connection.socket!.send(data); 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 = () => { private _unloadAudio = () => {
if (!this._audio) { this._audio?.removeAttribute("src");
return;
}
this._audio.pause();
this._audio.removeAttribute("src");
this._audio = undefined; this._audio = undefined;
}; };
private async _processText(text: string) { private async _processText(text: string) {
this._unloadAudio();
this._processing = true; this._processing = true;
this._audio?.pause();
this._addMessage({ who: "user", text }); this._addMessage({ who: "user", text });
const hassMessageProcesser = this._createAddHassMessageProcessor(); let hassMessage = {
hassMessageProcesser.addMessage(); 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 { try {
const unsub = await runAssistPipeline( const unsub = await runAssistPipeline(
this.hass, this.hass,
(event) => { (event) => {
if (event.type.startsWith("intent-")) { if (event.type === "intent-progress") {
hassMessageProcesser.processEvent(event); 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") { 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(); unsub();
} }
if (event.type === "error") { if (event.type === "error") {
hassMessageProcesser.setError(event.data.message); hassMessage.text = event.data.message;
hassMessage.error = true;
this.requestUpdate("_conversation");
unsub(); unsub();
} }
}, },
@@ -449,126 +560,20 @@ export class HaAssistChat extends LitElement {
} }
); );
} catch { } catch {
hassMessageProcesser.setError( hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error");
this.hass.localize("ui.dialogs.voice_command.error") hassMessage.error = true;
); this.requestUpdate("_conversation");
} finally { } finally {
this._processing = false; 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") {
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` static styles = css`
:host { :host {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
ha-alert {
margin-bottom: 8px;
}
ha-textfield { ha-textfield {
display: block; display: block;
} }
@@ -576,14 +581,17 @@ export class HaAssistChat extends LitElement {
flex: 1; flex: 1;
display: block; display: block;
box-sizing: border-box; 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; overflow-y: auto;
max-height: 100%; max-height: 100%;
display: flex;
flex-direction: column;
padding: 0 12px 16px;
}
.spacer {
flex: 1;
} }
.message { .message {
white-space: pre-line; white-space: pre-line;
@@ -593,9 +601,6 @@ export class HaAssistChat extends LitElement {
padding: 8px; padding: 8px;
border-radius: 15px; border-radius: 15px;
} }
.message:last-child {
margin-bottom: 0;
}
@media all and (max-width: 450px), all and (max-height: 500px) { @media all and (max-width: 450px), all and (max-height: 500px) {
.message { .message {
@@ -614,7 +619,7 @@ export class HaAssistChat extends LitElement {
margin-left: 24px; margin-left: 24px;
margin-inline-start: 24px; margin-inline-start: 24px;
margin-inline-end: initial; margin-inline-end: initial;
align-self: flex-end; float: var(--float-end);
text-align: right; text-align: right;
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
background-color: var(--chat-background-color-user, var(--primary-color)); background-color: var(--chat-background-color-user, var(--primary-color));
@@ -626,7 +631,7 @@ export class HaAssistChat extends LitElement {
margin-right: 24px; margin-right: 24px;
margin-inline-end: 24px; margin-inline-end: 24px;
margin-inline-start: initial; margin-inline-start: initial;
align-self: flex-start; float: var(--float-start);
border-bottom-left-radius: 0px; border-bottom-left-radius: 0px;
background-color: var( background-color: var(
--chat-background-color-hass, --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-size: var(--ha-badge-font-size, var(--ha-font-size-s));
font-style: normal; font-style: normal;
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-condensed); line-height: 16px;
letter-spacing: 0.1px; letter-spacing: 0.1px;
color: var(--primary-text-color); color: var(--primary-text-color);
} }

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -235,7 +235,7 @@ export class HaFilterDevices extends LitElement {
font-size: var(--ha-font-size-xs); font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: var(--ha-line-height-normal); line-height: 16px;
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@@ -192,7 +192,7 @@ export class HaFilterDomains extends LitElement {
font-size: var(--ha-font-size-xs); font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: var(--ha-line-height-normal); line-height: 16px;
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@@ -249,7 +249,7 @@ export class HaFilterEntities extends LitElement {
font-size: var(--ha-font-size-xs); font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: var(--ha-line-height-normal); line-height: 16px;
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@@ -306,7 +306,7 @@ export class HaFilterFloorAreas extends LitElement {
font-size: var(--ha-font-size-xs); font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: var(--ha-line-height-normal); line-height: 16px;
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

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

View File

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

@@ -1,11 +1,9 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement } from "lit/decorators";
@customElement("ha-input-helper-text") @customElement("ha-input-helper-text")
class InputHelperText extends LitElement { class InputHelperText extends LitElement {
@property({ type: Boolean, reflect: true }) disabled = false;
protected render(): TemplateResult { protected render(): TemplateResult {
return html`<slot></slot>`; return html`<slot></slot>`;
} }
@@ -20,9 +18,6 @@ class InputHelperText extends LitElement {
padding-inline-start: 16px; padding-inline-start: 16px;
padding-inline-end: 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 { mdiDrag, mdiEye, mdiEyeOff } from "@mdi/js";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } 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 { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat"; import { repeat } from "lit/directives/repeat";
@@ -25,7 +25,6 @@ export interface DisplayItem {
value: string; value: string;
label: string; label: string;
description?: string; description?: string;
disableSorting?: boolean;
} }
export interface DisplayValue { export interface DisplayValue {
@@ -51,9 +50,6 @@ export class HaItemDisplayEditor extends LitElement {
@property({ type: Boolean, attribute: "show-navigation-button" }) @property({ type: Boolean, attribute: "show-navigation-button" })
public showNavigationButton = false; public showNavigationButton = false;
@property({ type: Boolean, attribute: "dont-sort-visible" })
public dontSortVisible = false;
@property({ attribute: false }) @property({ attribute: false })
public value: DisplayValue = { public value: DisplayValue = {
order: [], order: [],
@@ -64,15 +60,86 @@ export class HaItemDisplayEditor extends LitElement {
item: DisplayItem item: DisplayItem
) => TemplateResult<1> | typeof nothing; ) => TemplateResult<1> | typeof nothing;
/**
* Used to sort items by keyboard navigation.
*/
@state() private _dragIndex: number | null = null;
private _showIcon = new ResizeController(this, { private _showIcon = new ResizeController(this, {
callback: (entries) => entries[0]?.contentRect.width > 450, 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() { protected render() {
const allItems = this._allItems( const allItems = this._allItems(
this.items, this.items,
@@ -91,47 +158,30 @@ export class HaItemDisplayEditor extends LitElement {
${repeat( ${repeat(
allItems, allItems,
(item) => item.value, (item) => item.value,
(item: DisplayItem, idx) => { (item: DisplayItem, _idx) => {
const isVisible = !this.value.hidden.includes(item.value); const isVisible = !this.value.hidden.includes(item.value);
const { const { label, value, description, icon, iconPath } = item;
label,
value,
description,
icon,
iconPath,
disableSorting,
} = item;
return html` return html`
<ha-md-list-item <ha-md-list-item
type="button" type=${ifDefined(
this.showNavigationButton ? "button" : undefined
)}
@click=${this.showNavigationButton @click=${this.showNavigationButton
? this._navigate ? this._navigate
: undefined} : undefined}
.value=${value} .value=${value}
class=${classMap({ class=${classMap({
hidden: !isVisible, hidden: !isVisible,
draggable: isVisible && !disableSorting, draggable: isVisible,
"drag-selected": this._dragIndex === idx,
})} })}
@keydown=${isVisible && !disableSorting
? this._listElementKeydown
: undefined}
.idx=${idx}
> >
<span slot="headline">${label}</span> <span slot="headline">${label}</span>
${description ${description
? html`<span slot="supporting-text">${description}</span>` ? html`<span slot="supporting-text">${description}</span>`
: nothing} : nothing}
${isVisible && !disableSorting ${isVisible
? html` ? html`
<ha-svg-icon <ha-svg-icon
tabindex=${ifDefined(
this.showNavigationButton ? "0" : undefined
)}
.idx=${idx}
@keydown=${this.showNavigationButton
? this._dragHandleKeydown
: undefined}
class="handle" class="handle"
.path=${mdiDrag} .path=${mdiDrag}
slot="start" 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` static styles = css`
:host { :host {
display: block; display: block;
@@ -380,12 +256,6 @@ export class HaItemDisplayEditor extends LitElement {
--md-list-item-two-line-container-height: 48px; --md-list-item-two-line-container-height: 48px;
--md-list-item-one-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 { ha-md-list-item ha-icon-button {
margin-left: -12px; margin-left: -12px;
margin-right: -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 { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit"; import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain"; 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 { import type {
DeviceEntityDisplayLookup, DeviceEntityDisplayLookup,
DeviceRegistryEntry, DeviceRegistryEntry,
@@ -17,19 +19,30 @@ import {
createLabelRegistryEntry, createLabelRegistryEntry,
subscribeLabelRegistry, subscribeLabelRegistry,
} from "../data/label_registry"; } from "../data/label_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail"; import { showLabelDetailDialog } from "../panels/config/labels/show-dialog-label-detail";
import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker"; import "./ha-combo-box";
import type { HaGenericPicker } from "./ha-generic-picker"; import type { HaComboBox } from "./ha-combo-box";
import type { PickerComboBoxItem } from "./ha-picker-combo-box"; import "./ha-combo-box-item";
import type { PickerValueRenderer } from "./ha-picker-field"; import "./ha-icon-button";
import "./ha-svg-icon"; import "./ha-svg-icon";
type ScorableLabelItem = ScorableTextItem & LabelRegistryEntry;
const ADD_NEW_ID = "___ADD_NEW___"; 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") @customElement("ha-label-picker")
export class HaLabelPicker extends SubscribeMixin(LitElement) { export class HaLabelPicker extends SubscribeMixin(LitElement) {
@@ -88,13 +101,24 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@state() private _labels?: LabelRegistryEntry[]; @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() { public async open() {
await this.updateComplete; 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>)[] { 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( private _getLabels = memoizeOne(
( (
labels: LabelRegistryEntry[] | undefined, labels: LabelRegistryEntry[],
haAreas: HomeAssistant["areas"], areas: HomeAssistant["areas"],
haDevices: HomeAssistant["devices"], devices: DeviceRegistryEntry[],
haEntities: HomeAssistant["entities"], entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"], deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeLabels: this["excludeLabels"] excludeLabels: this["excludeLabels"]
): PickerComboBoxItem[] => { ): LabelRegistryEntry[] => {
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);
let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined; let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined;
@@ -294,7 +274,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
if (areaIds) { if (areaIds) {
areaIds.forEach((areaId) => { areaIds.forEach((areaId) => {
const area = haAreas[areaId]; const area = areas[areaId];
area.labels.forEach((label) => usedLabels.add(label)); area.labels.forEach((label) => usedLabels.add(label));
}); });
} }
@@ -311,146 +291,192 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) {
); );
} }
const items = outputLabels.map<PickerComboBoxItem>((label) => ({ if (!outputLabels.length) {
id: label.label_id, outputLabels = [
primary: label.name, {
icon: label.icon || undefined, label_id: NO_LABELS_ID,
icon_path: label.icon ? undefined : mdiLabel, name: this.hass.localize("ui.components.label-picker.no_match"),
sorting_label: label.name, icon: null,
search_labels: [label.name, label.label_id, label.description].filter( color: null,
(v): v is string => Boolean(v) 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 = () => protected updated(changedProps: PropertyValues) {
this._getLabels( if (
this._labels, (!this._init && this.hass && this._labels) ||
this.hass.areas, (this._init && changedProps.has("_opened") && this._opened)
this.hass.devices, ) {
this.hass.entities, this._init = true;
this.includeDomains, const items = this._getLabels(
this.excludeDomains, this._labels!,
this.includeDeviceClasses, this.hass.areas,
this.deviceFilter, Object.values(this.hass.devices),
this.entityFilter, Object.values(this.hass.entities),
this.excludeLabels 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[]) => { this.comboBox.items = items;
if (!labels) { this.comboBox.filteredItems = items;
return [];
} }
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 { protected render(): TemplateResult {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.label-picker.label");
const valueRenderer = this._computeValueRenderer(this._labels);
return html` return html`
<ha-generic-picker <ha-combo-box
.hass=${this.hass} .hass=${this.hass}
.autofocus=${this.autofocus} .helper=${this.helper}
.label=${this.label} item-value-path="label_id"
.notFoundLabel=${this.hass.localize( item-id-path="label_id"
"ui.components.label-picker.no_match" item-label-path="name"
)} .value=${this._value}
.placeholder=${placeholder} .disabled=${this.disabled}
.value=${this.value} .required=${this.required}
.getItems=${this._getItems} .label=${this.label === undefined && this.hass
.getAdditionalItems=${this._getAdditionalItems} ? this.hass.localize("ui.components.label-picker.label")
.valueRenderer=${valueRenderer} : this.label}
@value-changed=${this._valueChanged} .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(); ev.stopPropagation();
let newValue = ev.detail.value;
const value = ev.detail.value; if (newValue === NO_LABELS_ID) {
newValue = "";
if (value === NO_LABELS) { this.comboBox.setInputValue("");
return; return;
} }
if (!value) { if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
this._setValue(undefined); if (newValue !== this._value) {
this._setValue(newValue);
}
return; return;
} }
if (value.startsWith(ADD_NEW_ID)) { (ev.target as any).value = this._value;
this.hass.loadFragmentTranslation("config");
const suggestedName = value.substring(ADD_NEW_ID.length); this.hass.loadFragmentTranslation("config");
showLabelDetailDialog(this, { showLabelDetailDialog(this, {
suggestedName: suggestedName, entry: undefined,
createEntry: async (values) => { suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
try { createEntry: async (values) => {
const label = await createLabelRegistryEntry(this.hass, values); const label = await createLabelRegistryEntry(this.hass, values);
this._setValue(label.label_id); const labels = [...this._labels!, label];
} catch (err: any) { this.comboBox.filteredItems = this._getLabels(
showAlertDialog(this, { labels,
title: this.hass.localize( this.hass.areas!,
"ui.components.label-picker.failed_create_label" Object.values(this.hass.devices)!,
), Object.values(this.hass.entities)!,
text: err.message, this.includeDomains,
}); this.excludeDomains,
} this.includeDeviceClasses,
}, this.deviceFilter,
}); this.entityFilter,
return; 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) { private _setValue(value?: string) {

View File

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

View File

@@ -122,7 +122,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
this.hass.locale.language this.hass.locale.language
); );
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${labels?.length ${labels?.length
? html`<ha-chip-set> ? html`<ha-chip-set>
${repeat( ${repeat(
@@ -158,6 +157,9 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
.helper=${this.helper} .helper=${this.helper}
.disabled=${this.disabled} .disabled=${this.disabled}
.required=${this.required} .required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.label-picker.add_label")
: this.label}
.placeholder=${this.placeholder} .placeholder=${this.placeholder}
.excludeLabels=${this.value} .excludeLabels=${this.value}
@value-changed=${this._labelChanged} @value-changed=${this._labelChanged}
@@ -180,7 +182,12 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) {
showLabelDetailDialog(this, { showLabelDetailDialog(this, {
entry: label, entry: label,
updateEntry: async (values) => { 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; --ha-input-chip-selected-container-opacity: 0.5;
--md-input-chip-selected-outline-width: 1px; --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-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 }) public breaks = false;
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages = @property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
@@ -69,7 +66,6 @@ class HaMarkdownElement extends ReactiveElement {
return hash({ return hash({
content: this.content, content: this.content,
allowSvg: this.allowSvg, allowSvg: this.allowSvg,
allowDataUrl: this.allowDataUrl,
breaks: this.breaks, breaks: this.breaks,
}); });
} }
@@ -83,7 +79,6 @@ class HaMarkdownElement extends ReactiveElement {
}, },
{ {
allowSvg: this.allowSvg, 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-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 }) public breaks = false;
@property({ type: Boolean, attribute: "lazy-images" }) public lazyImages = @property({ type: Boolean, attribute: "lazy-images" }) public lazyImages =
@@ -26,7 +23,6 @@ export class HaMarkdown extends LitElement {
return html`<ha-markdown-element return html`<ha-markdown-element
.content=${this.content} .content=${this.content}
.allowSvg=${this.allowSvg} .allowSvg=${this.allowSvg}
.allowDataUrl=${this.allowDataUrl}
.breaks=${this.breaks} .breaks=${this.breaks}
.lazyImages=${this.lazyImages} .lazyImages=${this.lazyImages}
.cache=${this.cache} .cache=${this.cache}
@@ -77,7 +73,7 @@ export class HaMarkdown extends LitElement {
pre { pre {
padding: 16px; padding: 16px;
overflow: auto; overflow: auto;
line-height: var(--ha-line-height-condensed); line-height: 1.45;
font-family: var(--ha-font-family-code); font-family: var(--ha-font-family-code);
} }
h1, h1,

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ const createPanelNavigationItem = (hass: HomeAssistant, panel: PanelInfo) => ({
path: `/${panel.url_path}`, path: `/${panel.url_path}`,
icon: panel.icon ?? "mdi:view-dashboard", icon: panel.icon ?? "mdi:view-dashboard",
title: title:
panel.url_path === hass.sidebar.defaultPanel panel.url_path === hass.defaultPanel
? hass.localize("panel.states") ? hass.localize("panel.states")
: hass.localize(`panel.${panel.title}`) || : hass.localize(`panel.${panel.title}`) ||
panel.title || panel.title ||

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); color: var(--primary-text-color);
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed); line-height: 20px;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.option .content .text .description { .option .content .text .description {
color: var(--secondary-text-color); color: var(--secondary-text-color);
font-size: var(--ha-font-size-s); font-size: 13px;
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed); line-height: 16px;
} }
img { img {
position: relative; position: relative;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,15 @@
import { mdiRoomService } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { html, LitElement, nothing, type TemplateResult } from "lit"; import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { isValidServiceId } from "../common/entity/valid_service_id";
import type { LocalizeFunc } from "../common/translations/localize"; import type { LocalizeFunc } from "../common/translations/localize";
import { getServiceIcons } from "../data/icons";
import { domainToName } from "../data/integration"; 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-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"; import "./ha-service-icon";
import { getServiceIcons } from "../data/icons";
interface ServiceComboBoxItem extends PickerComboBoxItem {
domain_name?: string;
service_id?: string;
}
@customElement("ha-service-picker") @customElement("ha-service-picker")
class HaServicePicker extends LitElement { class HaServicePicker extends LitElement {
@@ -27,121 +17,66 @@ class HaServicePicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
@property() public label?: string;
@property() public placeholder?: string;
@property() public value?: string; @property() public value?: string;
@property({ attribute: "show-service-id", type: Boolean }) @state() private _filter?: string;
public showServiceId = false;
@query("ha-generic-picker") private _picker?: HaGenericPicker; protected willUpdate() {
if (!this.hasUpdated) {
public async open() { this.hass.loadBackendTranslation("services");
await this.updateComplete; getServiceIcons(this.hass);
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>
`;
} }
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 = () => private _rowRenderer: ComboBoxLitRenderer<{ service: string; name: string }> =
this._services(this.hass.localize, this.hass.services); (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( private _services = memoizeOne(
( (
localize: LocalizeFunc, localize: LocalizeFunc,
services: HomeAssistant["services"] services: HomeAssistant["services"]
): ServiceComboBoxItem[] => { ): {
service: string;
name: string;
}[] => {
if (!services) { if (!services) {
return []; return [];
} }
const items: ServiceComboBoxItem[] = []; const result: { service: string; name: string }[] = [];
Object.keys(services) Object.keys(services)
.sort() .sort()
@@ -149,60 +84,56 @@ class HaServicePicker extends LitElement {
const services_keys = Object.keys(services[domain]).sort(); const services_keys = Object.keys(services[domain]).sort();
for (const service of services_keys) { for (const service of services_keys) {
const serviceId = `${domain}.${service}`; result.push({
const domainName = domainToName(localize, domain); service: `${domain}.${service}`,
name: `${domainToName(localize, domain)}: ${
const name = this.hass.localize(
this.hass.localize( `component.${domain}.services.${service}.name`
`component.${domain}.services.${service}.name` ) ||
) || services[domain][service].name ||
services[domain][service].name || service
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,
}); });
} }
}); });
return items; return result;
} }
); );
private _valueChanged(ev: ValueChangedEvent<string>) { private _filteredServices = memoizeOne(
ev.stopPropagation(); (
const value = ev.detail.value; localize: LocalizeFunc,
services: HomeAssistant["services"],
filter?: string
) => {
if (!services) {
return [];
}
const processedServices = this._services(localize, services);
if (!value) { if (!filter) {
this._setValue(undefined); return processedServices;
return; }
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)) { private _filterChanged(ev: CustomEvent): void {
return; this._filter = ev.detail.value.toLowerCase();
}
this._setValue(value);
} }
private _setValue(value: string | undefined) { private _valueChanged(ev) {
this.value = value; this.value = ev.detail.value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change"); fireEvent(this, "change");
fireEvent(this, "value-changed", { value: this.value });
} }
} }

View File

@@ -1,9 +1,11 @@
import "@material/mwc-button/mwc-button";
import { import {
mdiBell, mdiBell,
mdiCalendar, mdiCalendar,
mdiCellphoneCog, mdiCellphoneCog,
mdiChartBox, mdiChartBox,
mdiClipboardList, mdiClipboardList,
mdiClose,
mdiCog, mdiCog,
mdiFormatListBulletedType, mdiFormatListBulletedType,
mdiHammer, mdiHammer,
@@ -11,10 +13,12 @@ import {
mdiMenu, mdiMenu,
mdiMenuOpen, mdiMenuOpen,
mdiPlayBoxMultiple, mdiPlayBoxMultiple,
mdiPlus,
mdiTooltipAccount, mdiTooltipAccount,
mdiViewDashboard, mdiViewDashboard,
} from "@mdi/js"; } 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 { LitElement, css, html, nothing } from "lit";
import { import {
customElement, customElement,
@@ -25,32 +29,30 @@ import {
} from "lit/decorators"; } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { storage } from "../common/decorators/storage";
import { fireEvent } from "../common/dom/fire_event"; import { fireEvent } from "../common/dom/fire_event";
import { toggleAttribute } from "../common/dom/toggle_attribute"; import { toggleAttribute } from "../common/dom/toggle_attribute";
import { stringCompare } from "../common/string/compare"; import { stringCompare } from "../common/string/compare";
import { throttle } from "../common/util/throttle"; import { throttle } from "../common/util/throttle";
import { subscribeFrontendUserData } from "../data/frontend";
import type { ActionHandlerDetail } from "../data/lovelace/action_handler"; import type { ActionHandlerDetail } from "../data/lovelace/action_handler";
import type { PersistentNotification } from "../data/persistent_notification"; import type { PersistentNotification } from "../data/persistent_notification";
import { subscribeNotifications } from "../data/persistent_notification"; import { subscribeNotifications } from "../data/persistent_notification";
import { subscribeRepairsIssueRegistry } from "../data/repairs"; import { subscribeRepairsIssueRegistry } from "../data/repairs";
import type { UpdateEntity } from "../data/update"; import type { UpdateEntity } from "../data/update";
import { updateCanInstall } from "../data/update"; import { updateCanInstall } from "../data/update";
import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar";
import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { SubscribeMixin } from "../mixins/subscribe-mixin";
import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive";
import { haStyleScrollbar } from "../resources/styles"; import { haStyleScrollbar } from "../resources/styles";
import type { HomeAssistant, PanelInfo, Route } from "../types"; import type { HomeAssistant, PanelInfo, Route } from "../types";
import "./ha-fade-in";
import "./ha-icon"; import "./ha-icon";
import "./ha-icon-button"; import "./ha-icon-button";
import "./ha-md-list"; import "./ha-md-list";
import "./ha-md-list-item"; import "./ha-md-list-item";
import type { HaMdListItem } from "./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 "./ha-svg-icon";
import "./user/ha-user-badge"; import "./user/ha-user-badge";
import { DEFAULT_PANEL } from "../data/panel";
const SHOW_AFTER_SPACER = ["config", "developer-tools"]; const SHOW_AFTER_SPACER = ["config", "developer-tools"];
@@ -65,7 +67,7 @@ const SORT_VALUE_URL_PATHS = {
config: 11, config: 11,
}; };
export const PANEL_ICONS = { const PANEL_ICONS = {
calendar: mdiCalendar, calendar: mdiCalendar,
"developer-tools": mdiHammer, "developer-tools": mdiHammer,
energy: mdiLightningBolt, energy: mdiLightningBolt,
@@ -138,12 +140,12 @@ const defaultPanelSorter = (
return stringCompare(a.title!, b.title!, language); return stringCompare(a.title!, b.title!, language);
}; };
export const computePanels = memoizeOne( const computePanels = memoizeOne(
( (
panels: HomeAssistant["panels"], panels: HomeAssistant["panels"],
defaultPanel: HomeAssistant["sidebar"]["defaultPanel"], defaultPanel: HomeAssistant["defaultPanel"],
panelsOrder: HomeAssistant["sidebar"]["panelOrder"], panelsOrder: string[],
hiddenPanels: HomeAssistant["sidebar"]["hiddenPanels"], hiddenPanels: string[],
locale: HomeAssistant["locale"] locale: HomeAssistant["locale"]
): [PanelInfo[], PanelInfo[]] => { ): [PanelInfo[], PanelInfo[]] => {
if (!panels) { if (!panels) {
@@ -190,6 +192,9 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@property({ attribute: "always-expand", type: Boolean }) @property({ attribute: "always-expand", type: Boolean })
public alwaysExpand = false; public alwaysExpand = false;
@property({ attribute: "edit-mode", type: Boolean })
public editMode = false;
@state() private _notifications?: PersistentNotification[]; @state() private _notifications?: PersistentNotification[];
@state() private _updatesCount = 0; @state() private _updatesCount = 0;
@@ -202,55 +207,38 @@ class HaSidebar extends SubscribeMixin(LitElement) {
private _recentKeydownActiveUntil = 0; 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; @query(".tooltip") private _tooltip!: HTMLDivElement;
public hassSubscribe() { public hassSubscribe(): UnsubscribeFunc[] {
return [ return this.hass.user?.is_admin
subscribeFrontendUserData( ? [
this.hass.connection, subscribeRepairsIssueRegistry(this.hass.connection!, (repairs) => {
"sidebar", this._issuesCount = repairs.issues.filter(
({ value }) => { (issue) => !issue.ignored
let panelOrder = value?.panelOrder; ).length;
let hiddenPanels = value?.hiddenPanels; }),
let defaultPanel = value?.defaultPanel; ]
: [];
// fallback to old localStorage values
if (!panelOrder) {
const storedOrder = localStorage.getItem("sidebarPanelOrder");
panelOrder = storedOrder ? JSON.parse(storedOrder) : [];
}
if (!hiddenPanels) {
const storedHidden = localStorage.getItem("sidebarHiddenPanels");
hiddenPanels = storedHidden ? JSON.parse(storedHidden) : [];
}
if (!defaultPanel) {
const storedDefault = localStorage.getItem("defaultPanel");
defaultPanel = storedDefault
? JSON.parse(storedDefault)
: DEFAULT_PANEL;
}
fireEvent(this, "hass-set-sidebar-data", {
...value,
defaultPanel: defaultPanel as string,
panelOrder: panelOrder as string[],
hiddenPanels: hiddenPanels as string[],
});
}
),
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;
}),
]
: []),
];
} }
protected render() { protected render() {
@@ -282,12 +270,13 @@ class HaSidebar extends SubscribeMixin(LitElement) {
changedProps.has("expanded") || changedProps.has("expanded") ||
changedProps.has("narrow") || changedProps.has("narrow") ||
changedProps.has("alwaysExpand") || changedProps.has("alwaysExpand") ||
changedProps.has("editMode") ||
changedProps.has("_externalConfig") || changedProps.has("_externalConfig") ||
changedProps.has("_updatesCount") || changedProps.has("_updatesCount") ||
changedProps.has("_issuesCount") || changedProps.has("_issuesCount") ||
changedProps.has("_notifications") || changedProps.has("_notifications") ||
(changedProps.has("hass") && changedProps.has("_hiddenPanels") ||
changedProps.get("hass")?.sidebar !== this.hass.sidebar) changedProps.has("_panelOrder")
) { ) {
return true; return true;
} }
@@ -306,22 +295,50 @@ class HaSidebar extends SubscribeMixin(LitElement) {
hass.localize !== oldHass.localize || hass.localize !== oldHass.localize ||
hass.locale !== oldHass.locale || hass.locale !== oldHass.locale ||
hass.states !== oldHass.states || hass.states !== oldHass.states ||
hass.sidebar !== oldHass.sidebar || hass.defaultPanel !== oldHass.defaultPanel ||
hass.connected !== oldHass.connected hass.connected !== oldHass.connected
); );
} }
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) { protected updated(changedProps) {
super.updated(changedProps); super.updated(changedProps);
if (changedProps.has("alwaysExpand")) { if (changedProps.has("alwaysExpand")) {
toggleAttribute(this, "expanded", this.alwaysExpand); toggleAttribute(this, "expanded", this.alwaysExpand);
} }
if (changedProps.has("editMode") && this.editMode) {
this._editModeActivated();
}
if (!changedProps.has("hass")) { if (!changedProps.has("hass")) {
return; return;
} }
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
this.hass &&
oldHass?.connected === false &&
this.hass.connected === true
) {
this._subscribePersistentNotifications();
}
this._calculateCounts(); this._calculateCounts();
if (!SUPPORT_SCROLL_IF_NEEDED) { if (!SUPPORT_SCROLL_IF_NEEDED) {
@@ -357,7 +374,8 @@ class HaSidebar extends SubscribeMixin(LitElement) {
class="menu" class="menu"
@action=${this._handleAction} @action=${this._handleAction}
.actionHandler=${actionHandler({ .actionHandler=${actionHandler({
hasHold: true, hasHold: !this.editMode,
disabled: this.editMode,
})} })}
> >
${!this.narrow ${!this.narrow
@@ -371,29 +389,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
></ha-icon-button> ></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>`; </div>`;
} }
private _renderAllPanels(selectedPanel: string) { private _renderAllPanels(selectedPanel: string) {
if (!this.hass.sidebar.panelOrder || !this.hass.sidebar.hiddenPanels) {
return html`
<ha-fade-in .delay=${500}
><ha-spinner size="small"></ha-spinner
></ha-fade-in>
`;
}
const [beforeSpacer, afterSpacer] = computePanels( const [beforeSpacer, afterSpacer] = computePanels(
this.hass.panels, this.hass.panels,
this.hass.sidebar.defaultPanel, this.hass.defaultPanel,
this.hass.sidebar.panelOrder, this._panelOrder,
this.hass.sidebar.hiddenPanels, this._hiddenPanels,
this.hass.locale this.hass.locale
); );
// prettier-ignore // prettier-ignore
return html` return html`
<ha-sortable .disabled=${!this.editMode} draggable-selector=".draggable" @item-moved=${this._panelMoved}>
<ha-md-list <ha-md-list
class="ha-scrollbar" class="ha-scrollbar"
@focusin=${this._listboxFocusIn} @focusin=${this._listboxFocusIn}
@@ -401,47 +416,64 @@ class HaSidebar extends SubscribeMixin(LitElement) {
@scroll=${this._listboxScroll} @scroll=${this._listboxScroll}
@keydown=${this._listboxKeydown} @keydown=${this._listboxKeydown}
> >
${this._renderPanels(beforeSpacer, selectedPanel)} ${this.editMode
? this._renderPanelsEdit(beforeSpacer, selectedPanel)
: this._renderPanels(beforeSpacer, selectedPanel)}
${this._renderSpacer()} ${this._renderSpacer()}
${this._renderPanels(afterSpacer, selectedPanel)} ${this._renderPanels(afterSpacer, selectedPanel)}
${this._renderExternalConfiguration()} ${this._renderExternalConfiguration()}
</ha-md-list> </ha-md-list>
</ha-sortable>
`; `;
} }
private _renderPanels(panels: PanelInfo[], selectedPanel: string) { private _renderPanels(
panels: PanelInfo[],
selectedPanel: string,
sortable = false
) {
return panels.map((panel) => return panels.map((panel) =>
this._renderPanel( this._renderPanel(
panel.url_path, panel.url_path,
panel.url_path === this.hass.sidebar.defaultPanel panel.url_path === this.hass.defaultPanel
? panel.title || this.hass.localize("panel.states") ? panel.title || this.hass.localize("panel.states")
: this.hass.localize(`panel.${panel.title}`) || panel.title, : this.hass.localize(`panel.${panel.title}`) || panel.title,
panel.icon, panel.icon,
panel.url_path === this.hass.sidebar.defaultPanel && !panel.icon panel.url_path === this.hass.defaultPanel && !panel.icon
? PANEL_ICONS.lovelace ? PANEL_ICONS.lovelace
: panel.url_path in PANEL_ICONS : panel.url_path in PANEL_ICONS
? PANEL_ICONS[panel.url_path] ? PANEL_ICONS[panel.url_path]
: undefined, : undefined,
selectedPanel selectedPanel,
sortable
) )
); );
} }
private _renderPanelsEdit(beforeSpacer: PanelInfo[], selectedPanel: string) {
return html`
${this._renderPanels(beforeSpacer, selectedPanel, true)}
${this._renderSpacer()}${this._renderHiddenPanels()}
`;
}
private _renderPanel( private _renderPanel(
urlPath: string, urlPath: string,
title: string | null, title: string | null,
icon: string | null | undefined, icon: string | null | undefined,
iconPath: string | null | undefined, iconPath: string | null | undefined,
selectedPanel: string selectedPanel: string,
sortable = false
) { ) {
return urlPath === "config" return urlPath === "config"
? this._renderConfiguration(title, selectedPanel) ? this._renderConfiguration(title, selectedPanel)
: html` : html`
<ha-md-list-item <ha-md-list-item
.href=${`/${urlPath}`} .href=${this.editMode ? undefined : `/${urlPath}`}
type="link" type="link"
class=${classMap({ class=${classMap({
selected: selectedPanel === urlPath, selected: selectedPanel === urlPath,
draggable: this.editMode && sortable,
})} })}
@mouseenter=${this._itemMouseEnter} @mouseenter=${this._itemMouseEnter}
@mouseleave=${this._itemMouseLeave} @mouseleave=${this._itemMouseLeave}
@@ -450,10 +482,81 @@ class HaSidebar extends SubscribeMixin(LitElement) {
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>` ? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`} : html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
<span class="item-text" slot="headline">${title}</span> <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> </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() { private _renderDivider() {
return html`<div class="divider"></div>`; return html`<div class="divider"></div>`;
} }
@@ -574,7 +677,47 @@ class HaSidebar extends SubscribeMixin(LitElement) {
return; 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) { private _itemMouseEnter(ev: MouseEvent) {
@@ -637,15 +780,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
this._tooltipHideTimeout = undefined; this._tooltipHideTimeout = undefined;
} }
const tooltip = this._tooltip; const tooltip = this._tooltip;
const allListbox = this.shadowRoot!.querySelectorAll("ha-md-list")!; const listbox = this.shadowRoot!.querySelector("ha-md-list")!;
const listbox = [...allListbox].find((lb) => lb.contains(item)); let top = item.offsetTop + 11;
if (listbox.contains(item)) {
const top = top += listbox.offsetTop;
item.offsetTop + top -= listbox.scrollTop;
11 + }
(listbox?.offsetTop ?? 0) -
(listbox?.scrollTop ?? 0);
tooltip.innerText = ( tooltip.innerText = (
item.querySelector(".item-text") as HTMLElement item.querySelector(".item-text") as HTMLElement
).innerText; ).innerText;
@@ -711,12 +851,12 @@ class HaSidebar extends SubscribeMixin(LitElement) {
); );
font-size: var(--ha-font-size-xl); font-size: var(--ha-font-size-xl);
align-items: center; align-items: center;
padding-left: calc(4px + var(--safe-area-inset-left)); padding-left: calc(4px + env(safe-area-inset-left));
padding-inline-start: calc(4px + var(--safe-area-inset-left)); padding-inline-start: calc(4px + env(safe-area-inset-left));
padding-inline-end: initial; padding-inline-end: initial;
} }
:host([expanded]) .menu { :host([expanded]) .menu {
width: calc(256px + var(--safe-area-inset-left)); width: calc(256px + env(safe-area-inset-left));
} }
.menu ha-icon-button { .menu ha-icon-button {
color: var(--sidebar-icon-color); color: var(--sidebar-icon-color);
@@ -735,29 +875,26 @@ class HaSidebar extends SubscribeMixin(LitElement) {
:host([expanded]) .title { :host([expanded]) .title {
display: initial; display: initial;
} }
:host([expanded]) .menu mwc-button {
margin: 0 8px;
}
.menu mwc-button {
width: 100%;
}
.hidden-panel { .hidden-panel {
display: none; 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 { ha-md-list {
padding: 4px 0; padding: 4px 0;
box-sizing: border-box; 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; overflow-x: hidden;
background: none; background: none;
margin-left: var(--safe-area-inset-left); margin-left: env(safe-area-inset-left);
} }
ha-md-list-item { ha-md-list-item {
@@ -777,7 +914,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
} }
:host([expanded]) ha-md-list-item { :host([expanded]) ha-md-list-item {
width: 248px; width: 248px;
width: calc(248px - var(--safe-area-inset-left)); width: calc(248px - env(safe-area-inset-left));
} }
ha-md-list-item.selected { ha-md-list-item.selected {
@@ -812,6 +949,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
} }
ha-md-list-item .item-text { ha-md-list-item .item-text {
font-family: var(--ha-font-family-body);
display: none; display: none;
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
@@ -849,7 +987,7 @@ class HaSidebar extends SubscribeMixin(LitElement) {
left: 26px; left: 26px;
border-radius: 10px; border-radius: 10px;
font-size: 0.65em; font-size: 0.65em;
line-height: var(--ha-line-height-expanded); line-height: 2;
padding: 0 4px; padding: 0 4px;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -201,6 +201,25 @@ class HaWebRtcPlayer extends LitElement {
let candidates = ""; let candidates = "";
if (this._clientConfig?.getCandidatesUpfront) {
await new Promise<void>((resolve) => {
this._peerConnection!.onicegatheringstatechange = (ev: Event) => {
const iceGatheringState = (ev.target as RTCPeerConnection)
.iceGatheringState;
if (iceGatheringState === "complete") {
this._peerConnection!.onicegatheringstatechange = null;
resolve();
}
this._logEvent("Ice gathering state changed", iceGatheringState);
};
});
if (!this._peerConnection || !this.entityid) {
return;
}
}
while (this._candidatesList.length) { while (this._candidatesList.length) {
const candidate = this._candidatesList.pop(); const candidate = this._candidatesList.pop();
if (candidate) { if (candidate) {

View File

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

View File

@@ -56,7 +56,6 @@ export interface HaMapEntity {
color: string; color: string;
label_mode?: "name" | "state" | "attribute" | "icon"; label_mode?: "name" | "state" | "attribute" | "icon";
attribute?: string; attribute?: string;
unit?: string;
name?: string; name?: string;
focus?: boolean; focus?: boolean;
} }
@@ -550,12 +549,6 @@ export class HaMap extends ReactiveElement {
typeof entity !== "string" && entity.label_mode === "icon"; typeof entity !== "string" && entity.label_mode === "icon";
entityMarker.entityId = getEntityId(entity); entityMarker.entityId = getEntityId(entity);
entityMarker.entityName = entityName; entityMarker.entityName = entityName;
entityMarker.entityUnit =
typeof entity !== "string" &&
entity.unit &&
entity.label_mode === "attribute"
? entity.unit
: "";
entityMarker.entityPicture = entityMarker.entityPicture =
entityPicture && (typeof entity === "string" || !entity.label_mode) entityPicture && (typeof entity === "string" || !entity.label_mode)
? this.hass.hassUrl(entityPicture) ? this.hass.hassUrl(entityPicture)
@@ -699,7 +692,7 @@ export class HaMap extends ReactiveElement {
} }
.marker-cluster span { .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 = `${
item.media_content_id.split("?")[0] item.media_content_id.split("?")[0]
}?${query.toString()}`; }?${query.toString()}`;
item.media_content_type = "audio/mp3";
item.can_play = true; item.can_play = true;
item.title = message; item.title = message;
fireEvent(this, "tts-picked", { item }); fireEvent(this, "tts-picked", { item });

View File

@@ -966,7 +966,7 @@ export class HaMediaPlayerBrowse extends LitElement {
} }
.breadcrumb .title { .breadcrumb .title {
font-size: var(--ha-font-size-4xl); 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); font-weight: var(--ha-font-weight-bold);
margin: 0; margin: 0;
overflow: hidden; 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 { .primary {
font-size: var(--ha-font-size-m); font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium); font-weight: var(--ha-font-weight-medium);
line-height: var(--ha-line-height-normal); line-height: 20px;
letter-spacing: 0.1px; letter-spacing: 0.1px;
color: var(--primary-text-color); color: var(--primary-text-color);
} }
.secondary { .secondary {
font-size: var(--ha-font-size-s); font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed); line-height: 16px;
letter-spacing: 0.4px; letter-spacing: 0.4px;
color: var(--primary-text-color); 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 type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { User } from "../../data/user"; import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user"; import { fetchUsers } from "../../data/user";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item"; import "../ha-select";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "./ha-user-badge"; import "./ha-user-badge";
import "../ha-list-item";
interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
@customElement("ha-user-picker")
class HaUserPicker extends LitElement { class HaUserPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; public hass?: HomeAssistant;
@property() public label?: string; @property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: false }) public noUserLabel?: string; @property({ attribute: false }) public noUserLabel?: string;
@property() public value = ""; @property() public value = "";
@@ -33,124 +24,78 @@ class HaUserPicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
protected firstUpdated(changedProps) { private _sortedUsers = memoizeOne((users?: User[]) => {
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[]) => {
if (!users) { if (!users) {
return []; return [];
} }
return users return users
.filter((user) => !user.system_generated) .filter((user) => !user.system_generated)
.map<UserComboBoxItem>((user) => ({ .sort((a, b) =>
id: user.id, stringCompare(a.name, b.name, this.hass!.locale.language)
primary: user.name, );
domain_name: user.name,
search_labels: [user.name, user.id, user.username].filter(
Boolean
) as string[],
sorting_label: user.name,
user,
}));
}); });
private _getItems = () => this._getUsers(this.users);
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.user-picker.user");
return html` return html`
<ha-generic-picker <ha-select
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label} .label=${this.label}
.notFoundLabel=${this.hass.localize( .disabled=${this.disabled}
"ui.components.user-picker.no_match"
)}
.placeholder=${placeholder}
.value=${this.value} .value=${this.value}
.getItems=${this._getItems} @selected=${this._userChanged}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
> >
</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) { protected firstUpdated(changedProps) {
const value = ev.detail.value; super.firstUpdated(changedProps);
if (this.users === undefined) {
this.value = value; fetchUsers(this.hass!).then((users) => {
fireEvent(this, "value-changed", { value }); this.users = users;
fireEvent(this, "change"); });
}
} }
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 { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-user-picker": HaUserPicker; "ha-user-picker": HaUserPicker;

View File

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

View File

@@ -1,5 +1,6 @@
import type { HomeAssistant } from "../types"; import type { HomeAssistant } from "../types";
import type { ConversationResult } from "./conversation"; import type { ConversationResult } from "./conversation";
import type { ResolvedMediaSource } from "./media_source";
import type { SpeechMetadata } from "./stt"; import type { SpeechMetadata } from "./stt";
export interface AssistPipeline { export interface AssistPipeline {
@@ -52,16 +53,10 @@ interface PipelineRunStartEvent extends PipelineEventBase {
data: { data: {
pipeline: string; pipeline: string;
language: string; language: string;
conversation_id: string;
runner_data: { runner_data: {
stt_binary_handler_id: number | null; stt_binary_handler_id: number | null;
timeout: number; timeout: number;
}; };
tts_output?: {
token: string;
url: string;
mime_type: string;
};
}; };
} }
interface PipelineRunEndEvent extends PipelineEventBase { interface PipelineRunEndEvent extends PipelineEventBase {
@@ -114,7 +109,7 @@ interface PipelineIntentStartEvent extends PipelineEventBase {
}; };
} }
export interface ConversationChatLogAssistantDelta { interface ConversationChatLogAssistantDelta {
role: "assistant"; role: "assistant";
content: string; content: string;
tool_calls: { tool_calls: {
@@ -124,7 +119,7 @@ export interface ConversationChatLogAssistantDelta {
}[]; }[];
} }
export interface ConversationChatLogToolResultDelta { interface ConversationChatLogToolResultDelta {
role: "tool_result"; role: "tool_result";
agent_id: string; agent_id: string;
tool_call_id: string; tool_call_id: string;
@@ -161,12 +156,7 @@ interface PipelineTTSStartEvent extends PipelineEventBase {
interface PipelineTTSEndEvent extends PipelineEventBase { interface PipelineTTSEndEvent extends PipelineEventBase {
type: "tts-end"; type: "tts-end";
data: { data: {
tts_output: { tts_output: ResolvedMediaSource;
media_id: string;
token: string;
url: string;
mime_type: string;
};
}; };
} }

View File

@@ -103,20 +103,12 @@ export interface BackupContentAgent {
protected: boolean; protected: boolean;
} }
export interface AddonInfo {
name: string | null;
slug: string;
version: string | null;
}
export interface BackupContent { export interface BackupContent {
backup_id: string; backup_id: string;
date: string; date: string;
name: string; name: string;
agents: Record<string, BackupContentAgent>; agents: Record<string, BackupContentAgent>;
failed_agent_ids?: string[]; failed_agent_ids?: string[];
failed_addons?: AddonInfo[];
failed_folders?: string[];
extra_metadata?: { extra_metadata?: {
"supervisor.addon_update"?: string; "supervisor.addon_update"?: string;
}; };

View File

@@ -190,6 +190,7 @@ export const fetchCameraCapabilities = async (
export interface WebRTCClientConfiguration { export interface WebRTCClientConfiguration {
configuration: RTCConfiguration; configuration: RTCConfiguration;
dataChannel?: string; dataChannel?: string;
getCandidatesUpfront: boolean;
} }
export const fetchWebRtcClientConfiguration = async ( export const fetchWebRtcClientConfiguration = async (

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