mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 11:46:42 +00:00
20250531.1 (#25749)
This commit is contained in:
commit
7e2059e836
2
.github/workflows/relative-ci.yaml
vendored
2
.github/workflows/relative-ci.yaml
vendored
@ -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 }}
|
||||||
|
@ -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
|
|
@ -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 {
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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 () => {};
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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(" ") +
|
||||||
|
63
package.json
63
package.json
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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"
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
|
||||||
|
4
src/common/entity/valid_service_id.ts
Normal file
4
src/common/entity/valid_service_id.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
const validServiceId = /^(\w+)\.(\w+)$/;
|
||||||
|
|
||||||
|
export const isValidServiceId = (actionId: string) =>
|
||||||
|
validServiceId.test(actionId);
|
@ -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
|
||||||
|
@ -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> = {};
|
||||||
|
|
||||||
|
14
src/common/translations/markdown_support.ts
Normal file
14
src/common/translations/markdown_support.ts
Normal 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
|
||||||
|
>`,
|
||||||
|
});
|
72
src/components/chart/down-sample.ts
Normal file
72
src/components/chart/down-sample.ts
Normal 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[];
|
||||||
|
}
|
@ -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 {
|
||||||
|
299
src/components/chart/ha-network-graph.ts
Normal file
299
src/components/chart/ha-network-graph.ts
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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[];
|
||||||
|
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -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;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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");
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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)),
|
||||||
|
@ -154,7 +154,7 @@ class HaClimateState extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.state-label {
|
.state-label {
|
||||||
font-weight: bold;
|
font-weight: var(--ha-font-weight-bold);
|
||||||
}
|
}
|
||||||
|
|
||||||
.unit {
|
.unit {
|
||||||
|
@ -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),
|
||||||
|
24
src/components/ha-combo-box-textfield.ts
Normal file
24
src/components/ha-combo-box-textfield.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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]) {
|
||||||
|
@ -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 {
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
@ -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 {
|
||||||
|
@ -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>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user