20250531.1 (#25749)

This commit is contained in:
Paul Bottein 2025-06-11 14:42:10 +02:00 committed by GitHub
commit 7e2059e836
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
535 changed files with 12575 additions and 8193 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@v2.2.0 uses: relative-ci/agent-action@v3.0.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,18 +0,0 @@
diff --git a/dist/hls.light.mjs b/dist/hls.light.mjs
index eed9d788fafdb159975e1a2eb08ac88ba9c9ac33..ace881935e6665946f1c8110ebd2f739cde4427e 100644
--- a/dist/hls.light.mjs
+++ b/dist/hls.light.mjs
@@ -20523,9 +20523,9 @@ class Hls {
}
Hls.defaultConfig = void 0;
-var KeySystemFormats = empty.KeySystemFormats;
-var KeySystems = empty.KeySystems;
-var SubtitleStreamController = empty.SubtitleStreamController;
-var TimelineController = empty.TimelineController;
+var KeySystemFormats = empty;
+var KeySystems = empty;
+var SubtitleStreamController = empty;
+var TimelineController = empty;
export { AbrController, AttrList, Cues as AudioStreamController, Cues as AudioTrackController, BasePlaylistController, BaseSegment, BaseStreamController, BufferController, Cues as CMCDController, CapLevelController, ChunkMetadata, ContentSteeringController, DateRange, Cues as EMEController, ErrorActionFlags, ErrorController, ErrorDetails, ErrorTypes, Events, FPSController, Fragment, Hls, HlsSkip, HlsUrlParameters, KeySystemFormats, KeySystems, Level, LevelDetails, LevelKey, LoadStats, MetadataSchema, NetworkErrorAction, Part, PlaylistLevelType, SubtitleStreamController, Cues as SubtitleTrackController, TimelineController, Hls as default, getMediaSource, isMSESupported, isSupported };
//# sourceMappingURL=hls.light.mjs.map

View File

@ -302,7 +302,7 @@ export class HcConnect extends LitElement {
} }
.error { .error {
color: red; color: red;
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.error a { .error a {

View File

@ -86,9 +86,9 @@ class HcLayout extends LitElement {
.card-header { .card-header {
color: var(--ha-card-header-color, var(--primary-text-color)); color: var(--ha-card-header-color, var(--primary-text-color));
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, 24px); font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em; letter-spacing: -0.012em;
line-height: 32px; line-height: var(--ha-line-height-condensed);
padding: 24px 16px 16px; padding: 24px 16px 16px;
display: block; display: block;
margin: 0; margin: 0;
@ -98,7 +98,7 @@ class HcLayout extends LitElement {
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
} }
.subtitle { .subtitle {
font-size: 14px; font-size: var(--ha-font-size-m);
color: var(--secondary-text-color); color: var(--secondary-text-color);
line-height: initial; line-height: initial;
} }
@ -113,7 +113,7 @@ class HcLayout extends LitElement {
} }
:host ::slotted(.section-header) { :host ::slotted(.section-header) {
font-weight: 500; font-weight: var(--ha-font-weight-medium);
padding: 4px 16px; padding: 4px 16px;
text-transform: uppercase; text-transform: uppercase;
} }
@ -135,7 +135,7 @@ class HcLayout extends LitElement {
.footer { .footer {
text-align: center; text-align: center;
font-size: 12px; font-size: var(--ha-font-size-s);
padding: 8px 0 24px; padding: 8px 0 24px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }

View File

@ -29,7 +29,7 @@ class HcLaunchScreen extends LitElement {
display: block; display: block;
height: 100vh; height: 100vh;
background-color: #f2f4f9; background-color: #f2f4f9;
font-size: 24px; font-size: var(--ha-font-size-2xl);
} }
.container { .container {
display: flex; display: flex;

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(env(safe-area-inset-bottom), 48px) + 46px ); margin-top: calc( 2 * max(var(--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(env(safe-area-inset-bottom), 48px) 0; margin: max(var(--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,7 +1,30 @@
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: 56px; line-height: var(--ha-line-height-condensed);
padding-bottom: 8px; padding-bottom: 8px;
} }
.subtitle { .subtitle {
font-size: 18px; font-size: var(--ha-font-size-l);
line-height: 24px; line-height: var(--ha-line-height-normal);
} }
.root { .root {
max-width: 800px; max-width: 800px;

View File

@ -34,7 +34,7 @@ class HaDemoOptions extends LitElement {
height: 64px; height: 64px;
padding: 0 16px; padding: 0 16px;
pointer-events: none; pointer-events: none;
font-size: 20px; font-size: var(--ha-font-size-xl);
} }
`, `,
]; ];

View File

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

View File

@ -150,7 +150,7 @@ export class DemoHaBarButton extends LitElement {
margin: 0; margin: 0;
} }
label { label {
font-weight: 600; font-weight: var(--ha-font-weight-bold);
} }
.custom { .custom {
--control-button-icon-color: var(--primary-color); --control-button-icon-color: var(--primary-color);

View File

@ -86,7 +86,7 @@ export class DemoHarControlNumberButtons extends LitElement {
margin: 0; margin: 0;
} }
label { label {
font-weight: 600; font-weight: var(--ha-font-weight-bold);
} }
.custom { .custom {
color: #2196f3; color: #2196f3;

View File

@ -125,7 +125,7 @@ export class DemoHaControlSelectMenu extends LitElement {
margin: 0; margin: 0;
} }
label { label {
font-weight: 600; font-weight: var(--ha-font-weight-bold);
} }
.custom { .custom {
--control-button-icon-color: var(--primary-color); --control-button-icon-color: var(--primary-color);

View File

@ -181,7 +181,7 @@ export class DemoHaControlSelect extends LitElement {
margin: 0; margin: 0;
} }
label { label {
font-weight: 600; font-weight: var(--ha-font-weight-bold);
} }
.custom { .custom {
--mdc-icon-size: 24px; --mdc-icon-size: 24px;

View File

@ -144,7 +144,7 @@ export class DemoHaBarSlider extends LitElement {
margin: 0; margin: 0;
} }
label { label {
font-weight: 600; font-weight: var(--ha-font-weight-bold);
} }
.custom { .custom {
--control-slider-color: #ffcf4c; --control-slider-color: #ffcf4c;

View File

@ -112,7 +112,7 @@ export class DemoHaControlSwitch extends LitElement {
margin: 0; margin: 0;
} }
label { label {
font-weight: 600; font-weight: var(--ha-font-weight-bold);
} }
.custom { .custom {
--control-switch-on-color: var(--green-color); --control-switch-on-color: var(--green-color);

View File

@ -105,8 +105,8 @@ export class DemoHaHsColorPicker extends LitElement {
width: 400px; width: 400px;
} }
.value { .value {
font-size: 22px; font-size: var(--ha-font-size-xl);
font-weight: bold; font-weight: var(--ha-font-weight-bold);
margin: 0 0 12px 0; margin: 0 0 12px 0;
} }
`; `;

View File

@ -123,7 +123,7 @@ export class DemoHaSelectBox extends LitElement {
margin: 0; margin: 0;
} }
label { label {
font-weight: 600; font-weight: var(--ha-font-weight-bold);
margin-bottom: 8px; margin-bottom: 8px;
display: block; display: block;
} }

View File

@ -1,6 +1,7 @@
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { html, css, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-bar"; import "../../../../src/components/ha-bar";
import "../../../../src/components/ha-card"; import "../../../../src/components/ha-card";
import "../../../../src/components/ha-spinner"; import "../../../../src/components/ha-spinner";
@ -11,29 +12,66 @@ export class DemoHaSpinner extends LitElement {
@property({ attribute: false }) hass!: HomeAssistant; @property({ attribute: false }) hass!: HomeAssistant;
protected render(): TemplateResult { protected render(): TemplateResult {
return html`<ha-card header="Basic spinner"> return html`
<div class="card-content"> ${["light", "dark"].map(
<ha-spinner></ha-spinner></div (mode) => html`
></ha-card> <div class=${mode}>
<ha-card header="Different spinner sizes"> <ha-card header="ha-badge ${mode} demo">
<div class="card-content"> <div class="card-content">
<ha-spinner size="tiny"></ha-spinner> <ha-spinner></ha-spinner>
<ha-spinner size="small"></ha-spinner> <ha-spinner size="tiny"></ha-spinner>
<ha-spinner size="medium"></ha-spinner> <ha-spinner size="small"></ha-spinner>
<ha-spinner size="large"></ha-spinner></div <ha-spinner size="medium"></ha-spinner>
></ha-card> <ha-spinner size="large"></ha-spinner>
<ha-card header="Spinner with an aria-label"> <ha-spinner aria-label="Doing something..."></ha-spinner>
<div class="card-content"> <ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner>
<ha-spinner aria-label="Doing something..."></ha-spinner> </div>
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner></div </ha-card>
></ha-card>`; </div>
`
)}
`;
}
firstUpdated(changedProps) {
super.firstUpdated(changedProps);
applyThemesOnElement(
this.shadowRoot!.querySelector(".dark"),
{
default_theme: "default",
default_dark_theme: "default",
themes: {},
darkMode: true,
theme: "default",
},
undefined,
undefined,
true
);
} }
static styles = css` static styles = css`
:host {
display: flex;
justify-content: center;
}
.dark,
.light {
display: block;
background-color: var(--primary-background-color);
padding: 0 50px;
margin: 16px;
border-radius: 8px;
}
ha-card { ha-card {
max-width: 600px;
margin: 24px auto; margin: 24px auto;
} }
.card-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
`; `;
} }

View File

@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeNumeric extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeSeconds extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeShortYear extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -106,7 +106,7 @@ export class DemoDateTimeDateTimeShort extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -106,7 +106,7 @@ export class DemoDateTimeDateTime extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -92,7 +92,7 @@ export class DemoDateTimeDate extends LitElement {
static styles = css` static styles = css`
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -106,7 +106,7 @@ export class DemoDateTimeTimeSeconds extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -106,7 +106,7 @@ export class DemoDateTimeTimeWeekday extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -106,7 +106,7 @@ export class DemoDateTimeTime extends LitElement {
margin: 12px auto; margin: 12px auto;
} }
.header { .header {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.center { .center {
text-align: center; text-align: center;

View File

@ -428,13 +428,13 @@ class HassioAddonConfig extends LitElement {
.header h2 { .header h2 {
color: var(--ha-card-header-color, var(--primary-text-color)); color: var(--ha-card-header-color, var(--primary-text-color));
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, 24px); font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em; letter-spacing: -0.012em;
line-height: 48px; line-height: var(--ha-line-height-expanded);
padding: 12px 16px 16px; padding: 12px 16px 16px;
display: block; display: block;
margin-block: 0px; margin-block: 0px;
font-weight: normal; font-weight: var(--ha-font-weight-normal);
} }
.card-actions.right { .card-actions.right {
justify-content: flex-end; justify-content: flex-end;

View File

@ -1280,12 +1280,12 @@ class HassioAddonInfo extends LitElement {
padding-left: 8px; padding-left: 8px;
padding-inline-start: 8px; padding-inline-start: 8px;
padding-inline-end: initial; padding-inline-end: initial;
font-size: 24px; font-size: var(--ha-font-size-2xl);
color: var(--ha-card-header-color, var(--primary-text-color)); color: var(--ha-card-header-color, var(--primary-text-color));
} }
.addon-version { .addon-version {
float: var(--float-end); float: var(--float-end);
font-size: 15px; font-size: var(--ha-font-size-l);
vertical-align: middle; vertical-align: middle;
} }
.errors { .errors {

View File

@ -391,7 +391,7 @@ export class HassioBackups extends LitElement {
top: -4px; top: -4px;
} }
.selected-txt { .selected-txt {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
padding-left: 16px; padding-left: 16px;
padding-inline-start: 16px; padding-inline-start: 16px;
padding-inline-end: initial; padding-inline-end: initial;
@ -401,7 +401,7 @@ export class HassioBackups extends LitElement {
margin-top: 20px; margin-top: 20px;
} }
.header-toolbar .selected-txt { .header-toolbar .selected-txt {
font-size: 16px; font-size: var(--ha-font-size-l);
} }
.header-toolbar .header-btns { .header-toolbar .header-btns {
margin-right: -12px; margin-right: -12px;

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: 1.2em; line-height: var(--ha-line-height-condensed);
} }
.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 + env(safe-area-inset-right)); right: calc(16px + var(--safe-area-inset-right));
bottom: calc(16px + env(safe-area-inset-bottom)); bottom: calc(16px + var(--safe-area-inset-bottom));
inset-inline-end: calc(16px + env(safe-area-inset-right)); inset-inline-end: calc(16px + var(--safe-area-inset-right));
inset-inline-start: initial; inset-inline-start: initial;
z-index: 1; z-index: 1;
} }

View File

@ -131,7 +131,7 @@ export class HassioUpdate extends LitElement {
} }
.update-heading { .update-heading {
font-size: var(--ha-font-size-l); font-size: var(--ha-font-size-l);
font-weight: 500; font-weight: var(--ha-font-weight-medium);
margin-bottom: 0.5em; margin-bottom: 0.5em;
color: var(--primary-text-color); color: var(--primary-text-color);
} }

View File

@ -173,7 +173,7 @@ class HassioHardwareDialog extends LitElement {
font-family: var(--ha-font-family-code); font-family: var(--ha-font-family-code);
} }
code { code {
font-size: 85%; font-size: var(--ha-font-size-s);
padding: 0.2em 0.4em; padding: 0.2em 0.4em;
} }
search-input { search-input {

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(env(safe-area-inset-bottom), 8px); padding-bottom: max(var(--safe-area-inset-bottom), 8px);
background-color: var(--mdc-theme-surface, #fff); background-color: var(--mdc-theme-surface, #fff);
} }
.warning { .warning {

View File

@ -1,3 +1,8 @@
import {
haFontFamilyBody,
haFontSmoothing,
haMozOsxFontSmoothing,
} from "../../src/resources/theme/typography.globals";
import "./hassio-main"; import "./hassio-main";
import("../../src/resources/append-ha-style"); import("../../src/resources/append-ha-style");
@ -5,10 +10,10 @@ import("../../src/resources/append-ha-style");
const styleEl = document.createElement("style"); const styleEl = document.createElement("style");
styleEl.textContent = ` styleEl.textContent = `
body { body {
font-family: Roboto, sans-serif; font-family: ${haFontFamilyBody};
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: ${haMozOsxFontSmoothing};
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: ${haFontSmoothing};
font-weight: 400; font-weight: var(--ha-font-weight-normal);
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100vh; height: 100vh;

View File

@ -340,12 +340,12 @@ class HassioIngressView extends LitElement {
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
font-size: 16px; font-size: var(--ha-font-size-l);
height: 40px; height: 40px;
padding: 0 16px; padding: 0 16px;
pointer-events: none; pointer-events: none;
background-color: var(--app-header-background-color); background-color: var(--app-header-background-color);
font-weight: 400; font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, white); color: var(--app-header-text-color, white);
border-bottom: var(--app-header-border-bottom, none); border-bottom: var(--app-header-border-bottom, none);
box-sizing: border-box; box-sizing: border-box;
@ -354,7 +354,7 @@ class HassioIngressView extends LitElement {
.main-title { .main-title {
margin: var(--margin-title); margin: var(--margin-title);
line-height: 20px; line-height: var(--ha-line-height-condensed);
flex-grow: 1; flex-grow: 1;
} }

View File

@ -14,6 +14,7 @@ export const hassioStyle = css`
margin-bottom: 8px; margin-bottom: 8px;
font-family: var(--ha-font-family-body); font-family: var(--ha-font-family-body);
-webkit-font-smoothing: var(--ha-font-smoothing); -webkit-font-smoothing: var(--ha-font-smoothing);
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
font-size: var(--ha-font-size-2xl); font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal); font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-condensed); line-height: var(--ha-line-height-condensed);

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,y?aml}": "prettier --cache --write", "*.{json,css,md,markdown,html,ya?ml}": "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.0", "@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.10", "@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2", "@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.6", "@codemirror/view": "6.36.8",
"@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,17 +89,17 @@
"@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.4", "@vaadin/combo-box": "24.7.7",
"@vaadin/vaadin-themable-mixin": "24.7.4", "@vaadin/vaadin-themable-mixin": "24.7.7",
"@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.1", "barcode-detector": "3.0.4",
"color-name": "2.0.0", "color-name": "2.0.0",
"comlink": "4.4.2", "comlink": "4.4.2",
"core-js": "3.41.0", "core-js": "3.42.0",
"cropperjs": "1.6.2", "cropperjs": "1.6.2",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"date-fns-tz": "3.2.0", "date-fns-tz": "3.2.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": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch", "hls.js": "1.6.2",
"home-assistant-js-websocket": "9.5.0", "home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.2",
"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.11", "marked": "15.0.12",
"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",
@ -131,13 +131,12 @@
"qrcode": "1.5.4", "qrcode": "1.5.4",
"roboto-fontface": "0.10.0", "roboto-fontface": "0.10.0",
"rrule": "2.8.1", "rrule": "2.8.1",
"sortablejs": "patch:sortablejs@npm%3A1.15.3#~/.yarn/patches/sortablejs-npm-1.15.3-3235a8f83b.patch", "sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2", "stacktrace-js": "2.0.2",
"superstruct": "2.0.2", "superstruct": "2.0.2",
"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",
@ -150,18 +149,18 @@
"xss": "1.0.15" "xss": "1.0.15"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.26.10", "@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.26.10", "@babel/plugin-transform-runtime": "7.27.1",
"@babel/preset-env": "7.26.9", "@babel/preset-env": "7.27.2",
"@bundle-stats/plugin-webpack-filter": "4.19.1", "@bundle-stats/plugin-webpack-filter": "4.20.1",
"@lokalise/node-api": "14.4.0", "@lokalise/node-api": "14.7.0",
"@octokit/auth-oauth-device": "7.1.5", "@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "7.2.1", "@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "21.1.1", "@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "1.0.2", "@rsdoctor/rspack-plugin": "1.1.2",
"@rspack/cli": "1.3.7", "@rspack/cli": "1.3.11",
"@rspack/core": "1.3.7", "@rspack/core": "1.3.11",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21", "@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11", "@types/chromecast-caf-sender": "1.0.11",
@ -169,8 +168,8 @@
"@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.17", "@types/leaflet": "1.9.18",
"@types/leaflet-draw": "1.0.11", "@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",
"@types/luxon": "3.6.2", "@types/luxon": "3.6.2",
@ -180,20 +179,20 @@
"@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.2", "@vitest/coverage-v8": "3.1.4",
"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.25.1", "eslint": "9.27.0",
"eslint-config-airbnb-base": "15.0.0", "eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.2", "eslint-config-prettier": "10.1.5",
"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",
"eslint-plugin-lit-a11y": "4.1.4", "eslint-plugin-lit-a11y": "4.1.4",
"eslint-plugin-unused-imports": "4.1.4", "eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "3.0.0", "eslint-plugin-wc": "3.0.1",
"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",
@ -205,7 +204,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": "15.5.1", "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",
@ -219,9 +218,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.31.0", "typescript-eslint": "8.32.1",
"vite-tsconfig-paths": "5.1.4", "vite-tsconfig-paths": "5.1.4",
"vitest": "3.1.2", "vitest": "3.1.4",
"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"
@ -233,7 +232,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.0.0", "globals": "16.1.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

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "home-assistant-frontend" name = "home-assistant-frontend"
version = "20250516.0" version = "20250531.1"
license = "Apache-2.0" license = "Apache-2.0"
license-files = ["LICENSE*"] license-files = ["LICENSE*"]
description = "The Home Assistant frontend" description = "The Home Assistant frontend"

View File

@ -93,8 +93,8 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
background-color: var(--primary-background-color, #fafafa); background-color: var(--primary-background-color, #fafafa);
} }
p { p {
font-size: 14px; font-size: var(--ha-font-size-m);
line-height: 20px; line-height: var(--ha-line-height-normal);
} }
.card-content { .card-content {
background: var( background: var(
@ -151,8 +151,8 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
margin-inline-start: initial; margin-inline-start: initial;
} }
h1 { h1 {
font-size: 28px; font-size: var(--ha-font-size-3xl);
font-weight: 400; font-weight: var(--ha-font-weight-normal);
margin-top: 16px; margin-top: 16px;
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@ -57,9 +57,9 @@ export class HaPickAuthProvider extends LitElement {
position: relative; position: relative;
z-index: 1; z-index: 1;
text-align: center; text-align: center;
font-size: 14px; font-size: var(--ha-font-size-m);
font-weight: 400; font-weight: var(--ha-font-weight-normal);
line-height: 20px; line-height: var(--ha-line-height-normal);
} }
h3:before { h3:before {
border-top: 1px solid var(--divider-color); border-top: 1px solid var(--divider-color);

View File

@ -2,7 +2,6 @@ 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,
@ -10,17 +9,10 @@ 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

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

View File

@ -1,9 +1,19 @@
// 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 = `aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz${delimiter}`; const b = `aaaaaaaaaaabcccdddeeeeeeeeeeefggghhiiiiiiiiijkllmmnnnnnoooooooooopprrrsssssstttuuuuuuuuuuvwxyyyzzzz${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;
@ -14,6 +24,7 @@ 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

@ -2,7 +2,7 @@ import type { CSSResult } from "lit";
const _extractCssVars = ( const _extractCssVars = (
cssString: string, cssString: string,
condition: (string) => boolean = () => true condition: (string: string) => boolean = () => true
) => { ) => {
const variables: Record<string, string> = {}; const variables: Record<string, string> = {};

View File

@ -0,0 +1,14 @@
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

@ -0,0 +1,72 @@
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,6 +27,7 @@ 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;
@ -48,7 +49,8 @@ export class HaChartBase extends LitElement {
@property({ attribute: "expand-legend", type: Boolean }) @property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean; public expandLegend?: boolean;
@property({ attribute: false }) public extraComponents?: any[]; // extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@state() @state()
@consume({ context: themesContext, subscribe: true }) @consume({ context: themesContext, subscribe: true })
@ -106,48 +108,49 @@ export class HaChartBase extends LitElement {
}) })
); );
// Add keyboard event listeners if (!this.options?.dataZoom) {
const handleKeyDown = (ev: KeyboardEvent) => { // Add keyboard event listeners
if ( const handleKeyDown = (ev: KeyboardEvent) => {
!this._modifierPressed && if (
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) !this._modifierPressed &&
) { ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
this._modifierPressed = true; ) {
if (!this.options?.dataZoom) { this._modifierPressed = true;
this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); if (!this.options?.dataZoom) {
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({ };
type: "takeGlobalCursor", window.addEventListener("keydown", handleKeyDown);
key: "dataZoomSelect", window.addEventListener("keyup", handleKeyUp);
dataZoomSelectActive: false, this._listeners.push(
}); () => 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() {
@ -191,16 +194,19 @@ export class HaChartBase extends LitElement {
<div class="chart"></div> <div class="chart"></div>
</div> </div>
${this._renderLegend()} ${this._renderLegend()}
${this._isZoomed <div class="chart-controls">
? html`<ha-icon-button ${this._isZoomed
class="zoom-reset" ? html`<ha-icon-button
.path=${mdiRestart} class="zoom-reset"
@click=${this._handleZoomReset} .path=${mdiRestart}
title=${this.hass.localize( @click=${this._handleZoomReset}
"ui.components.history_charts.zoom_reset" title=${this.hass.localize(
)} "ui.components.history_charts.zoom_reset"
></ha-icon-button>` )}
: nothing} ></ha-icon-button>`
: nothing}
<slot name="button"></slot>
</div>
</div> </div>
`; `;
} }
@ -210,15 +216,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) { if (!legend.show || legend.type !== "custom") {
return nothing; return nothing;
} }
const datasets = ensureArray(this.data); const datasets = ensureArray(this.data);
const items = (legend.data || const items: LegendComponentOption["data"] =
datasets legend.data ||
((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) || .map((d) => d.name ?? d.id) || []) as string[]);
[]) 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)"
@ -233,20 +239,32 @@ export class HaChartBase extends LitElement {
})} })}
> >
<ul> <ul>
${items.map((item: string, index: number) => { ${items.map((item, index) => {
if (!this.expandLegend && index >= overflowLimit) { if (!this.expandLegend && index >= overflowLimit) {
return nothing; return nothing;
} }
const dataset = datasets.find( let itemStyle: Record<string, any> = {};
(d) => d.id === item || d.name === item let name = "";
); if (typeof item === "string") {
const color = dataset?.color as string; name = item;
const borderColor = dataset?.itemStyle?.borderColor as string; const dataset = datasets.find(
(d) => d.id === item || d.name === item
);
itemStyle = {
color: dataset?.color as string,
...(dataset?.itemStyle as { borderColor?: string }),
};
} else {
name = item.name ?? "";
itemStyle = item.itemStyle ?? {};
}
const color = itemStyle?.color as string;
const borderColor = itemStyle?.borderColor as string;
return html`<li return html`<li
.name=${item} .name=${name}
@click=${this._legendClick} @click=${this._legendClick}
class=${classMap({ hidden: this._hiddenDatasets.has(item) })} class=${classMap({ hidden: this._hiddenDatasets.has(name) })}
.title=${item} .title=${name}
> >
<div <div
class="bullet" class="bullet"
@ -255,7 +273,7 @@ export class HaChartBase extends LitElement {
borderColor: borderColor || color, borderColor: borderColor || color,
})} })}
></div> ></div>
<div class="label">${item}</div> <div class="label">${name}</div>
</li>`; </li>`;
})} })}
${items.length > overflowLimit ${items.length > overflowLimit
@ -315,7 +333,9 @@ 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);
}); });
this.chart.getZr().on("dblclick", this._handleClickZoom); if (!this.options?.dataZoom) {
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) {
@ -380,9 +400,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.max && axis.min) { if (axis.min) {
this._minutesDifference = differenceInMinutes( this._minutesDifference = differenceInMinutes(
axis.max as Date, (axis.max as Date) || new Date(),
axis.min as Date axis.min as Date
); );
} }
@ -410,6 +430,12 @@ 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,
@ -424,7 +450,7 @@ export class HaChartBase extends LitElement {
iconStyle: { opacity: 0 }, iconStyle: { opacity: 0 },
}, },
...this.options, ...this.options,
legend: { show: false }, legend,
xAxis, xAxis,
}; };
@ -468,6 +494,13 @@ 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 },
@ -600,19 +633,21 @@ export class HaChartBase extends LitElement {
} }
private _getSeries() { private _getSeries() {
const series = ensureArray(this.data).filter( const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
(d) => !this._hiddenDatasets.has(String(d.name ?? d.id)) | XAXisOption
); | 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;
if (yAxis?.type === "log") { const series = ensureArray(this.data)
// set <=0 values to null so they render as gaps on a log graph .filter((d) => !this._hiddenDatasets.has(String(d.name ?? d.id)))
return series.map((d) => .map((s) => {
d.type === "line" if (s.type === "line") {
? { if (yAxis?.type === "log") {
...d, // set <=0 values to null so they render as gaps on a log graph
data: d.data?.map((v) => return {
...s,
data: s.data?.map((v) =>
Array.isArray(v) Array.isArray(v)
? [ ? [
v[0], v[0],
@ -621,10 +656,26 @@ 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;
} }
@ -725,21 +776,31 @@ export class HaChartBase extends LitElement {
height: 100%; height: 100%;
width: 100%; width: 100%;
} }
.zoom-reset { .chart-controls {
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;
padding: 12px 0 0; padding: 12px 0 0;
font-size: 12px; font-size: var(--ha-font-size-s);
color: var(--primary-text-color); color: var(--primary-text-color);
} }
.chart-legend ul { .chart-legend ul {

View File

@ -0,0 +1,299 @@
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

@ -105,10 +105,41 @@ export class HaSankeyChart extends LitElement {
private _createData = memoizeOne((data: SankeyChartData, width = 0) => { private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0); const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))]; const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
const depthMap = new Map<number, number>(); const depthMap = new Map<number, number>();
indexes.sort().forEach((index, i) => { const sections: Node[][] = [];
indexes.forEach((index, i) => {
depthMap.set(index, i); depthMap.set(index, i);
const nodesWithIndex = filteredNodes.filter((n) => n.index === index);
if (nodesWithIndex.length > 0) {
sections.push(
sections.length > 0
? nodesWithIndex.sort((a, b) => {
// sort by the order of their parents in the previous section with orphans at the end
const aParentIndex = this._findParentIndex(
a.id,
data.links,
sections
);
const bParentIndex = this._findParentIndex(
b.id,
data.links,
sections
);
if (aParentIndex === bParentIndex) {
return 0;
}
if (aParentIndex === -1) {
return 1;
}
if (bParentIndex === -1) {
return -1;
}
return aParentIndex - bParentIndex;
})
: nodesWithIndex
);
}
}); });
const links = this._processLinks(filteredNodes, data.links); const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length; const sectionWidth = width / indexes.length;
@ -117,7 +148,7 @@ export class HaSankeyChart extends LitElement {
return { return {
id: "sankey", id: "sankey",
type: "sankey", type: "sankey",
nodes: filteredNodes.map((node) => ({ nodes: sections.flat().map((node) => ({
id: node.id, id: node.id,
value: node.value, value: node.value,
itemStyle: { itemStyle: {
@ -227,6 +258,23 @@ export class HaSankeyChart extends LitElement {
return links; return links;
} }
private _findParentIndex(id: string, links: Link[], sections: Node[][]) {
const parent = links.find((l) => l.target === id)?.source;
if (!parent) {
return -1;
}
let offset = 0;
for (let i = sections.length - 1; i >= 0; i--) {
const section = sections[i];
const index = section.findIndex((n) => n.id === parent);
if (index !== -1) {
return offset + index;
}
offset += section.length;
}
return -1;
}
static styles = css` static styles = css`
:host { :host {
display: block; display: block;

View File

@ -82,6 +82,8 @@ 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
@ -227,14 +229,20 @@ export class StateHistoryChartLine extends LitElement {
minYAxis = ({ min }) => Math.min(min, this.minYAxis!); minYAxis = ({ min }) => Math.min(min, this.minYAxis!);
} }
} else if (this.logarithmicScale) { } else if (this.logarithmicScale) {
minYAxis = ({ min }) => Math.floor(min > 0 ? min * 0.95 : min * 1.05); minYAxis = ({ min }) => {
const value = min > 0 ? min * 0.95 : min * 1.05;
return Math.abs(value) < 1 ? value : Math.floor(value);
};
} }
if (typeof maxYAxis === "number") { if (typeof maxYAxis === "number") {
if (this.fitYData) { if (this.fitYData) {
maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!); maxYAxis = ({ max }) => Math.max(max, this.maxYAxis!);
} }
} else if (this.logarithmicScale) { } else if (this.logarithmicScale) {
maxYAxis = ({ max }) => Math.ceil(max > 0 ? max * 1.05 : max * 0.95); maxYAxis = ({ max }) => {
const value = max > 0 ? max * 1.05 : max * 0.95;
return Math.abs(value) < 1 ? value : Math.ceil(value);
};
} }
this._chartOptions = { this._chartOptions = {
xAxis: { xAxis: {
@ -258,35 +266,11 @@ export class StateHistoryChartLine extends LitElement {
}, },
axisLabel: { axisLabel: {
margin: 5, margin: 5,
formatter: (value: number) => { formatter: this._formatYAxisLabel,
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: {
@ -744,14 +728,41 @@ 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
if (typeof value === "number") { if (typeof value === "number") {
return Math.max(value, 0.1); return Math.max(value, Number.EPSILON);
} }
if (typeof value === "function") { if (typeof value === "function") {
return (values: any) => Math.max(value(values), 0.1); return (values: any) => Math.max(value(values), Number.EPSILON);
} }
} }
return value; return value;

View File

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

View File

@ -60,7 +60,7 @@ export class HaAssistChip extends AssistChip {
opacity: var(--ha-assist-chip-active-container-opacity); opacity: var(--ha-assist-chip-active-container-opacity);
} }
.label { .label {
font-family: Roboto, sans-serif; font-family: var(--ha-font-family-body);
} }
`, `,
]; ];

View File

@ -164,6 +164,8 @@ 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[] = [];
@ -187,6 +189,7 @@ export class HaDataTable extends LitElement {
public clearSelection(): void { public clearSelection(): void {
this._checkedRows = []; this._checkedRows = [];
this._lastSelectedRowId = null;
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
@ -194,6 +197,7 @@ 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();
} }
@ -207,6 +211,7 @@ export class HaDataTable extends LitElement {
this._checkedRows.push(id); this._checkedRows.push(id);
} }
}); });
this._lastSelectedRowId = null;
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
@ -217,6 +222,7 @@ export class HaDataTable extends LitElement {
this._checkedRows.splice(index, 1); this._checkedRows.splice(index, 1);
} }
}); });
this._lastSelectedRowId = null;
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
@ -261,6 +267,7 @@ 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,
@ -286,6 +293,7 @@ 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")) {
@ -296,9 +304,11 @@ 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 });
} }
@ -312,6 +322,14 @@ 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];
} }
@ -542,7 +560,7 @@ export class HaDataTable extends LitElement {
> >
<ha-checkbox <ha-checkbox
class="mdc-data-table__row-checkbox" class="mdc-data-table__row-checkbox"
@change=${this._handleRowCheckboxClick} @click=${this._handleRowCheckboxClicked}
.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]))}
@ -722,8 +740,10 @@ 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"
@ -732,9 +752,10 @@ export class HaDataTable extends LitElement {
> >
<ha-icon-button <ha-icon-button
.path=${mdiChevronUp} .path=${mdiChevronUp}
class=${collapsedGroups.includes(groupName) .label=${this.hass.localize(
? "collapsed" `ui.components.data-table.${collapsed ? "expand" : "collapse"}`
: ""} )}
class=${collapsed ? "collapsed" : ""}
> >
</ha-icon-button> </ha-icon-button>
${groupName === UNDEFINED_GROUP_KEY ${groupName === UNDEFINED_GROUP_KEY
@ -750,7 +771,7 @@ export class HaDataTable extends LitElement {
} }
if (appendRow) { if (appendRow) {
items.push({ append: true, content: appendRow }); items.push({ append: true, selectable: false, content: appendRow });
} }
if (hasFab) { if (hasFab) {
@ -800,23 +821,84 @@ export class HaDataTable extends LitElement {
this._checkedRows = []; this._checkedRows = [];
this._checkedRowsChanged(); this._checkedRowsChanged();
} }
this._lastSelectedRowId = null;
} }
private _handleRowCheckboxClick = (ev: Event) => { private _handleRowCheckboxClicked = (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;
if (checkbox.checked) { const groupedData = this._groupData(
if (this._checkedRows.includes(rowId)) { this._filteredData,
return; this.localizeFunc || this.hass.localize,
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
@ -858,6 +940,7 @@ 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);
} }
@ -894,11 +977,13 @@ 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 });
} }
@ -916,6 +1001,7 @@ 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 });
} }
@ -928,12 +1014,12 @@ export class HaDataTable extends LitElement {
height: 100%; height: 100%;
} }
.mdc-data-table__content { .mdc-data-table__content {
font-family: Roboto, sans-serif; font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem; line-height: var(--ha-line-height-condensed);
font-weight: 400; font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em; letter-spacing: 0.0178571429em;
text-decoration: inherit; text-decoration: inherit;
text-transform: inherit; text-transform: inherit;
@ -1048,12 +1134,12 @@ export class HaDataTable extends LitElement {
} }
.mdc-data-table__cell { .mdc-data-table__cell {
font-family: Roboto, sans-serif; font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.25rem; line-height: var(--ha-line-height-condensed);
font-weight: 400; font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.0178571429em; letter-spacing: 0.0178571429em;
text-decoration: inherit; text-decoration: inherit;
text-transform: inherit; text-transform: inherit;
@ -1170,12 +1256,12 @@ export class HaDataTable extends LitElement {
} }
.mdc-data-table__header-cell { .mdc-data-table__header-cell {
font-family: Roboto, sans-serif; font-family: var(--ha-font-family-body);
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: var(--ha-font-smoothing);
font-size: 0.875rem; font-size: var(--ha-font-size-s);
line-height: 1.375rem; line-height: var(--ha-line-height-normal);
font-weight: 500; font-weight: var(--ha-font-weight-medium);
letter-spacing: 0.0071428571em; letter-spacing: 0.0071428571em;
text-decoration: inherit; text-decoration: inherit;
text-transform: inherit; text-transform: inherit;
@ -1199,7 +1285,7 @@ export class HaDataTable extends LitElement {
padding-inline-start: 12px; padding-inline-start: 12px;
padding-inline-end: initial; padding-inline-end: initial;
width: 100%; width: 100%;
font-weight: 500; font-weight: var(--ha-font-weight-medium);
display: flex; display: flex;
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;

View File

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

View File

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

View File

@ -1,34 +1,45 @@
import { mdiClose, mdiMenuDown, mdiShape } from "@mdi/js"; import { mdiPlus, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { import type { HassEntity } from "home-assistant-js-websocket";
css, import { html, LitElement, nothing, type PropertyValues } from "lit";
html, import { customElement, property, query } from "lit/decorators";
LitElement, import memoizeOne from "memoize-one";
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
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 { debounce } from "../../common/util/debounce"; import { domainToName } from "../../data/integration";
import {
isHelperDomain,
type HelperDomain,
} from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-combo-box-item"; import "../ha-combo-box-item";
import "../ha-icon-button"; import "../ha-generic-picker";
import type { HaMdListItem } from "../ha-md-list-item"; import type { HaGenericPicker } from "../ha-generic-picker";
import "../ha-svg-icon";
import "./ha-entity-combo-box";
import type { import type {
HaEntityComboBox, PickerComboBoxItem,
HaEntityComboBoxEntityFilterFunc, PickerComboBoxSearchFn,
} from "./ha-entity-combo-box"; } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-svg-icon";
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;
@ -43,6 +54,9 @@ 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;
@ -51,6 +65,9 @@ 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[];
/** /**
@ -102,16 +119,12 @@ export class HaEntityPicker extends LitElement {
public excludeEntities?: string[]; public excludeEntities?: string[];
@property({ attribute: false }) @property({ attribute: false })
public entityFilter?: HaEntityComboBoxEntityFilterFunc; public entityFilter?: HaEntityPickerEntityFilterFunc;
@property({ attribute: "hide-clear-icon", type: Boolean }) @property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false; public hideClearIcon = false;
@query("#anchor") private _anchor?: HaMdListItem; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("#input") private _input?: HaEntityComboBox;
@state() private _opened = false;
protected firstUpdated(changedProperties: PropertyValues): void { protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties); super.firstUpdated(changedProperties);
@ -119,39 +132,19 @@ export class HaEntityPicker extends LitElement {
this.hass.loadBackendTranslation("title"); this.hass.loadBackendTranslation("title");
} }
private _renderContent() { private _valueRenderer: PickerValueRenderer = (value) => {
const entityId = this.value || ""; const entityId = 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 slot="start" .path=${mdiShape}></ha-svg-icon> <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>
`; `;
} }
@ -176,169 +169,309 @@ 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;
} }
protected render() { private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this._showEntityId;
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing} <ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
<div class="container"> ${item.icon_path
${!this._opened ? html`
? html`<ha-combo-box-item <ha-svg-icon
.disabled=${this.disabled} slot="start"
id="anchor" style="margin: 0 4px"
type="button" .path=${item.icon_path}
compact ></ha-svg-icon>
@click=${this._showPicker} `
> : html`
${this._renderContent()} <state-badge
</ha-combo-box-item>` slot="start"
: html`<ha-entity-combo-box .stateObj=${item.stateObj}
id="input" .hass=${this.hass}
.hass=${this.hass} ></state-badge>
.autofocus=${this.autofocus} `}
.allowCustomEntity=${this.allowCustomEntity} <span slot="headline">${item.primary}</span>
.label=${this.hass.localize("ui.common.search")} ${item.secondary
.value=${this.value} ? html`<span slot="supporting-text">${item.secondary}</span>`
.createDomains=${this.createDomains} : nothing}
.includeDomains=${this.includeDomains} ${item.stateObj && showEntityId
.excludeDomains=${this.excludeDomains} ? html`
.includeDeviceClasses=${this.includeDeviceClasses} <span slot="supporting-text" class="code">
.includeUnitOfMeasurement=${this.includeUnitOfMeasurement} ${item.stateObj.entity_id}
.includeEntities=${this.includeEntities} </span>
.excludeEntities=${this.excludeEntities} `
.entityFilter=${this.entityFilter} : nothing}
hide-clear-icon ${item.domain_name && !showEntityId
@opened-changed=${this._debounceOpenedChanged} ? html`
@input=${stopPropagation} <div slot="trailing-supporting-text" class="domain">
></ha-entity-combo-box>`} ${item.domain_name}
${this._renderHelper()} </div>
</div> `
: nothing}
</ha-combo-box-item>
`; `;
} };
private _renderHelper() { private _getAdditionalItems = () =>
return this.helper this._getCreateItems(this.hass.localize, this.createDomains);
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
private _clear(e) { private _getCreateItems = memoizeOne(
e.stopPropagation(); (
this.value = undefined; localize: this["hass"]["localize"],
fireEvent(this, "value-changed", { value: undefined }); createDomains: this["createDomains"]
fireEvent(this, "change"); ) => {
} if (!createDomains?.length) {
return [];
}
private async _showPicker() { return createDomains.map((domain) => {
if (this.disabled) { const primary = localize(
return; "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;
});
} }
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) { private _getItems = () =>
const opened = ev.detail.value; this._getEntities(
if (this._opened && !opened) { this.hass,
this._opened = false; this.includeDomains,
await this.updateComplete; this.excludeDomains,
this._anchor?.focus(); this.entityFilter,
this.includeDeviceClasses,
this.includeUnitOfMeasurement,
this.includeEntities,
this.excludeEntities
);
private _getEntities = memoizeOne(
(
hass: this["hass"],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
entityFilter: this["entityFilter"],
includeDeviceClasses: this["includeDeviceClasses"],
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
includeEntities: this["includeEntities"],
excludeEntities: this["excludeEntities"]
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
let entityIds = Object.keys(hass.states);
if (includeEntities) {
entityIds = entityIds.filter((entityId) =>
includeEntities.includes(entityId)
);
}
if (excludeEntities) {
entityIds = entityIds.filter(
(entityId) => !excludeEntities.includes(entityId)
);
}
if (includeDomains) {
entityIds = entityIds.filter((eid) =>
includeDomains.includes(computeDomain(eid))
);
}
if (excludeDomains) {
entityIds = entityIds.filter(
(eid) => !excludeDomains.includes(computeDomain(eid))
);
}
const isRTL = computeRTL(this.hass);
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass!.states[entityId];
const { area, device } = getEntityContext(stateObj, hass);
const friendlyName = computeStateName(stateObj); // Keep this for search
const entityName = computeEntityName(stateObj, hass);
const deviceName = device ? computeDeviceName(device) : undefined;
const areaName = area ? computeAreaName(area) : undefined;
const domainName = domainToName(
this.hass.localize,
computeDomain(entityId)
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return {
id: entityId,
primary: primary,
secondary: secondary,
domain_name: domainName,
sorting_label: [deviceName, entityName].filter(Boolean).join("_"),
search_labels: [
entityName,
deviceName,
areaName,
domainName,
friendlyName,
entityId,
].filter(Boolean) as string[],
a11y_label: a11yLabel,
stateObj: stateObj,
};
});
if (includeDeviceClasses) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.device_class &&
includeDeviceClasses.includes(
item.stateObj.attributes.device_class
))
);
}
if (includeUnitOfMeasurement) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj?.attributes.unit_of_measurement &&
includeUnitOfMeasurement.includes(
item.stateObj.attributes.unit_of_measurement
))
);
}
if (entityFilter) {
items = items.filter(
(item) =>
// We always want to include the entity of the current value
item.id === this.value ||
(item.stateObj && entityFilter!(item.stateObj))
);
}
return items;
} }
);
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.entity.entity-picker.no_match"
);
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.helper=${this.helper}
.searchLabel=${this.searchLabel}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`;
} }
static get styles(): CSSResultGroup { private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
return [ search,
css` filteredItems
mwc-menu-surface { ) => {
--mdc-menu-min-width: 100%; // If there is exact match for entity id, put it first
} const index = filteredItems.findIndex(
.container { (item) => item.stateObj?.entity_id === search
position: relative; );
display: block; if (index === -1) {
} return filteredItems;
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 */ const [exactMatch] = filteredItems.splice(index, 1);
ha-combo-box-item:after { filteredItems.unshift(exactMatch);
display: block; return filteredItems;
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 { public async open() {
height: 2px; await this.updateComplete;
background-color: var(--mdc-theme-primary); await this._picker?.open();
} }
ha-combo-box-item ha-svg-icon[slot="start"] { private _valueChanged(ev) {
margin: 0 4px; ev.stopPropagation();
} const value = ev.detail.value;
.clear {
margin: 0 -8px; if (!value) {
--mdc-icon-button-size: 32px; this._setValue(undefined);
--mdc-icon-size: 20px; return;
} }
.edit {
--mdc-icon-size: 20px; if (value.startsWith(CREATE_ID)) {
width: 32px; const domain = value.substring(CREATE_ID.length);
}
label { showHelperDetailDialog(this, {
display: block; domain,
margin: 0 0 8px; dialogClosedCallback: (item) => {
} if (item.entityId) this._setValue(item.entityId);
.placeholder { },
color: var(--secondary-text-color); });
padding: 0 8px; return;
} }
`,
]; if (!isValidEntityId(value)) {
return;
}
this._setValue(value);
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
} }
} }

View File

@ -267,7 +267,7 @@ export class HaStateLabelBadge extends LitElement {
cursor: pointer; cursor: pointer;
} }
.big { .big {
font-size: 70%; font-size: var(--ha-font-size-xs);
} }
ha-label-badge { ha-label-badge {
--ha-label-badge-color: var(--label-badge-red); --ha-label-badge-color: var(--label-badge-red);

View File

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

View File

@ -1,45 +1,48 @@
import { mdiChartLine, mdiClose, mdiMenuDown, mdiShape } from "@mdi/js"; import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; 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 { import { html, LitElement, nothing, type PropertyValues } from "lit";
css, import { customElement, property, query } from "lit/decorators";
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 } from "../../types"; import type { HomeAssistant, ValueChangedEvent } 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 type { HaMdListItem } from "../ha-md-list-item"; import "../ha-input-helper-text";
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";
interface StatisticItem { const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
primary: string;
secondary?: string; const MISSING_ID = "___missing-entity___";
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")
@ -70,6 +73,9 @@ 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}
@ -114,11 +120,7 @@ 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("#anchor") private _anchor?: HaMdListItem; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@query("#input") private _input?: HaEntityComboBox;
@state() private _opened = false;
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
if ( if (
@ -133,6 +135,167 @@ 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) {
@ -144,26 +307,11 @@ export class HaStatisticPicker extends LitElement {
} }
); );
private _renderContent() { private _valueRenderer: PickerValueRenderer = (value) => {
const statisticId = this.value || ""; const statisticId = 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`
@ -173,29 +321,19 @@ export class HaStatisticPicker extends LitElement {
slot="start" slot="start"
></state-badge> ></state-badge>
` `
: item.iconPath : item.icon_path
? html`<ha-svg-icon ? html`
slot="start" <ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
.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): StatisticItem { private _computeItem(statisticId: string): StatisticComboBoxItem {
const stateObj = this.hass.states[statisticId]; const stateObj = this.hass.states[statisticId];
if (stateObj) { if (stateObj) {
@ -211,11 +349,24 @@ 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[],
}; };
} }
@ -230,175 +381,143 @@ 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,
iconPath: mdiChartLine, type: "external",
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 {
primary: statisticId, id: statisticId,
iconPath: mdiShape, primary: label,
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
type: "no_state",
sorting_label: [sortingPrefix, label].join("_"),
search_labels: [label, statisticId],
icon_path: mdiShape,
}; };
} }
protected render() { private _rowRenderer: ComboBoxLitRenderer<StatisticComboBoxItem> = (
item,
{ index }
) => {
const showEntityId = this.hass.userData?.showEntityIdPicker;
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing} <ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
<div class="container"> ${item.icon_path
${!this._opened
? html` ? html`
<ha-combo-box-item <ha-svg-icon
.disabled=${this.disabled} style="margin: 0 4px"
id="anchor" slot="start"
type="button" .path=${item.icon_path}
compact ></ha-svg-icon>
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>
` `
: html` : item.stateObj
<ha-statistic-combo-box ? html`
id="input" <state-badge
.hass=${this.hass} slot="start"
.autofocus=${this.autofocus} .stateObj=${item.stateObj}
.allowCustomEntity=${this.allowCustomEntity} .hass=${this.hass}
.label=${this.hass.localize("ui.common.search")} ></state-badge>
.value=${this.value} `
.includeStatisticsUnitOfMeasurement=${this : nothing}
.includeStatisticsUnitOfMeasurement} <span slot="headline">${item.primary} </span>
.includeUnitClass=${this.includeUnitClass} ${item.secondary || item.type
.includeDeviceClass=${this.includeDeviceClass} ? html`<span slot="supporting-text"
.statisticTypes=${this.statisticTypes} >${item.secondary} - ${item.type}</span
.statisticIds=${this.statisticIds} >`
.excludeStatistics=${this.excludeStatistics} : nothing}
hide-clear-icon ${item.statistic_id && showEntityId
@opened-changed=${this._debounceOpenedChanged} ? html`<span slot="supporting-text" class="code">
@input=${stopPropagation} ${item.statistic_id}
></ha-statistic-combo-box> </span>`
`} : nothing}
${this._renderHelper()} </ha-combo-box-item>
</div> `;
};
protected render() {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.statistic-picker.placeholder");
const notFoundLabel = this.hass.localize(
"ui.components.statistic-picker.no_match"
);
return html`
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.label=${this.label}
.notFoundLabel=${notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`; `;
} }
private _renderHelper() { private _searchFn: PickerComboBoxSearchFn<StatisticComboBoxItem> = (
return this.helper search,
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>` filteredItems
: 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;
}
private _clear(e) { const [exactMatch] = filteredItems.splice(index, 1);
e.stopPropagation(); filteredItems.unshift(exactMatch);
this.value = undefined; return filteredItems;
fireEvent(this, "value-changed", { value: undefined }); };
fireEvent(this, "change");
}
private async _showPicker() { private _valueChanged(ev: ValueChangedEvent<string>) {
if (this.disabled) { ev.stopPropagation();
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;
fireEvent(this, "value-changed", { value });
}
public async open() {
await this.updateComplete; await this.updateComplete;
this._input?.focus(); await this._picker?.open();
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`
.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: 20px; line-height: var(--ha-line-height-condensed);
} }
.time-ago, .time-ago,

View File

@ -129,7 +129,7 @@ class HaAlert extends LitElement {
} }
.title { .title {
margin-top: 2px; margin-top: 2px;
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.action mwc-button, .action mwc-button,
.action ha-icon-button { .action ha-icon-button {

View File

@ -56,7 +56,7 @@ export class HaAnsiToHtml extends LitElement {
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.bold { .bold {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.italic { .italic {
font-style: italic; font-style: italic;

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 { PropertyValues, TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, html, nothing } from "lit"; import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query } 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,29 +19,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 { getFloorAreaLookup } from "../data/floor_registry"; getFloorAreaLookup,
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";
type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry; const SEPARATOR = "________";
interface FloorAreaEntry { interface FloorComboBoxItem extends PickerComboBoxItem {
id: string | null; type: "floor" | "area";
name: string; floor?: FloorRegistryEntry;
icon: string | null; area?: AreaRegistryEntry;
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")
@ -50,12 +54,15 @@ export class HaAreaFloorPicker extends LitElement {
@property() public label?: string; @property() public label?: string;
@property() public value?: string; @property({ attribute: false }) public value?: AreaFloorValue;
@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}
@ -106,66 +113,53 @@ export class HaAreaFloorPicker extends LitElement {
@property({ type: Boolean }) public required = false; @property({ type: Boolean }) public required = false;
@state() private _opened?: boolean; @query("ha-generic-picker") private _picker?: HaGenericPicker;
@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.comboBox?.open(); await this._picker?.open();
} }
public async focus() { private _valueRenderer: PickerValueRenderer = (value: string) => {
await this.updateComplete; const item = this._parseValue(value);
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-combo-box-item <ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
type="button" <span slot="headline">${value}</span>
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 _getAreas = memoizeOne( private _getAreasAndFloors = memoizeOne(
( (
floors: FloorRegistryEntry[], haFloors: HomeAssistant["floors"],
areas: AreaRegistryEntry[], haAreas: HomeAssistant["areas"],
devices: DeviceRegistryEntry[], haDevices: HomeAssistant["devices"],
entities: EntityRegistryDisplayEntry[], haEntities: HomeAssistant["entities"],
includeDomains: this["includeDomains"], includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"], excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"], includeDeviceClasses: this["includeDeviceClasses"],
@ -173,19 +167,11 @@ export class HaAreaFloorPicker extends LitElement {
entityFilter: this["entityFilter"], entityFilter: this["entityFilter"],
excludeAreas: this["excludeAreas"], excludeAreas: this["excludeAreas"],
excludeFloors: this["excludeFloors"] excludeFloors: this["excludeFloors"]
): FloorAreaEntry[] => { ): FloorComboBoxItem[] => {
if (!areas.length && !floors.length) { const floors = Object.values(haFloors);
return [ const areas = Object.values(haAreas);
{ const devices = Object.values(haDevices);
id: "no_areas", const entities = Object.values(haEntities);
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;
@ -326,19 +312,6 @@ 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]
@ -360,151 +333,186 @@ export class HaAreaFloorPicker extends LitElement {
return stringCompare(floorA.name, floorB.name); return stringCompare(floorA.name, floorB.name);
}); });
const output: FloorAreaEntry[] = []; const items: FloorComboBoxItem[] = [];
floorAreaEntries.forEach(([floor, floorAreas]) => { floorAreaEntries.forEach(([floor, floorAreas]) => {
if (floor) { if (floor) {
output.push({ const floorName = computeFloorName(floor);
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",
name: floor.name, primary: floorName,
icon: floor.icon, floor: floor,
strings: [floor.floor_id, ...floor.aliases, floor.name], search_labels: [
level: floor.level, floor.floor_id,
floorName,
...floor.aliases,
...areaSearchLabels,
],
}); });
} }
output.push( items.push(
...floorAreas.map((area, index, array) => ({ ...floorAreas.map((area) => {
id: area.area_id, const areaName = computeAreaName(area) || area.area_id;
type: "area" as const, return {
name: area.name, id: this._formatValue({ id: area.area_id, type: "area" }),
icon: area.icon, type: "area" as const,
strings: [area.area_id, ...area.aliases, area.name], primary: areaName,
hasFloor: true, area: area,
level: null, icon: area.icon || undefined,
lastArea: index === array.length - 1, search_labels: [area.area_id, areaName, ...area.aliases],
})) };
})
); );
}); });
if (!output.length && !unassisgnedAreas.length) { items.push(
output.push({ ...unassisgnedAreas.map((area) => {
id: "no_areas", const areaName = computeAreaName(area) || area.area_id;
type: "area", return {
name: this.hass.localize( id: this._formatValue({ id: area.area_id, type: "area" }),
"ui.components.area-picker.unassigned_areas" type: "area" as const,
), primary: areaName,
icon: null, icon: area.icon || undefined,
strings: [], search_labels: [area.area_id, areaName, ...area.aliases],
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 output; return items;
} }
); );
protected updated(changedProps: PropertyValues) { private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
if ( item,
(!this._init && this.hass) || { index },
(this._init && changedProps.has("_opened") && this._opened) combobox
) { ) => {
this._init = true; const nextItem = combobox.filteredItems?.[index + 1];
const areas = this._getAreas( const isLastArea =
Object.values(this.hass.floors), !nextItem ||
Object.values(this.hass.areas), nextItem.type === "floor" ||
Object.values(this.hass.devices), (nextItem.type === "area" && !nextItem.area?.floor_id);
Object.values(this.hass.entities),
this.includeDomains, const rtl = computeRTL(this.hass);
this.excludeDomains,
this.includeDeviceClasses, const hasFloor = item.type === "area" && item.area?.floor_id;
this.deviceFilter,
this.entityFilter, return html`
this.excludeAreas, <ha-combo-box-item
this.excludeFloors type="button"
); style=${item.type === "area" && hasFloor
this.comboBox.items = areas; ? "--md-list-item-leading-space: 48px;"
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-combo-box <ha-generic-picker
.hass=${this.hass} .hass=${this.hass}
.helper=${this.helper} .autofocus=${this.autofocus}
item-value-path="id" .label=${this.label}
item-id-path="id" .searchLabel=${this.searchLabel}
item-label-path="name" .notFoundLabel=${this.hass.localize(
.value=${this._value} "ui.components.area-picker.no_match"
.disabled=${this.disabled} )}
.required=${this.required} .placeholder=${placeholder}
.label=${this.label === undefined && this.hass .value=${value}
? this.hass.localize("ui.components.area-picker.area") .getItems=${this._getItems}
: this.label} .valueRenderer=${this._valueRenderer}
.placeholder=${this.placeholder .rowRenderer=${this._rowRenderer}
? this.hass.areas[this.placeholder]?.name @value-changed=${this._valueChanged}
: undefined}
.renderer=${this._rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
> >
</ha-combo-box> </ha-generic-picker>
`; `;
} }
private _filterChanged(ev: CustomEvent): void { private _valueChanged(ev: ValueChangedEvent<string>) {
const target = ev.target as HaComboBox;
const filterString = ev.detail.value;
if (!filterString) {
this.comboBox.filteredItems = this.comboBox.items;
return;
}
const filteredItems = fuzzyFilterSort<ScorableAreaFloorEntry>(
filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private async _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation(); ev.stopPropagation();
const newValue = ev.detail.value; const value = ev.detail.value;
if (newValue === "no_areas") { if (!value) {
this._setValue(undefined);
return; return;
} }
const selected = this.comboBox.selectedItem; const selected = this._parseValue(value);
this._setValue(selected);
}
fireEvent(this, "value-changed", { private _setValue(value?: AreaFloorValue) {
value: { this.value = value;
id: selected.id, fireEvent(this, "value-changed", { value });
type: selected.type, fireEvent(this, "change");
},
});
} }
} }

View File

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

View File

@ -5,8 +5,11 @@ 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";
@ -90,7 +93,7 @@ export class HaAssistChat extends LitElement {
super.disconnectedCallback(); super.disconnectedCallback();
this._audioRecorder?.close(); this._audioRecorder?.close();
this._audioRecorder = undefined; this._audioRecorder = undefined;
this._audio?.pause(); this._unloadAudio();
this._conversation = []; this._conversation = [];
this._conversationId = null; this._conversationId = null;
} }
@ -109,25 +112,24 @@ 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`
${controlHA <div class="messages" id="scroll-container">
? nothing ${controlHA
: html` ? nothing
<ha-alert> : html`
${this.hass.localize( <ha-alert>
"ui.dialogs.voice_command.conversation_no_control" ${this.hass.localize(
)} "ui.dialogs.voice_command.conversation_no_control"
</ha-alert> )}
`} </ha-alert>
<div class="messages"> `}
<div class="messages-container" id="scroll-container"> <div class="spacer"></div>
${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
@ -273,8 +275,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) {
@ -293,27 +295,36 @@ export class HaAssistChat extends LitElement {
await this._audioRecorder.start(); await this._audioRecorder.start();
this._addMessage(userMessage); this._addMessage(userMessage);
this.requestUpdate("_audioRecorder");
let continueConversation = false; const hassMessageProcesser = this._createAddHassMessageProcessor();
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) => { (event: PipelineRunEvent) => {
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
if (event.type === "stt-start" && this._audioBuffer) { else 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);
@ -322,91 +333,26 @@ 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
if (event.type === "stt-end") { else 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");
// To make sure the answer is placed at the right user text, we add it before we process it // Add the response message placeholder to the chat when we know the STT is done
this._addMessage(hassMessage); hassMessageProcesser.addMessage();
} } else if (event.type.startsWith("intent-")) {
hassMessageProcesser.processEvent(event);
if (event.type === "intent-progress") { } else if (event.type === "run-end") {
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 {
hassMessage.text = event.data.message; hassMessageProcesser.setError(event.data.message);
hassMessage.error = true;
} }
this._stopListening(); this._stopListening();
this.requestUpdate("_conversation"); this.requestUpdate("_conversation");
@ -464,90 +410,33 @@ 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 = () => {
this._audio?.removeAttribute("src"); if (!this._audio) {
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 });
let hassMessage = { const hassMessageProcesser = this._createAddHassMessageProcessor();
who: "hass", hassMessageProcesser.addMessage();
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 === "intent-progress") { if (event.type.startsWith("intent-")) {
const delta = event.data.chat_log_delta; hassMessageProcesser.processEvent(event);
// 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") {
hassMessage.text = event.data.message; hassMessageProcesser.setError(event.data.message);
hassMessage.error = true;
this.requestUpdate("_conversation");
unsub(); unsub();
} }
}, },
@ -560,20 +449,126 @@ export class HaAssistChat extends LitElement {
} }
); );
} catch { } catch {
hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error"); hassMessageProcesser.setError(
hassMessage.error = true; this.hass.localize("ui.dialogs.voice_command.error")
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;
} }
@ -581,30 +576,30 @@ 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;
font-size: 18px; font-size: var(--ha-font-size-l);
clear: both; clear: both;
margin: 8px 0; margin: 8px 0;
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 {
font-size: 16px; font-size: var(--ha-font-size-l);
} }
} }
@ -619,7 +614,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;
float: var(--float-end); align-self: flex-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));
@ -631,7 +626,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;
float: var(--float-start); align-self: flex-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

@ -95,9 +95,9 @@ export class HaBadge extends LitElement {
text-align: center; text-align: center;
} }
.label { .label {
font-size: 10px; font-size: var(--ha-font-size-xs);
font-style: normal; font-style: normal;
font-weight: 500; font-weight: var(--ha-font-weight-medium);
line-height: 10px; line-height: 10px;
letter-spacing: 0.1px; letter-spacing: 0.1px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
@ -105,8 +105,8 @@ export class HaBadge extends LitElement {
.content { .content {
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: 500; font-weight: var(--ha-font-weight-medium);
line-height: 16px; line-height: var(--ha-line-height-condensed);
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({ attribute: false }) dayLabel = ""; @property({ type: String, attribute: "day-label" }) dayLabel = "";
/** /**
* Label for the hour input * Label for the hour input
*/ */
@property({ attribute: false }) hourLabel = ""; @property({ type: String, attribute: "hour-label" }) hourLabel = "";
/** /**
* Label for the min input * Label for the min input
*/ */
@property({ attribute: false }) minLabel = ""; @property({ type: String, attribute: "min-label" }) minLabel = "";
/** /**
* Label for the sec input * Label for the sec input
*/ */
@property({ attribute: false }) secLabel = ""; @property({ type: String, attribute: "sec-label" }) secLabel = "";
/** /**
* Label for the milli sec input * Label for the milli sec input
*/ */
@property({ attribute: false }) millisecLabel = ""; @property({ type: String, attribute: "ms-label" }) 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: 55px; width: 60px;
flex-grow: 1; flex-grow: 1;
text-align: center; text-align: center;
--mdc-shape-small: 0; --mdc-shape-small: 0;
@ -381,15 +381,21 @@ export class HaBaseTimeInput extends LitElement {
border-bottom-width: 1px; border-bottom-width: 1px;
} }
label { label {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: var(--ha-font-smoothing);
font-family: var( font-family: var(
--mdc-typography-body2-font-family, --mdc-typography-body2-font-family,
var(--mdc-typography-font-family, Roboto, sans-serif) var(--mdc-typography-font-family, var(--ha-font-family-body))
);
font-size: var(--mdc-typography-body2-font-size, var(--ha-font-size-s));
line-height: var(
--mdc-typography-body2-line-height,
var(--ha-line-height-condensed)
);
font-weight: var(
--mdc-typography-body2-font-weight,
var(--ha-font-weight-normal)
); );
font-size: var(--mdc-typography-body2-font-size, 0.875rem);
line-height: var(--mdc-typography-body2-line-height, 1.25rem);
font-weight: var(--mdc-typography-body2-font-weight, 400);
letter-spacing: var( letter-spacing: var(
--mdc-typography-body2-letter-spacing, --mdc-typography-body2-letter-spacing,
0.0178571429em 0.0178571429em
@ -403,7 +409,7 @@ export class HaBaseTimeInput extends LitElement {
} }
ha-input-helper-text { ha-input-helper-text {
padding-top: 8px; padding-top: 8px;
line-height: normal; line-height: var(--ha-line-height-condensed);
} }
`; `;
} }

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: 1.26; line-height: var(--ha-line-height-condensed);
} }
/* Accessibility */ /* Accessibility */
.visually-hidden { .visually-hidden {

View File

@ -41,14 +41,14 @@ export class HaCard extends LitElement {
:host ::slotted(.card-header) { :host ::slotted(.card-header) {
color: var(--ha-card-header-color, var(--primary-text-color)); color: var(--ha-card-header-color, var(--primary-text-color));
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, 24px); font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
letter-spacing: -0.012em; letter-spacing: -0.012em;
line-height: 48px; line-height: var(--ha-line-height-expanded);
padding: 12px 16px 16px; padding: 12px 16px 16px;
display: block; display: block;
margin-block-start: 0px; margin-block-start: 0px;
margin-block-end: 0px; margin-block-end: 0px;
font-weight: normal; font-weight: var(--ha-font-weight-normal);
} }
:host ::slotted(.card-content:not(:first-child)), :host ::slotted(.card-content:not(:first-child)),

View File

@ -154,7 +154,7 @@ class HaClimateState extends LitElement {
} }
.state-label { .state-label {
font-weight: bold; font-weight: var(--ha-font-weight-bold);
} }
.unit { .unit {

View File

@ -21,13 +21,13 @@ 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: 22px; line-height: var(--ha-line-height-normal);
font-size: 14px; font-size: var(--ha-font-size-m);
white-space: nowrap; white-space: nowrap;
} }
[slot="supporting-text"] { [slot="supporting-text"] {
line-height: 18px; line-height: var(--ha-line-height-normal);
font-size: 12px; font-size: var(--ha-font-size-s);
white-space: nowrap; white-space: nowrap;
} }
::slotted(state-badge), ::slotted(state-badge),

View File

@ -0,0 +1,24 @@
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,11 +12,12 @@ 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 } from "lit/decorators"; import { customElement, property, query, state } 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";
@ -108,9 +109,14 @@ 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-textfield", true) private _inputElement!: HaTextField; @query("ha-combo-box-textfield", true) private _inputElement!: HaTextField;
@state({ type: Boolean }) private _disableSetValue = false;
private _overlayMutationObserver?: MutationObserver; private _overlayMutationObserver?: MutationObserver;
@ -171,7 +177,7 @@ export class HaComboBox extends LitElement {
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
attr-for-value="value" attr-for-value="value"
> >
<ha-textfield <ha-combo-box-textfield
label=${ifDefined(this.label)} label=${ifDefined(this.label)}
placeholder=${ifDefined(this.placeholder)} placeholder=${ifDefined(this.placeholder)}
?disabled=${this.disabled} ?disabled=${this.disabled}
@ -191,9 +197,10 @@ 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-textfield> </ha-combo-box-textfield>
${this.value && !this.hideClearIcon ${this.value && !this.hideClearIcon
? html`<ha-svg-icon ? html`<ha-svg-icon
role="button" role="button"
@ -246,8 +253,20 @@ 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>(
@ -326,8 +345,10 @@ export class HaComboBox extends LitElement {
// @ts-ignore // @ts-ignore
this._comboBox._closeOnBlurIsPrevented = true; this._comboBox._closeOnBlurIsPrevented = true;
} }
if (!this.opened) {
return;
}
const newValue = ev.detail.value; const newValue = ev.detail.value;
if (newValue !== this.value) { if (newValue !== this.value) {
fireEvent(this, "value-changed", { value: newValue || undefined }); fireEvent(this, "value-changed", { value: newValue || undefined });
} }
@ -342,10 +363,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-textfield { ha-combo-box-textfield {
width: 100%; width: 100%;
} }
ha-textfield > ha-icon-button { ha-combo-box-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

@ -58,8 +58,8 @@ export class HaControlButton extends LitElement {
padding: var(--control-button-padding); padding: var(--control-button-padding);
box-sizing: border-box; box-sizing: border-box;
line-height: inherit; line-height: inherit;
font-family: Roboto; font-family: var(--ha-font-family-body);
font-weight: 500; font-weight: var(--ha-font-weight-medium);
outline: none; outline: none;
overflow: hidden; overflow: hidden;
background: none; background: none;

View File

@ -194,7 +194,7 @@ export class HaControlNumberButton extends LitElement {
color: var(--primary-text-color); color: var(--primary-text-color);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: var(--ha-font-weight-medium);
transition: color 180ms ease-in-out; transition: color 180ms ease-in-out;
} }
:host([disabled]) { :host([disabled]) {

View File

@ -179,7 +179,7 @@ export class HaControlSelectMenu extends SelectBase {
--control-select-menu-padding: 6px 10px; --control-select-menu-padding: 6px 10px;
--mdc-icon-size: 20px; --mdc-icon-size: 20px;
--ha-ripple-color: var(--secondary-text-color); --ha-ripple-color: var(--secondary-text-color);
font-size: 14px; font-size: var(--ha-font-size-m);
line-height: 1.4; line-height: 1.4;
width: auto; width: auto;
color: var(--primary-text-color); color: var(--primary-text-color);
@ -208,7 +208,7 @@ export class HaControlSelectMenu extends SelectBase {
width: 100%; width: 100%;
user-select: none; user-select: none;
font-style: normal; font-style: normal;
font-weight: 400; font-weight: var(--ha-font-weight-normal);
letter-spacing: 0.25px; letter-spacing: 0.25px;
} }
.content { .content {

View File

@ -207,7 +207,7 @@ export class HaControlSelect extends LitElement {
outline: none; outline: none;
transition: box-shadow 180ms ease-in-out; transition: box-shadow 180ms ease-in-out;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: var(--ha-font-weight-medium);
color: var(--primary-text-color); color: var(--primary-text-color);
user-select: none; user-select: none;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;

View File

@ -368,7 +368,7 @@ export class HaControlSlider extends LitElement {
--control-slider-background-opacity: 0.2; --control-slider-background-opacity: 0.2;
--control-slider-thickness: 40px; --control-slider-thickness: 40px;
--control-slider-border-radius: 10px; --control-slider-border-radius: 10px;
--control-slider-tooltip-font-size: 14px; --control-slider-tooltip-font-size: var(--ha-font-size-m);
height: var(--control-slider-thickness); height: var(--control-slider-thickness);
width: 100%; width: 100%;
border-radius: var(--control-slider-border-radius); border-radius: var(--control-slider-border-radius);

View File

@ -53,12 +53,12 @@ export class HaDialogHeader extends LitElement {
white-space: nowrap; white-space: nowrap;
} }
.header-title { .header-title {
font-size: 22px; font-size: var(--ha-font-size-xl);
line-height: 28px; line-height: var(--ha-line-height-condensed);
font-weight: 400; font-weight: var(--ha-font-weight-normal);
} }
.header-subtitle { .header-subtitle {
font-size: 14px; font-size: var(--ha-font-size-m);
line-height: 20px; line-height: 20px;
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }

View File

@ -85,12 +85,12 @@ export class HaDialog extends DialogBase {
var(--dialog-backdrop-filter, none) var(--dialog-backdrop-filter, none)
); );
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none); --mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
--mdc-typography-headline6-font-weight: 400; --mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
--mdc-typography-headline6-font-size: 1.574rem; --mdc-typography-headline6-font-size: 1.574rem;
} }
.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(env(safe-area-inset-bottom), 12px) 24px; padding: 12px 24px max(var(--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),
env(safe-area-inset-bottom) var(--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
dayLabel="dd" day-label="dd"
hourLabel="hh" hour-label="hh"
minLabel="mm" min-label="mm"
secLabel="ss" sec-label="ss"
millisecLabel="ms" ms-label="ms"
></ha-base-time-input> ></ha-base-time-input>
`; `;
} }

View File

@ -188,7 +188,7 @@ export class HaExpansionPanel extends LitElement {
align-items: center; align-items: center;
cursor: pointer; cursor: pointer;
overflow: hidden; overflow: hidden;
font-weight: 500; font-weight: var(--ha-font-weight-medium);
outline: none; outline: none;
} }
#summary.noCollapse { #summary.noCollapse {
@ -202,6 +202,7 @@ export class HaExpansionPanel extends LitElement {
.header, .header,
::slotted([slot="header"]) { ::slotted([slot="header"]) {
flex: 1; flex: 1;
overflow-wrap: anywhere;
} }
.container { .container {
@ -218,7 +219,7 @@ export class HaExpansionPanel extends LitElement {
.secondary { .secondary {
display: block; display: block;
color: var(--secondary-text-color); color: var(--secondary-text-color);
font-size: 12px; font-size: var(--ha-font-size-s);
} }
`; `;
} }

View File

@ -294,7 +294,7 @@ export class HaFileUpload extends LitElement {
} }
.supports { .supports {
color: var(--secondary-text-color); color: var(--secondary-text-color);
font-size: 12px; font-size: var(--ha-font-size-s);
} }
:host([disabled]) .secondary { :host([disabled]) .secondary {
color: var(--disabled-text-color); color: var(--disabled-text-color);
@ -324,7 +324,7 @@ export class HaFileUpload extends LitElement {
box-sizing: border-box; box-sizing: border-box;
} }
.header { .header {
font-weight: 500; font-weight: var(--ha-font-weight-medium);
} }
.progress { .progress {
color: var(--secondary-text-color); color: var(--secondary-text-color);
@ -333,7 +333,7 @@ export class HaFileUpload extends LitElement {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;
font-size: 14px; font-size: var(--ha-font-size-m);
color: var(--primary-color); color: var(--primary-color);
text-decoration: underline; text-decoration: underline;
cursor: pointer; cursor: pointer;

View File

@ -208,10 +208,10 @@ export class HaFilterBlueprints extends LitElement {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -303,10 +303,10 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -232,10 +232,10 @@ export class HaFilterDevices extends LitElement {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -189,10 +189,10 @@ export class HaFilterDomains extends LitElement {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -246,10 +246,10 @@ export class HaFilterEntities extends LitElement {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -303,10 +303,10 @@ export class HaFilterFloorAreas extends LitElement {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -195,10 +195,10 @@ export class HaFilterIntegrations extends LitElement {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -233,10 +233,10 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

View File

@ -177,10 +177,10 @@ export class HaFilterStates extends LitElement {
min-width: 16px; min-width: 16px;
box-sizing: border-box; box-sizing: border-box;
border-radius: 50%; border-radius: 50%;
font-weight: 400; font-size: var(--ha-font-size-xs);
font-size: 11px; font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color); background-color: var(--primary-color);
line-height: 16px; line-height: var(--ha-line-height-normal);
text-align: center; text-align: center;
padding: 0px 2px; padding: 0px 2px;
color: var(--text-primary-color); color: var(--text-primary-color);

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