mirror of
https://github.com/home-assistant/frontend.git
synced 2025-08-15 12:19:25 +00:00
Compare commits
7 Commits
move-defau
...
update-typ
Author | SHA1 | Date | |
---|---|---|---|
![]() |
cea80d9830 | ||
![]() |
fd279ea2b4 | ||
![]() |
335b876fec | ||
![]() |
b36b4d734b | ||
![]() |
514b6568e5 | ||
![]() |
4750a59719 | ||
![]() |
404d6c75b5 |
2
.github/workflows/relative-ci.yaml
vendored
2
.github/workflows/relative-ci.yaml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send bundle stats and build information to RelativeCI
|
||||
uses: relative-ci/agent-action@v3.0.0
|
||||
uses: relative-ci/agent-action@v2.2.0
|
||||
with:
|
||||
key: ${{ secrets[format('RELATIVE_CI_KEY_{0}_{1}', matrix.bundle, matrix.build)] }}
|
||||
token: ${{ github.token }}
|
||||
|
@@ -1 +1 @@
|
||||
yarn run lint-staged --relative
|
||||
yarn run lint-staged --relative --shell "/bin/bash"
|
||||
|
18
.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch
Normal file
18
.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
@@ -88,7 +88,7 @@ class HcLayout extends LitElement {
|
||||
font-family: var(--ha-card-header-font-family, inherit);
|
||||
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
|
||||
letter-spacing: -0.012em;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
padding: 24px 16px 16px;
|
||||
display: block;
|
||||
margin: 0;
|
||||
@@ -113,7 +113,7 @@ class HcLayout extends LitElement {
|
||||
}
|
||||
|
||||
:host ::slotted(.section-header) {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
padding: 4px 16px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
@@ -50,7 +50,7 @@
|
||||
font-family: Roboto, Noto, sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: 400;
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -68,7 +68,7 @@
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-top {
|
||||
flex: 1;
|
||||
margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px );
|
||||
margin-top: calc( 2 * max(env(safe-area-inset-bottom), 48px) + 46px );
|
||||
padding-top: 48px;
|
||||
}
|
||||
#ha-launch-screen .ha-launch-screen-spacer-bottom {
|
||||
@@ -76,7 +76,7 @@
|
||||
padding-top: 48px;
|
||||
}
|
||||
.ohf-logo {
|
||||
margin: max(var(--safe-area-inset-bottom), 48px) 0;
|
||||
margin: max(env(safe-area-inset-bottom), 48px) 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
@@ -1,30 +1,7 @@
|
||||
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
|
||||
|
||||
let changeFunction;
|
||||
|
||||
export const mockFrontend = (hass: MockHomeAssistant) => {
|
||||
hass.mockWS("frontend/get_user_data", () => ({
|
||||
value: null,
|
||||
}));
|
||||
hass.mockWS("frontend/set_user_data", ({ key, value }) => {
|
||||
if (key === "sidebar") {
|
||||
changeFunction?.({
|
||||
value: {
|
||||
panelOrder: value.panelOrder || [],
|
||||
hiddenPanels: value.hiddenPanels || [],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
hass.mockWS("frontend/subscribe_user_data", (_msg, _hass, onChange) => {
|
||||
changeFunction = onChange;
|
||||
onChange?.({
|
||||
value: {
|
||||
panelOrder: [],
|
||||
hiddenPanels: [],
|
||||
},
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return () => {};
|
||||
});
|
||||
};
|
||||
|
@@ -37,8 +37,8 @@ class PageDescription extends HaMarkdown {
|
||||
border-bottom: 1px solid var(--secondary-background-color);
|
||||
}
|
||||
.title {
|
||||
font-size: 42px;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
font-size: var(--ha-font-size-5xl);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
.subtitle {
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import "./ha-gallery";
|
||||
|
||||
import("../../src/resources/append-ha-style");
|
||||
import("../../src/resources/ha-style");
|
||||
|
||||
document.body.appendChild(document.createElement("ha-gallery"));
|
||||
|
@@ -251,7 +251,7 @@ class HaGallery extends LitElement {
|
||||
|
||||
.page-footer .header {
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
}
|
||||
|
@@ -1,65 +0,0 @@
|
||||
---
|
||||
title: Badge
|
||||
subtitle: Lovelace dashboard badge
|
||||
---
|
||||
|
||||
<style>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
</style>
|
||||
|
||||
# Badge `<ha-badge>`
|
||||
|
||||
The badge component is a small component that displays a number or status information. It is used in the lovelace dashboard on the top.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Example Usage
|
||||
|
||||
<div class="wrapper">
|
||||
<ha-badge>
|
||||
simple badge
|
||||
</ha-badge>
|
||||
|
||||
<ha-badge label="Info">
|
||||
With a label
|
||||
</ha-badge>
|
||||
|
||||
<ha-badge type="button">
|
||||
Type button
|
||||
</ha-badge>
|
||||
</div>
|
||||
|
||||
```html
|
||||
<ha-badge> simple badge </ha-badge>
|
||||
|
||||
<ha-badge label="Info"> With a label </ha-badge>
|
||||
|
||||
<ha-badge type="button"> Type button </ha-badge>
|
||||
```
|
||||
|
||||
### API
|
||||
|
||||
**Slots**
|
||||
|
||||
- default slot is the content of the badge
|
||||
- no default
|
||||
- `icon` set the icon of the badge
|
||||
- no default
|
||||
|
||||
**Properties/Attributes**
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ----------------------- | ----------- | ------------------------------------------------------------ |
|
||||
| type | `"badge"` or `"button"` | `"badge"` | If it's button it shows a ripple effect |
|
||||
| label | string | `undefined` | Text label for the badge, only visible if `iconOnly = false` |
|
||||
| iconOnly | boolean | `false` | Only show label |
|
||||
|
||||
**CSS Custom Properties**
|
||||
|
||||
- `--ha-badge-size` (default `36px`)
|
||||
- `--ha-badge-border-radius` (default `calc(var(--ha-badge-size, 36px) / 2)`)
|
||||
- `--ha-badge-font-size` (default `var(--ha-font-size-s)`)
|
||||
- `--ha-badge-icon-size` (default `18px`)
|
@@ -1,129 +0,0 @@
|
||||
import { mdiButtonCursor, mdiHome } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-badge";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-svg-icon";
|
||||
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
|
||||
|
||||
const badges: {
|
||||
type?: "badge" | "button";
|
||||
label?: string;
|
||||
iconOnly?: boolean;
|
||||
slot?: TemplateResult;
|
||||
iconSlot?: TemplateResult;
|
||||
}[] = [
|
||||
{
|
||||
slot: html`<span>Badge</span>`,
|
||||
},
|
||||
{
|
||||
type: "badge",
|
||||
label: "Badge",
|
||||
iconSlot: html`<ha-svg-icon slot="icon" .path=${mdiHome}></ha-svg-icon>`,
|
||||
slot: html`<span>Badge</span>`,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
label: "Button",
|
||||
iconSlot: html`<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiButtonCursor}
|
||||
></ha-svg-icon>`,
|
||||
slot: html`<span>Button</span>`,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
label: "Label only",
|
||||
iconSlot: html`<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiButtonCursor}
|
||||
></ha-svg-icon>`,
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
label: "Label",
|
||||
slot: html`<span>Button no label</span>`,
|
||||
},
|
||||
{
|
||||
label: "Icon only",
|
||||
iconOnly: true,
|
||||
iconSlot: html`<ha-svg-icon
|
||||
slot="icon"
|
||||
.path=${mdiHomeAssistant}
|
||||
></ha-svg-icon>`,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-components-ha-badge")
|
||||
export class DemoHaBadge extends LitElement {
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-badge ${mode} demo">
|
||||
<div class="card-content">
|
||||
${badges.map(
|
||||
(badge) => html`
|
||||
<ha-badge
|
||||
.type=${badge.type || undefined}
|
||||
.label=${badge.label}
|
||||
.iconOnly=${badge.iconOnly || false}
|
||||
>
|
||||
${badge.iconSlot} ${badge.slot}
|
||||
</ha-badge>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</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`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-badge": DemoHaBadge;
|
||||
}
|
||||
}
|
@@ -105,7 +105,7 @@ export class DemoHaHsColorPicker extends LitElement {
|
||||
width: 400px;
|
||||
}
|
||||
.value {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
font-size: var(--ha-font-size-2xl);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
margin: 0 0 12px 0;
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { html, css, LitElement } from "lit";
|
||||
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-card";
|
||||
import "../../../../src/components/ha-spinner";
|
||||
@@ -12,66 +11,29 @@ export class DemoHaSpinner extends LitElement {
|
||||
@property({ attribute: false }) hass!: HomeAssistant;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-badge ${mode} demo">
|
||||
<div class="card-content">
|
||||
<ha-spinner></ha-spinner>
|
||||
<ha-spinner size="tiny"></ha-spinner>
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
<ha-spinner size="medium"></ha-spinner>
|
||||
<ha-spinner size="large"></ha-spinner>
|
||||
<ha-spinner aria-label="Doing something..."></ha-spinner>
|
||||
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner>
|
||||
</div>
|
||||
</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
|
||||
);
|
||||
return html`<ha-card header="Basic spinner">
|
||||
<div class="card-content">
|
||||
<ha-spinner></ha-spinner></div
|
||||
></ha-card>
|
||||
<ha-card header="Different spinner sizes">
|
||||
<div class="card-content">
|
||||
<ha-spinner size="tiny"></ha-spinner>
|
||||
<ha-spinner size="small"></ha-spinner>
|
||||
<ha-spinner size="medium"></ha-spinner>
|
||||
<ha-spinner size="large"></ha-spinner></div
|
||||
></ha-card>
|
||||
<ha-card header="Spinner with an aria-label">
|
||||
<div class="card-content">
|
||||
<ha-spinner aria-label="Doing something..."></ha-spinner>
|
||||
<ha-spinner .ariaLabel=${"Doing something..."}></ha-spinner></div
|
||||
></ha-card>`;
|
||||
}
|
||||
|
||||
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 {
|
||||
max-width: 600px;
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -1285,7 +1285,7 @@ class HassioAddonInfo extends LitElement {
|
||||
}
|
||||
.addon-version {
|
||||
float: var(--float-end);
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-size: var(--ha-font-size-m);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.errors {
|
||||
|
@@ -132,9 +132,9 @@ class HassioDashboard extends LitElement {
|
||||
}
|
||||
ha-fab.non-tabs {
|
||||
position: fixed;
|
||||
right: calc(16px + var(--safe-area-inset-right));
|
||||
bottom: calc(16px + var(--safe-area-inset-bottom));
|
||||
inset-inline-end: calc(16px + var(--safe-area-inset-right));
|
||||
right: calc(16px + env(safe-area-inset-right));
|
||||
bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
inset-inline-end: calc(16px + env(safe-area-inset-right));
|
||||
inset-inline-start: initial;
|
||||
z-index: 1;
|
||||
}
|
||||
|
@@ -131,7 +131,7 @@ export class HassioUpdate extends LitElement {
|
||||
}
|
||||
.update-heading {
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
margin-bottom: 0.5em;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
@@ -169,11 +169,11 @@ class HassioHardwareDialog extends LitElement {
|
||||
pre {
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
line-height: 1.45;
|
||||
line-height: var(--ha-line-height-normal);
|
||||
font-family: var(--ha-font-family-code);
|
||||
}
|
||||
code {
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-size: 85%;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
search-input {
|
||||
|
@@ -610,7 +610,7 @@ export class DialogHassioNetwork
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px;
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), 8px);
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 8px);
|
||||
background-color: var(--mdc-theme-surface, #fff);
|
||||
}
|
||||
.warning {
|
||||
|
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
haFontFamilyBody,
|
||||
haFontSmoothing,
|
||||
haMozOsxFontSmoothing,
|
||||
} from "../../src/resources/theme/typography.globals";
|
||||
import "./hassio-main";
|
||||
|
||||
import("../../src/resources/append-ha-style");
|
||||
@@ -10,9 +5,9 @@ import("../../src/resources/append-ha-style");
|
||||
const styleEl = document.createElement("style");
|
||||
styleEl.textContent = `
|
||||
body {
|
||||
font-family: ${haFontFamilyBody};
|
||||
-moz-osx-font-smoothing: ${haMozOsxFontSmoothing};
|
||||
-webkit-font-smoothing: ${haFontSmoothing};
|
||||
font-family: Roboto, sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
@@ -354,7 +354,7 @@ class HassioIngressView extends LitElement {
|
||||
|
||||
.main-title {
|
||||
margin: var(--margin-title);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
|
@@ -14,7 +14,6 @@ export const hassioStyle = css`
|
||||
margin-bottom: 8px;
|
||||
font-family: var(--ha-font-family-body);
|
||||
-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-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
|
@@ -1,3 +1,3 @@
|
||||
import "./ha-landing-page";
|
||||
|
||||
import("../../src/resources/append-ha-style");
|
||||
import("../../src/resources/ha-style");
|
||||
|
@@ -4,7 +4,7 @@ export default {
|
||||
"prettier --cache --write",
|
||||
"lit-analyzer --quiet",
|
||||
],
|
||||
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
|
||||
"*.{json,css,md,markdown,html,y?aml}": "prettier --cache --write",
|
||||
"translations/*/*.json": (files) =>
|
||||
'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(" ") +
|
||||
|
69
package.json
69
package.json
@@ -26,15 +26,15 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.27.4",
|
||||
"@babel/runtime": "7.27.0",
|
||||
"@braintree/sanitize-url": "7.1.1",
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/language": "6.11.0",
|
||||
"@codemirror/legacy-modes": "6.5.1",
|
||||
"@codemirror/search": "6.5.11",
|
||||
"@codemirror/search": "6.5.10",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.37.1",
|
||||
"@codemirror/view": "6.36.6",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "6.18.0",
|
||||
"@formatjs/intl-displaynames": "6.8.11",
|
||||
@@ -89,17 +89,17 @@
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "3.8.1",
|
||||
"@tsparticles/preset-links": "3.2.0",
|
||||
"@vaadin/combo-box": "24.7.7",
|
||||
"@vaadin/vaadin-themable-mixin": "24.7.7",
|
||||
"@vaadin/combo-box": "24.7.4",
|
||||
"@vaadin/vaadin-themable-mixin": "24.7.4",
|
||||
"@vibrant/color": "4.0.0",
|
||||
"@vue/web-component-wrapper": "1.3.0",
|
||||
"@webcomponents/scoped-custom-element-registry": "0.0.10",
|
||||
"@webcomponents/webcomponentsjs": "2.8.0",
|
||||
"app-datepicker": "5.1.1",
|
||||
"barcode-detector": "3.0.4",
|
||||
"barcode-detector": "3.0.1",
|
||||
"color-name": "2.0.0",
|
||||
"comlink": "4.4.2",
|
||||
"core-js": "3.42.0",
|
||||
"core-js": "3.41.0",
|
||||
"cropperjs": "1.6.2",
|
||||
"date-fns": "4.1.0",
|
||||
"date-fns-tz": "3.2.0",
|
||||
@@ -111,9 +111,9 @@
|
||||
"fuse.js": "7.1.0",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "6.0.2",
|
||||
"hls.js": "1.6.4",
|
||||
"hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch",
|
||||
"home-assistant-js-websocket": "9.5.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"idb-keyval": "6.2.1",
|
||||
"intl-messageformat": "10.7.16",
|
||||
"js-yaml": "4.1.0",
|
||||
"leaflet": "1.9.4",
|
||||
@@ -122,7 +122,7 @@
|
||||
"lit": "3.3.0",
|
||||
"lit-html": "3.3.0",
|
||||
"luxon": "3.6.1",
|
||||
"marked": "15.0.12",
|
||||
"marked": "15.0.11",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.3",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -131,12 +131,13 @@
|
||||
"qrcode": "1.5.4",
|
||||
"roboto-fontface": "0.10.0",
|
||||
"rrule": "2.8.1",
|
||||
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
|
||||
"sortablejs": "patch:sortablejs@npm%3A1.15.3#~/.yarn/patches/sortablejs-npm-1.15.3-3235a8f83b.patch",
|
||||
"stacktrace-js": "2.0.2",
|
||||
"superstruct": "2.0.2",
|
||||
"tinykeys": "3.0.0",
|
||||
"ua-parser-js": "2.0.3",
|
||||
"vis-data": "7.1.9",
|
||||
"vis-network": "9.1.9",
|
||||
"vue": "2.7.16",
|
||||
"vue2-daterange-picker": "0.6.8",
|
||||
"weekstart": "2.0.0",
|
||||
@@ -149,27 +150,27 @@
|
||||
"xss": "1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.27.4",
|
||||
"@babel/core": "7.26.10",
|
||||
"@babel/helper-define-polyfill-provider": "0.6.4",
|
||||
"@babel/plugin-transform-runtime": "7.27.4",
|
||||
"@babel/preset-env": "7.27.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.20.2",
|
||||
"@lokalise/node-api": "14.8.0",
|
||||
"@octokit/auth-oauth-device": "8.0.1",
|
||||
"@octokit/plugin-retry": "8.0.1",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@rsdoctor/rspack-plugin": "1.1.2",
|
||||
"@rspack/cli": "1.3.12",
|
||||
"@rspack/core": "1.3.12",
|
||||
"@babel/plugin-transform-runtime": "7.26.10",
|
||||
"@babel/preset-env": "7.26.9",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.19.1",
|
||||
"@lokalise/node-api": "14.4.0",
|
||||
"@octokit/auth-oauth-device": "7.1.5",
|
||||
"@octokit/plugin-retry": "7.2.1",
|
||||
"@octokit/rest": "21.1.1",
|
||||
"@rsdoctor/rspack-plugin": "1.0.2",
|
||||
"@rspack/cli": "1.3.7",
|
||||
"@rspack/core": "1.3.7",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.22",
|
||||
"@types/chromecast-caf-receiver": "6.0.21",
|
||||
"@types/chromecast-caf-sender": "1.0.11",
|
||||
"@types/color-name": "2.0.0",
|
||||
"@types/glob": "8.1.0",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/leaflet": "1.9.18",
|
||||
"@types/leaflet-draw": "1.0.12",
|
||||
"@types/leaflet": "1.9.17",
|
||||
"@types/leaflet-draw": "1.0.11",
|
||||
"@types/leaflet.markercluster": "1.5.5",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.6.2",
|
||||
@@ -179,24 +180,24 @@
|
||||
"@types/tar": "6.1.13",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@types/webspeechapi": "0.0.29",
|
||||
"@vitest/coverage-v8": "3.1.4",
|
||||
"@vitest/coverage-v8": "3.1.2",
|
||||
"babel-loader": "10.0.0",
|
||||
"babel-plugin-template-html-minifier": "4.1.0",
|
||||
"browserslist-useragent-regexp": "4.1.3",
|
||||
"del": "8.0.0",
|
||||
"eslint": "9.28.0",
|
||||
"eslint": "9.25.1",
|
||||
"eslint-config-airbnb-base": "15.0.0",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"eslint-config-prettier": "10.1.2",
|
||||
"eslint-import-resolver-webpack": "0.13.10",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-lit": "2.1.1",
|
||||
"eslint-plugin-lit-a11y": "4.1.4",
|
||||
"eslint-plugin-unused-imports": "4.1.4",
|
||||
"eslint-plugin-wc": "3.0.1",
|
||||
"eslint-plugin-wc": "3.0.0",
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.0",
|
||||
"glob": "11.0.2",
|
||||
"gulp": "5.0.1",
|
||||
"gulp": "5.0.0",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
"gulp-rename": "2.0.0",
|
||||
@@ -204,7 +205,7 @@
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "26.1.0",
|
||||
"jszip": "3.10.1",
|
||||
"lint-staged": "16.1.0",
|
||||
"lint-staged": "15.5.1",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.5.0",
|
||||
@@ -218,9 +219,9 @@
|
||||
"terser-webpack-plugin": "5.3.14",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.33.0",
|
||||
"typescript-eslint": "8.31.0",
|
||||
"vite-tsconfig-paths": "5.1.4",
|
||||
"vitest": "3.1.4",
|
||||
"vitest": "3.1.2",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
"webpackbar": "7.0.0",
|
||||
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
|
||||
@@ -232,7 +233,7 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.0",
|
||||
"@fullcalendar/daygrid": "6.1.17",
|
||||
"globals": "16.2.0",
|
||||
"globals": "16.0.0",
|
||||
"tslib": "2.8.1",
|
||||
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
|
||||
},
|
||||
|
@@ -1,13 +1,13 @@
|
||||
<svg width="94" height="72" viewBox="0 0 94 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M76.9105 39.4999C77.739 39.4999 78.4105 38.8284 78.4105 37.9999C78.4105 37.1715 77.739 36.4999 76.9105 36.4999V39.4999ZM37.5 37.9999L37.5 39.4999L76.9105 39.4999V37.9999V36.4999L37.5 36.4999L37.5 37.9999Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<path d="M76.9105 39.4999C77.739 39.4999 78.4105 38.8283 78.4105 37.9999C78.4105 37.1715 77.739 36.4999 76.9105 36.4999V39.4999ZM37.5 39.4999L76.9105 39.4999V36.4999L37.5 36.4999L37.5 39.4999Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<path d="M30.8239 22.3365L38.8239 38.8365L30.3239 50.3365" stroke="black" stroke-opacity="0.12" stroke-width="3" stroke-linecap="round"/>
|
||||
<mask id="mask0_2_779" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="30" y="27" width="18" height="18">
|
||||
<mask id="mask0_1110_23734" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="30" y="27" width="18" height="18">
|
||||
<path d="M45.75 42.075C45.75 42.4462 45.4462 42.75 45.075 42.75H32.925C32.5538 42.75 32.25 42.4462 32.25 42.075V36.675C32.25 36.3037 32.4649 35.7851 32.7276 35.5224L38.5224 29.7275C38.7851 29.4649 39.2143 29.4649 39.477 29.7275L45.2724 35.523C45.5351 35.7857 45.75 36.3043 45.75 36.6755V42.075Z" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_2_779)">
|
||||
<rect x="30" y="27" width="18" height="18" fill="#00AFFF"/>
|
||||
<g mask="url(#mask0_1110_23734)">
|
||||
<rect x="30" y="27" width="18" height="18" fill="#212121"/>
|
||||
</g>
|
||||
<path d="M85 34.9999C86.6569 34.9999 88 36.343 88 37.9999C88 39.6567 86.6569 40.9999 85 40.9999C83.3431 40.9999 82 39.6567 82 37.9999C82 36.343 83.3431 34.9999 85 34.9999Z" stroke="#00AFFF" stroke-width="2"/>
|
||||
<path d="M82 37.9999C82 36.343 83.3431 34.9999 85 34.9999C86.6569 34.9999 88 36.343 88 37.9999C88 39.6567 86.6569 40.9999 85 40.9999C83.3431 40.9999 82 39.6567 82 37.9999Z" stroke="#00AFFF" stroke-width="2"/>
|
||||
<rect x="23" y="11" width="8" height="8" rx="4" fill="black" fill-opacity="0.32"/>
|
||||
<rect x="22" y="52" width="8" height="8" rx="4" fill="black" fill-opacity="0.32"/>
|
||||
<path d="M21.5715 19.5C17.4983 23.801 15 29.6087 15 36C15 41.9085 17.1351 47.3183 20.6759 51.5" stroke="black" stroke-opacity="0.12" stroke-width="3" stroke-linecap="round"/>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -1,19 +1,19 @@
|
||||
<svg width="94" height="72" viewBox="0 0 94 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M55.1358 38.5084C55.9608 38.4334 56.5688 37.7038 56.4938 36.8788C56.4188 36.0538 55.6892 35.4457 54.8642 35.5207L55.1358 38.5084ZM38.5 38.5146L38.6358 40.0084L55.1358 38.5084L55 37.0146L54.8642 35.5207L38.3642 37.0207L38.5 38.5146Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<path d="M30.5 22L37.9722 37.4115C38.2967 38.0807 38.223 38.8747 37.781 39.4728L30 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<circle cx="39" cy="36" r="34" fill="white"/>
|
||||
<circle cx="39" cy="36" r="33.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<path d="M33.8777 12.5216C35.4905 12.1798 37.1631 12 38.8777 12C50.2401 12 59.7582 19.8959 62.2445 30.5M32 59C34.1788 59.6506 36.4874 60 38.8777 60C48.9498 60 57.5728 53.7955 61.1332 45" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M30.5 22L37.9722 37.4115C38.2967 38.0807 38.223 38.8747 37.781 39.4728L30 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<mask id="mask0_2_810" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="30" y="27" width="18" height="18">
|
||||
<path d="M45.75 42.075C45.75 42.4462 45.4463 42.75 45.075 42.75H32.925C32.5538 42.75 32.25 42.4462 32.25 42.075V36.675C32.25 36.3037 32.4649 35.7851 32.7276 35.5224L38.5224 29.7275C38.7851 29.4649 39.2143 29.4649 39.477 29.7275L45.2724 35.523C45.5351 35.7857 45.75 36.3043 45.75 36.6755V42.075Z" fill="black"/>
|
||||
<path d="M63.1358 38.5084C63.9608 38.4334 64.5688 37.7037 64.4938 36.8787C64.4188 36.0537 63.6892 35.4457 62.8642 35.5207L63.1358 38.5084ZM46.6358 40.0084L63.1358 38.5084L62.8642 35.5207L46.3642 37.0207L46.6358 40.0084Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<path d="M38.5 22L45.9722 37.4115C46.2967 38.0807 46.223 38.8747 45.781 39.4728L38 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<circle cx="47" cy="36" r="34" fill="white"/>
|
||||
<circle cx="47" cy="36" r="33.5" stroke="black" stroke-opacity="0.12"/>
|
||||
<path d="M41.8777 12.5216C43.4905 12.1798 45.1631 12 46.8777 12C58.2401 12 67.7582 19.8959 70.2445 30.5M40 59C42.1788 59.6506 44.4874 60 46.8777 60C56.9498 60 65.5728 53.7955 69.1332 45" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M38.5 22L45.9722 37.4115C46.2967 38.0807 46.223 38.8747 45.781 39.4728L38 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<mask id="mask0_1110_23775" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="38" y="27" width="18" height="18">
|
||||
<path d="M53.75 42.075C53.75 42.4462 53.4462 42.75 53.075 42.75H40.925C40.5538 42.75 40.25 42.4462 40.25 42.075V36.675C40.25 36.3037 40.4649 35.7851 40.7276 35.5224L46.5224 29.7275C46.7851 29.4649 47.2143 29.4649 47.477 29.7275L53.2724 35.523C53.5351 35.7857 53.75 36.3043 53.75 36.6755V42.075Z" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_2_810)">
|
||||
<rect x="30" y="27" width="18" height="18" fill="#00AFFF"/>
|
||||
<g mask="url(#mask0_1110_23775)">
|
||||
<rect x="38" y="27" width="18" height="18" fill="#212121"/>
|
||||
</g>
|
||||
<path d="M55.5 39.4999C56.3284 39.4999 57 38.8283 57 37.9999C57 37.1715 56.3284 36.4999 55.5 36.4999L55.5 39.4999ZM41.5 37.9999L41.5 39.4999L55.5 39.4999L55.5 37.9999L55.5 36.4999L41.5 36.4999L41.5 37.9999Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<rect x="23" y="11" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<rect x="22" y="52" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<path d="M21.5715 19.5C17.4983 23.801 15 29.6087 15 36C15 41.9085 17.1351 47.3183 20.6759 51.5" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M63 34.9999C64.6569 34.9999 66 36.343 66 37.9999C66 39.6567 64.6569 40.9999 63 40.9999C61.3431 40.9999 60 39.6567 60 37.9999C60 36.343 61.3431 34.9999 63 34.9999Z" stroke="#00AFFF" stroke-width="2"/>
|
||||
<path d="M63.5 39.4999C64.3284 39.4999 65 38.8283 65 37.9999C65 37.1715 64.3284 36.4999 63.5 36.4999L63.5 39.4999ZM49.5 39.4999L63.5 39.4999L63.5 36.4999L49.5 36.4999L49.5 39.4999Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<rect x="31" y="11" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<rect x="30" y="52" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<path d="M29.5715 19.5C25.4983 23.801 23 29.6087 23 36C23 41.9085 25.1351 47.3183 28.6759 51.5" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M68 37.9999C68 36.343 69.3431 34.9999 71 34.9999C72.6569 34.9999 74 36.343 74 37.9999C74 39.6567 72.6569 40.9999 71 40.9999C69.3431 40.9999 68 39.6567 68 37.9999Z" stroke="#00AFFF" stroke-width="2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -1,18 +1,19 @@
|
||||
<svg width="94" height="72" viewBox="0 0 94 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M55.1358 38.5084C55.9608 38.4334 56.5688 37.7038 56.4938 36.8788C56.4188 36.0538 55.6892 35.4457 54.8642 35.5207L55.1358 38.5084ZM38.5 38.5146L38.6358 40.0084L55.1358 38.5084L55 37.0146L54.8642 35.5207L38.3642 37.0207L38.5 38.5146Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<path d="M30.5 22L37.9722 37.4115C38.2967 38.0807 38.223 38.8747 37.781 39.4728L30 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<circle cx="39" cy="36" r="33.5" stroke="white" stroke-opacity="0.24"/>
|
||||
<path d="M33.8777 12.5216C35.4905 12.1798 37.1631 12 38.8777 12C50.2401 12 59.7582 19.8959 62.2445 30.5M32 59C34.1788 59.6506 36.4874 60 38.8777 60C48.9498 60 57.5728 53.7955 61.1332 45" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M30.5 22L37.9722 37.4115C38.2967 38.0807 38.223 38.8747 37.781 39.4728L30 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<mask id="mask0_2_810" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="30" y="27" width="18" height="18">
|
||||
<path d="M45.75 42.075C45.75 42.4462 45.4463 42.75 45.075 42.75H32.925C32.5538 42.75 32.25 42.4462 32.25 42.075V36.675C32.25 36.3037 32.4649 35.7851 32.7276 35.5224L38.5224 29.7275C38.7851 29.4649 39.2143 29.4649 39.477 29.7275L45.2724 35.523C45.5351 35.7857 45.75 36.3043 45.75 36.6755V42.075Z" fill="black"/>
|
||||
<path d="M63.1358 38.5084C63.9608 38.4334 64.5688 37.7037 64.4938 36.8787C64.4188 36.0537 63.6892 35.4457 62.8642 35.5207L63.1358 38.5084ZM46.6358 40.0084L63.1358 38.5084L62.8642 35.5207L46.3642 37.0207L46.6358 40.0084Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<path d="M38.5 22L45.9722 37.4115C46.2967 38.0807 46.223 38.8747 45.781 39.4728L38 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<circle cx="47" cy="36" r="34" fill="#1C1C1C"/>
|
||||
<circle cx="47" cy="36" r="33.5" stroke="white" stroke-opacity="0.24"/>
|
||||
<path d="M41.8777 12.5216C43.4905 12.1798 45.1631 12 46.8777 12C58.2401 12 67.7582 19.8959 70.2445 30.5M40 59C42.1788 59.6506 44.4874 60 46.8777 60C56.9498 60 65.5728 53.7955 69.1332 45" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M38.5 22L45.9722 37.4115C46.2967 38.0807 46.223 38.8747 45.781 39.4728L38 50" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<mask id="mask0_1180_4965" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="38" y="27" width="18" height="18">
|
||||
<path d="M53.75 42.075C53.75 42.4462 53.4462 42.75 53.075 42.75H40.925C40.5538 42.75 40.25 42.4462 40.25 42.075V36.675C40.25 36.3037 40.4649 35.7851 40.7276 35.5224L46.5224 29.7275C46.7851 29.4649 47.2143 29.4649 47.477 29.7275L53.2724 35.523C53.5351 35.7857 53.75 36.3043 53.75 36.6755V42.075Z" fill="black"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_2_810)">
|
||||
<rect x="30" y="27" width="18" height="18" fill="#00AFFF"/>
|
||||
<g mask="url(#mask0_1180_4965)">
|
||||
<rect x="38" y="27" width="18" height="18" fill="#00AFFF"/>
|
||||
</g>
|
||||
<path d="M55.5 39.4999C56.3284 39.4999 57 38.8283 57 37.9999C57 37.1715 56.3284 36.4999 55.5 36.4999L55.5 39.4999ZM41.5 37.9999L41.5 39.4999L55.5 39.4999L55.5 37.9999L55.5 36.4999L41.5 36.4999L41.5 37.9999Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<rect x="23" y="11" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<rect x="22" y="52" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<path d="M21.5715 19.5C17.4983 23.801 15 29.6087 15 36C15 41.9085 17.1351 47.3183 20.6759 51.5" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M63 34.9999C64.6569 34.9999 66 36.343 66 37.9999C66 39.6567 64.6569 40.9999 63 40.9999C61.3431 40.9999 60 39.6567 60 37.9999C60 36.343 61.3431 34.9999 63 34.9999Z" stroke="#00AFFF" stroke-width="2"/>
|
||||
<path d="M63.5 39.4999C64.3284 39.4999 65 38.8283 65 37.9999C65 37.1715 64.3284 36.4999 63.5 36.4999L63.5 39.4999ZM49.5 39.4999L63.5 39.4999L63.5 36.4999L49.5 36.4999L49.5 39.4999Z" fill="#00AFFF" fill-opacity="0.3"/>
|
||||
<rect x="31" y="11" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<rect x="30" y="52" width="8" height="8" rx="4" fill="#00AFFF" fill-opacity="0.6"/>
|
||||
<path d="M29.5715 19.5C25.4983 23.801 23 29.6087 23 36C23 41.9085 25.1351 47.3183 28.6759 51.5" stroke="#00AFFF" stroke-opacity="0.3" stroke-width="3" stroke-linecap="round" stroke-linejoin="bevel"/>
|
||||
<path d="M68 37.9999C68 36.343 69.3431 34.9999 71 34.9999C72.6569 34.9999 74 36.343 74 37.9999C74 39.6567 72.6569 40.9999 71 40.9999C69.3431 40.9999 68 39.6567 68 37.9999Z" stroke="#00AFFF" stroke-width="2"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20250430.0"
|
||||
version = "20250326.0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
@@ -101,7 +101,7 @@ export class HaAuthFlow extends LitElement {
|
||||
a.forgot-password {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--ha-font-size-s);
|
||||
}
|
||||
.space-between {
|
||||
display: flex;
|
||||
|
@@ -9,7 +9,6 @@ import type { LitElement } from "lit";
|
||||
*/
|
||||
export interface DragScrollControllerConfig {
|
||||
selector: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export class DragScrollController implements ReactiveController {
|
||||
@@ -29,47 +28,19 @@ export class DragScrollController implements ReactiveController {
|
||||
|
||||
private _scrollContainer?: HTMLElement | null;
|
||||
|
||||
private _enabled = true;
|
||||
|
||||
public get enabled(): boolean {
|
||||
return this._enabled;
|
||||
}
|
||||
|
||||
public set enabled(value: boolean) {
|
||||
if (value === this._enabled) {
|
||||
return;
|
||||
}
|
||||
this._enabled = value;
|
||||
if (this._enabled) {
|
||||
this._attach();
|
||||
} else {
|
||||
this._detach();
|
||||
}
|
||||
this._host.requestUpdate();
|
||||
}
|
||||
|
||||
constructor(
|
||||
host: ReactiveControllerHost & LitElement,
|
||||
{ selector, enabled }: DragScrollControllerConfig
|
||||
{ selector }: DragScrollControllerConfig
|
||||
) {
|
||||
this._selector = selector;
|
||||
this._host = host;
|
||||
this.enabled = enabled ?? true;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
hostUpdated() {
|
||||
if (!this.enabled || this._scrollContainer) {
|
||||
if (this._scrollContainer) {
|
||||
return;
|
||||
}
|
||||
this._attach();
|
||||
}
|
||||
|
||||
hostDisconnected() {
|
||||
this._detach();
|
||||
}
|
||||
|
||||
private _attach() {
|
||||
this._scrollContainer = this._host.renderRoot?.querySelector(
|
||||
this._selector
|
||||
);
|
||||
@@ -78,18 +49,9 @@ export class DragScrollController implements ReactiveController {
|
||||
}
|
||||
}
|
||||
|
||||
private _detach() {
|
||||
hostDisconnected() {
|
||||
window.removeEventListener("mousemove", this._mouseMove);
|
||||
window.removeEventListener("mouseup", this._mouseUp);
|
||||
if (this._scrollContainer) {
|
||||
this._scrollContainer.removeEventListener("mousedown", this._mouseDown);
|
||||
this._scrollContainer = undefined;
|
||||
}
|
||||
this.scrolled = false;
|
||||
this.scrolling = false;
|
||||
this.mouseIsDown = false;
|
||||
this.scrollStartX = 0;
|
||||
this.scrollLeft = 0;
|
||||
}
|
||||
|
||||
private _mouseDown = (event: MouseEvent) => {
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { ReactiveElement } from "lit";
|
||||
import { ReactiveElement } from "lit";
|
||||
import type { InternalPropertyDeclaration } from "lit/decorators";
|
||||
|
||||
type Callback = (oldValue: any, newValue: any) => void;
|
||||
|
||||
@@ -107,6 +108,7 @@ export function storage(options: {
|
||||
storage?: "localStorage" | "sessionStorage";
|
||||
subscribe?: boolean;
|
||||
state?: boolean;
|
||||
stateOptions?: InternalPropertyDeclaration;
|
||||
serializer?: (value: any) => any;
|
||||
deserializer?: (value: any) => any;
|
||||
}) {
|
||||
@@ -172,7 +174,7 @@ export function storage(options: {
|
||||
performUpdate.call(this);
|
||||
};
|
||||
|
||||
if (options.subscribe) {
|
||||
if (options.state && options.subscribe) {
|
||||
const connectedCallback = proto.connectedCallback;
|
||||
const disconnectedCallback = proto.disconnectedCallback;
|
||||
|
||||
@@ -190,6 +192,12 @@ export function storage(options: {
|
||||
el.__unbsubLocalStorage = undefined;
|
||||
};
|
||||
}
|
||||
if (options.state) {
|
||||
ReactiveElement.createProperty(propertyKey, {
|
||||
noAccessor: true,
|
||||
...options.stateOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(proto, propertyKey);
|
||||
let newDescriptor: PropertyDescriptor;
|
||||
|
@@ -1,4 +1,10 @@
|
||||
import type { ReactiveElement, PropertyValues } from "lit";
|
||||
import {
|
||||
ReactiveElement,
|
||||
type PropertyDeclaration,
|
||||
type PropertyValues,
|
||||
} from "lit";
|
||||
import { shallowEqual } from "../util/shallow-equal";
|
||||
|
||||
/**
|
||||
* Transform function type.
|
||||
*/
|
||||
@@ -17,6 +23,7 @@ type ReactiveTransformElement = ReactiveElement & {
|
||||
export function transform<T, V>(config: {
|
||||
transformer: Transformer<T, V>;
|
||||
watch?: PropertyKey[];
|
||||
propertyOptions?: PropertyDeclaration;
|
||||
}) {
|
||||
return <ElemClass extends ReactiveElement>(
|
||||
proto: ElemClass,
|
||||
@@ -77,6 +84,11 @@ export function transform<T, V>(config: {
|
||||
curWatch.add(propertyKey);
|
||||
});
|
||||
}
|
||||
ReactiveElement.createProperty(propertyKey, {
|
||||
noAccessor: true,
|
||||
hasChanged: (v: any, o: any) => !shallowEqual(v, o),
|
||||
...config.propertyOptions,
|
||||
});
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(proto, propertyKey);
|
||||
let newDescriptor: PropertyDescriptor;
|
||||
|
@@ -1,36 +0,0 @@
|
||||
export const canOverrideAlphanumericInput = (composedPath: EventTarget[]) => {
|
||||
if (
|
||||
composedPath.some(
|
||||
(el) =>
|
||||
"tagName" in el &&
|
||||
(el.tagName === "HA-MENU" || el.tagName === "HA-CODE-EDITOR")
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const el = composedPath[0] as Element;
|
||||
|
||||
if (el.tagName === "TEXTAREA") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.parentElement?.tagName === "HA-SELECT") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (el.tagName !== "INPUT") {
|
||||
return true;
|
||||
}
|
||||
|
||||
switch ((el as HTMLInputElement).type) {
|
||||
case "button":
|
||||
case "checkbox":
|
||||
case "hidden":
|
||||
case "radio":
|
||||
case "range":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
@@ -6,15 +6,34 @@ interface AreaContext {
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the context of a specific area, including its associated area registry entry
|
||||
* and floor registry entry, if available.
|
||||
*
|
||||
* @param areaId - The unique identifier of the area to retrieve context for.
|
||||
* @param hass - The Home Assistant instance containing area and floor registry data.
|
||||
* @returns An object containing the area registry entry and the associated floor registry entry,
|
||||
* or `null` values if the area or floor is not found.
|
||||
*/
|
||||
export const getAreaContext = (
|
||||
area: AreaRegistryEntry,
|
||||
areaId: string,
|
||||
hass: HomeAssistant
|
||||
): AreaContext => {
|
||||
const floorId = area.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : undefined;
|
||||
const area = (hass.areas[areaId] as AreaRegistryEntry | undefined) || null;
|
||||
|
||||
if (!area) {
|
||||
return {
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
}
|
||||
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : null;
|
||||
|
||||
return {
|
||||
area: area,
|
||||
floor: floor || null,
|
||||
floor: floor,
|
||||
};
|
||||
};
|
||||
|
@@ -1,26 +0,0 @@
|
||||
import type { AreaRegistryEntry } from "../../../data/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
interface DeviceContext {
|
||||
device: DeviceRegistryEntry;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
|
||||
export const getDeviceContext = (
|
||||
device: DeviceRegistryEntry,
|
||||
hass: HomeAssistant
|
||||
): DeviceContext => {
|
||||
const areaId = device.area_id;
|
||||
const area = areaId ? hass.areas[areaId] : undefined;
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : undefined;
|
||||
|
||||
return {
|
||||
device: device,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
};
|
@@ -1,11 +1,6 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { AreaRegistryEntry } from "../../../data/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../../data/device_registry";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
} from "../../../data/entity_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../data/entity_registry";
|
||||
import type { FloorRegistryEntry } from "../../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
@@ -16,15 +11,27 @@ interface EntityContext {
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the context of an entity, including its associated device, area, and floor.
|
||||
*
|
||||
* @param entityId - The unique identifier of the entity to retrieve the context for.
|
||||
* @param hass - The Home Assistant object containing the registry data for entities, devices, areas, and floors.
|
||||
* @returns An object containing the entity, its associated device, area, and floor, or `null` for each if not found.
|
||||
*
|
||||
* The returned `EntityContext` object includes:
|
||||
* - `entity`: The entity registry entry, or `null` if the entity is not found.
|
||||
* - `device`: The device registry entry associated with the entity, or `null` if not found.
|
||||
* - `area`: The area registry entry associated with the entity or device, or `null` if not found.
|
||||
* - `floor`: The floor registry entry associated with the area, or `null` if not found.
|
||||
*/
|
||||
export const getEntityContext = (
|
||||
stateObj: HassEntity,
|
||||
entityId: string,
|
||||
hass: HomeAssistant
|
||||
): EntityContext => {
|
||||
const entry = hass.entities[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
const entity =
|
||||
(hass.entities[entityId] as EntityRegistryDisplayEntry | undefined) || null;
|
||||
|
||||
if (!entry) {
|
||||
if (!entity) {
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
@@ -32,28 +39,18 @@ export const getEntityContext = (
|
||||
floor: null,
|
||||
};
|
||||
}
|
||||
return getEntityEntryContext(entry, hass);
|
||||
};
|
||||
|
||||
export const getEntityEntryContext = (
|
||||
entry:
|
||||
| EntityRegistryDisplayEntry
|
||||
| EntityRegistryEntry
|
||||
| ExtEntityRegistryEntry,
|
||||
hass: HomeAssistant
|
||||
): EntityContext => {
|
||||
const entity = hass.entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? hass.devices[deviceId] : undefined;
|
||||
const areaId = entry?.area_id || device?.area_id;
|
||||
const area = areaId ? hass.areas[areaId] : undefined;
|
||||
const deviceId = entity?.device_id;
|
||||
const device = deviceId ? hass.devices[deviceId] : null;
|
||||
const areaId = entity?.area_id || device?.area_id;
|
||||
const area = areaId ? hass.areas[areaId] : null;
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : undefined;
|
||||
const floor = floorId ? hass.floors[floorId] : null;
|
||||
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
device: device,
|
||||
area: area,
|
||||
floor: floor,
|
||||
};
|
||||
};
|
||||
|
@@ -60,7 +60,7 @@ export const generateEntityFilter = (
|
||||
}
|
||||
}
|
||||
|
||||
const { area, floor, device, entity } = getEntityContext(stateObj, hass);
|
||||
const { area, floor, device, entity } = getEntityContext(entityId, hass);
|
||||
|
||||
if (entity && entity.hidden) {
|
||||
return false;
|
||||
|
18
src/common/entity/get_area_context.ts
Normal file
18
src/common/entity/get_area_context.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { AreaRegistryEntry } from "../../data/area_registry";
|
||||
import type { FloorRegistryEntry } from "../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface AreaContext {
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
export const getAreaContext = (
|
||||
area: AreaRegistryEntry,
|
||||
hass: HomeAssistant
|
||||
): AreaContext => {
|
||||
const floorId = area.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : null;
|
||||
|
||||
return {
|
||||
floor: floor,
|
||||
};
|
||||
};
|
24
src/common/entity/get_device_context.ts
Normal file
24
src/common/entity/get_device_context.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AreaRegistryEntry } from "../../data/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../data/device_registry";
|
||||
import type { FloorRegistryEntry } from "../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface DeviceContext {
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
|
||||
export const getDeviceContext = (
|
||||
device: DeviceRegistryEntry,
|
||||
hass: HomeAssistant
|
||||
): DeviceContext => {
|
||||
const areaId = device.area_id;
|
||||
const area = areaId ? hass.areas[areaId] : null;
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : null;
|
||||
|
||||
return {
|
||||
area: area,
|
||||
floor: floor,
|
||||
};
|
||||
};
|
55
src/common/entity/get_entity_context.ts
Normal file
55
src/common/entity/get_entity_context.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { AreaRegistryEntry } from "../../data/area_registry";
|
||||
import type { DeviceRegistryEntry } from "../../data/device_registry";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
ExtEntityRegistryEntry,
|
||||
} from "../../data/entity_registry";
|
||||
import type { FloorRegistryEntry } from "../../data/floor_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
interface EntityContext {
|
||||
device: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
|
||||
export const getEntityContext = (
|
||||
stateObj: HassEntity,
|
||||
hass: HomeAssistant
|
||||
): EntityContext => {
|
||||
const entry = hass.entities[stateObj.entity_id] as
|
||||
| EntityRegistryDisplayEntry
|
||||
| undefined;
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
device: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
}
|
||||
return getEntityEntryContext(entry, hass);
|
||||
};
|
||||
|
||||
export const getEntityEntryContext = (
|
||||
entry:
|
||||
| EntityRegistryDisplayEntry
|
||||
| EntityRegistryEntry
|
||||
| ExtEntityRegistryEntry,
|
||||
hass: HomeAssistant
|
||||
): EntityContext => {
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? hass.devices[deviceId] : null;
|
||||
const areaId = entry?.area_id || device?.area_id;
|
||||
const area = areaId ? hass.areas[areaId] : null;
|
||||
const floorId = area?.floor_id;
|
||||
const floor = floorId ? hass.floors[floorId] : null;
|
||||
|
||||
return {
|
||||
device: device,
|
||||
area: area,
|
||||
floor: floor,
|
||||
};
|
||||
};
|
@@ -2,6 +2,7 @@ import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { computeStateDomain } from "./compute_state_domain";
|
||||
import { updateIcon } from "./update_icon";
|
||||
import { deviceTrackerIcon } from "./device_tracker_icon";
|
||||
import { batteryIcon } from "./battery_icon";
|
||||
|
||||
export const stateIcon = (
|
||||
stateObj: HassEntity,
|
||||
@@ -9,10 +10,17 @@ export const stateIcon = (
|
||||
): string | undefined => {
|
||||
const domain = computeStateDomain(stateObj);
|
||||
const compareState = state ?? stateObj.state;
|
||||
const dc = stateObj.attributes.device_class;
|
||||
switch (domain) {
|
||||
case "update":
|
||||
return updateIcon(stateObj, compareState);
|
||||
|
||||
case "sensor":
|
||||
if (dc === "battery") {
|
||||
return batteryIcon(stateObj, compareState);
|
||||
}
|
||||
break;
|
||||
|
||||
case "device_tracker":
|
||||
return deviceTrackerIcon(stateObj, compareState);
|
||||
|
||||
|
@@ -1,4 +0,0 @@
|
||||
const validServiceId = /^(\w+)\.(\w+)$/;
|
||||
|
||||
export const isValidServiceId = (actionId: string) =>
|
||||
validServiceId.test(actionId);
|
@@ -96,7 +96,7 @@ const customGenerator = (colors: Swatch[]) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
"%cPicked colors",
|
||||
`color: ${foregroundColor}; background-color: ${backgroundColor.hex}; font-weight: bold; padding: 16px;`
|
||||
`color: ${foregroundColor}; background-color: ${backgroundColor.hex}; font-weight: var(--ha-font-weight-bold); padding: 16px;`
|
||||
);
|
||||
colors.forEach((color) => logColor(color));
|
||||
// eslint-disable-next-line no-console
|
||||
|
@@ -1,19 +1,9 @@
|
||||
// https://gist.github.com/hagemann/382adfc57adbd5af078dc93feef01fe1
|
||||
export const slugify = (value: string, delimiter = "_") => {
|
||||
const a =
|
||||
"àáâäæãåāăąабçćčđďдèéêëēėęěеёэфğǵгḧхîïíīįìıİийкłлḿмñńǹňнôöòóœøōõőоṕпŕřрßśšşșсťțтûüùúūǘůűųувẃẍÿýыžźżз·";
|
||||
const b = `aaaaaaaaaaabcccdddeeeeeeeeeeefggghhiiiiiiiiijkllmmnnnnnoooooooooopprrrsssssstttuuuuuuuuuuvwxyyyzzzz${delimiter}`;
|
||||
"àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·";
|
||||
const b = `aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz${delimiter}`;
|
||||
const p = new RegExp(a.split("").join("|"), "g");
|
||||
const complex_cyrillic = {
|
||||
ж: "zh",
|
||||
х: "kh",
|
||||
ц: "ts",
|
||||
ч: "ch",
|
||||
ш: "sh",
|
||||
щ: "shch",
|
||||
ю: "iu",
|
||||
я: "ia",
|
||||
};
|
||||
|
||||
let slugified;
|
||||
|
||||
@@ -24,7 +14,6 @@ export const slugify = (value: string, delimiter = "_") => {
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.replace(p, (c) => b.charAt(a.indexOf(c))) // Replace special characters
|
||||
.replace(/[а-я]/g, (c) => complex_cyrillic[c] || "") // Replace some cyrillic characters
|
||||
.replace(/(\d),(?=\d)/g, "$1") // Remove Commas between numbers
|
||||
.replace(/[^a-z0-9]+/g, delimiter) // Replace all non-word characters
|
||||
.replace(new RegExp(`(${delimiter})\\1+`, "g"), "$1") // Replace multiple delimiters with single delimiter
|
||||
|
@@ -2,7 +2,7 @@ import type { CSSResult } from "lit";
|
||||
|
||||
const _extractCssVars = (
|
||||
cssString: string,
|
||||
condition: (string: string) => boolean = () => true
|
||||
condition: (string) => boolean = () => true
|
||||
) => {
|
||||
const variables: Record<string, string> = {};
|
||||
|
||||
|
@@ -1,14 +0,0 @@
|
||||
import { html } from "lit";
|
||||
import type { LocalizeFunc } from "./localize";
|
||||
|
||||
const MARKDOWN_SUPPORT_URL = "https://commonmark.org/help/";
|
||||
|
||||
export const supportsMarkdownHelper = (localize: LocalizeFunc) =>
|
||||
localize("ui.common.supports_markdown", {
|
||||
markdown_help_link: html`<a
|
||||
href=${MARKDOWN_SUPPORT_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>${localize("ui.common.markdown")}</a
|
||||
>`,
|
||||
});
|
@@ -1,72 +0,0 @@
|
||||
import type { LineSeriesOption } from "echarts";
|
||||
|
||||
export function downSampleLineData(
|
||||
data: LineSeriesOption["data"],
|
||||
chartWidth: number,
|
||||
minX?: number,
|
||||
maxX?: number
|
||||
) {
|
||||
if (!data || data.length < 10) {
|
||||
return data;
|
||||
}
|
||||
const width = chartWidth * window.devicePixelRatio;
|
||||
if (data.length <= width) {
|
||||
return data;
|
||||
}
|
||||
const min = minX ?? getPointData(data[0]!)[0];
|
||||
const max = maxX ?? getPointData(data[data.length - 1]!)[0];
|
||||
const step = Math.floor((max - min) / width);
|
||||
const frames = new Map<
|
||||
number,
|
||||
{
|
||||
min: { point: (typeof data)[number]; x: number; y: number };
|
||||
max: { point: (typeof data)[number]; x: number; y: number };
|
||||
}
|
||||
>();
|
||||
|
||||
// Group points into frames
|
||||
for (const point of data) {
|
||||
const pointData = getPointData(point);
|
||||
if (!Array.isArray(pointData)) continue;
|
||||
const x = Number(pointData[0]);
|
||||
const y = Number(pointData[1]);
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const frameIndex = Math.floor((x - min) / step);
|
||||
const frame = frames.get(frameIndex);
|
||||
if (!frame) {
|
||||
frames.set(frameIndex, { min: { point, x, y }, max: { point, x, y } });
|
||||
} else {
|
||||
if (frame.min.y > y) {
|
||||
frame.min = { point, x, y };
|
||||
}
|
||||
if (frame.max.y < y) {
|
||||
frame.max = { point, x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert frames back to points
|
||||
const result: typeof data = [];
|
||||
for (const [_i, frame] of frames) {
|
||||
// Use min/max points to preserve visual accuracy
|
||||
// The order of the data must be preserved so max may be before min
|
||||
if (frame.min.x > frame.max.x) {
|
||||
result.push(frame.max.point);
|
||||
}
|
||||
result.push(frame.min.point);
|
||||
if (frame.min.x < frame.max.x) {
|
||||
result.push(frame.max.point);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getPointData(point: NonNullable<LineSeriesOption["data"]>[number]) {
|
||||
const pointData =
|
||||
point && typeof point === "object" && "value" in point
|
||||
? point.value
|
||||
: point;
|
||||
return pointData as number[];
|
||||
}
|
@@ -27,7 +27,6 @@ import "../ha-icon-button";
|
||||
import { formatTimeLabel } from "./axis-label";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import "../chips/ha-assist-chip";
|
||||
import { downSampleLineData } from "./down-sample";
|
||||
|
||||
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||
const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
@@ -49,8 +48,7 @@ export class HaChartBase extends LitElement {
|
||||
@property({ attribute: "expand-legend", type: Boolean })
|
||||
public expandLegend?: boolean;
|
||||
|
||||
// extraComponents is not reactive and should not trigger updates
|
||||
public extraComponents?: any[];
|
||||
@property({ attribute: false }) public extraComponents?: any[];
|
||||
|
||||
@state()
|
||||
@consume({ context: themesContext, subscribe: true })
|
||||
@@ -108,49 +106,48 @@ export class HaChartBase extends LitElement {
|
||||
})
|
||||
);
|
||||
|
||||
if (!this.options?.dataZoom) {
|
||||
// Add keyboard event listeners
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (
|
||||
!this._modifierPressed &&
|
||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||
) {
|
||||
this._modifierPressed = true;
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
// drag to zoom
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
dataZoomSelectActive: true,
|
||||
});
|
||||
// Add keyboard event listeners
|
||||
const handleKeyDown = (ev: KeyboardEvent) => {
|
||||
if (
|
||||
!this._modifierPressed &&
|
||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||
) {
|
||||
this._modifierPressed = true;
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
};
|
||||
// drag to zoom
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
dataZoomSelectActive: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (ev: KeyboardEvent) => {
|
||||
if (
|
||||
this._modifierPressed &&
|
||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||
) {
|
||||
this._modifierPressed = false;
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
dataZoomSelectActive: false,
|
||||
});
|
||||
const handleKeyUp = (ev: KeyboardEvent) => {
|
||||
if (
|
||||
this._modifierPressed &&
|
||||
((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control"))
|
||||
) {
|
||||
this._modifierPressed = false;
|
||||
if (!this.options?.dataZoom) {
|
||||
this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
this._listeners.push(
|
||||
() => window.removeEventListener("keydown", handleKeyDown),
|
||||
() => window.removeEventListener("keyup", handleKeyUp)
|
||||
);
|
||||
}
|
||||
this.chart?.dispatchAction({
|
||||
type: "takeGlobalCursor",
|
||||
key: "dataZoomSelect",
|
||||
dataZoomSelectActive: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
this._listeners.push(
|
||||
() => window.removeEventListener("keydown", handleKeyDown),
|
||||
() => window.removeEventListener("keyup", handleKeyUp)
|
||||
);
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
@@ -194,19 +191,16 @@ export class HaChartBase extends LitElement {
|
||||
<div class="chart"></div>
|
||||
</div>
|
||||
${this._renderLegend()}
|
||||
<div class="chart-controls">
|
||||
${this._isZoomed
|
||||
? html`<ha-icon-button
|
||||
class="zoom-reset"
|
||||
.path=${mdiRestart}
|
||||
@click=${this._handleZoomReset}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.history_charts.zoom_reset"
|
||||
)}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
<slot name="button"></slot>
|
||||
</div>
|
||||
${this._isZoomed
|
||||
? html`<ha-icon-button
|
||||
class="zoom-reset"
|
||||
.path=${mdiRestart}
|
||||
@click=${this._handleZoomReset}
|
||||
title=${this.hass.localize(
|
||||
"ui.components.history_charts.zoom_reset"
|
||||
)}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -216,15 +210,15 @@ export class HaChartBase extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
const legend = ensureArray(this.options.legend)[0] as LegendComponentOption;
|
||||
if (!legend.show || legend.type !== "custom") {
|
||||
if (!legend.show) {
|
||||
return nothing;
|
||||
}
|
||||
const datasets = ensureArray(this.data);
|
||||
const items: LegendComponentOption["data"] =
|
||||
legend.data ||
|
||||
((datasets
|
||||
const items = (legend.data ||
|
||||
datasets
|
||||
.filter((d) => (d.data as any[])?.length && (d.id || d.name))
|
||||
.map((d) => d.name ?? d.id) || []) as string[]);
|
||||
.map((d) => d.name ?? d.id) ||
|
||||
[]) as string[];
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
@@ -239,32 +233,20 @@ export class HaChartBase extends LitElement {
|
||||
})}
|
||||
>
|
||||
<ul>
|
||||
${items.map((item, index) => {
|
||||
${items.map((item: string, index: number) => {
|
||||
if (!this.expandLegend && index >= overflowLimit) {
|
||||
return nothing;
|
||||
}
|
||||
let itemStyle: Record<string, any> = {};
|
||||
let name = "";
|
||||
if (typeof item === "string") {
|
||||
name = item;
|
||||
const dataset = datasets.find(
|
||||
(d) => d.id === item || d.name === item
|
||||
);
|
||||
itemStyle = {
|
||||
color: dataset?.color as string,
|
||||
...(dataset?.itemStyle as { borderColor?: string }),
|
||||
};
|
||||
} else {
|
||||
name = item.name ?? "";
|
||||
itemStyle = item.itemStyle ?? {};
|
||||
}
|
||||
const color = itemStyle?.color as string;
|
||||
const borderColor = itemStyle?.borderColor as string;
|
||||
const dataset = datasets.find(
|
||||
(d) => d.id === item || d.name === item
|
||||
);
|
||||
const color = dataset?.color as string;
|
||||
const borderColor = dataset?.itemStyle?.borderColor as string;
|
||||
return html`<li
|
||||
.name=${name}
|
||||
.name=${item}
|
||||
@click=${this._legendClick}
|
||||
class=${classMap({ hidden: this._hiddenDatasets.has(name) })}
|
||||
.title=${name}
|
||||
class=${classMap({ hidden: this._hiddenDatasets.has(item) })}
|
||||
.title=${item}
|
||||
>
|
||||
<div
|
||||
class="bullet"
|
||||
@@ -273,7 +255,7 @@ export class HaChartBase extends LitElement {
|
||||
borderColor: borderColor || color,
|
||||
})}
|
||||
></div>
|
||||
<div class="label">${name}</div>
|
||||
<div class="label">${item}</div>
|
||||
</li>`;
|
||||
})}
|
||||
${items.length > overflowLimit
|
||||
@@ -333,9 +315,7 @@ export class HaChartBase extends LitElement {
|
||||
this.chart.on("click", (e: ECElementEvent) => {
|
||||
fireEvent(this, "chart-click", e);
|
||||
});
|
||||
if (!this.options?.dataZoom) {
|
||||
this.chart.getZr().on("dblclick", this._handleClickZoom);
|
||||
}
|
||||
this.chart.getZr().on("dblclick", this._handleClickZoom);
|
||||
if (this._isTouchDevice) {
|
||||
this.chart.getZr().on("click", (e: ECElementEvent) => {
|
||||
if (!e.zrByTouch) {
|
||||
@@ -400,9 +380,9 @@ export class HaChartBase extends LitElement {
|
||||
if (axis.type !== "time" || axis.show === false) {
|
||||
return axis;
|
||||
}
|
||||
if (axis.min) {
|
||||
if (axis.max && axis.min) {
|
||||
this._minutesDifference = differenceInMinutes(
|
||||
(axis.max as Date) || new Date(),
|
||||
axis.max as Date,
|
||||
axis.min as Date
|
||||
);
|
||||
}
|
||||
@@ -430,12 +410,6 @@ export class HaChartBase extends LitElement {
|
||||
} as XAXisOption;
|
||||
});
|
||||
}
|
||||
let legend = this.options?.legend;
|
||||
if (legend) {
|
||||
legend = ensureArray(legend).map((l) =>
|
||||
l.type === "custom" ? { show: false } : l
|
||||
);
|
||||
}
|
||||
const options = {
|
||||
animation: !this._reducedMotion,
|
||||
darkMode: this._themes.darkMode ?? false,
|
||||
@@ -450,7 +424,7 @@ export class HaChartBase extends LitElement {
|
||||
iconStyle: { opacity: 0 },
|
||||
},
|
||||
...this.options,
|
||||
legend,
|
||||
legend: { show: false },
|
||||
xAxis,
|
||||
};
|
||||
|
||||
@@ -494,13 +468,6 @@ export class HaChartBase extends LitElement {
|
||||
smooth: false,
|
||||
},
|
||||
bar: { itemStyle: { barBorderWidth: 1.5 } },
|
||||
graph: {
|
||||
label: {
|
||||
color: style.getPropertyValue("--primary-text-color"),
|
||||
textBorderColor: style.getPropertyValue("--primary-background-color"),
|
||||
textBorderWidth: 2,
|
||||
},
|
||||
},
|
||||
categoryAxis: {
|
||||
axisLine: { show: false },
|
||||
axisTick: { show: false },
|
||||
@@ -633,50 +600,12 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
private _getSeries() {
|
||||
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
|
||||
| XAXisOption
|
||||
| undefined;
|
||||
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
|
||||
| YAXisOption
|
||||
| undefined;
|
||||
const series = ensureArray(this.data)
|
||||
.filter((d) => !this._hiddenDatasets.has(String(d.name ?? d.id)))
|
||||
.map((s) => {
|
||||
if (s.type === "line") {
|
||||
if (yAxis?.type === "log") {
|
||||
// set <=0 values to null so they render as gaps on a log graph
|
||||
return {
|
||||
...s,
|
||||
data: s.data?.map((v) =>
|
||||
Array.isArray(v)
|
||||
? [
|
||||
v[0],
|
||||
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
|
||||
...v.slice(2),
|
||||
]
|
||||
: v
|
||||
),
|
||||
};
|
||||
}
|
||||
if (s.sampling === "minmax") {
|
||||
const minX =
|
||||
xAxis?.min && typeof xAxis.min === "number"
|
||||
? xAxis.min
|
||||
: undefined;
|
||||
const maxX =
|
||||
xAxis?.max && typeof xAxis.max === "number"
|
||||
? xAxis.max
|
||||
: undefined;
|
||||
return {
|
||||
...s,
|
||||
sampling: undefined,
|
||||
data: downSampleLineData(s.data, this.clientWidth, minX, maxX),
|
||||
};
|
||||
}
|
||||
}
|
||||
return s;
|
||||
});
|
||||
return series;
|
||||
if (!Array.isArray(this.data)) {
|
||||
return this.data;
|
||||
}
|
||||
return this.data.filter(
|
||||
(d) => !this._hiddenDatasets.has(String(d.name ?? d.id))
|
||||
);
|
||||
}
|
||||
|
||||
private _getDefaultHeight() {
|
||||
@@ -776,26 +705,16 @@ export class HaChartBase extends LitElement {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.chart-controls {
|
||||
.zoom-reset {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.chart-controls ha-icon-button,
|
||||
.chart-controls ::slotted(ha-icon-button) {
|
||||
background: var(--card-background-color);
|
||||
border-radius: 4px;
|
||||
--mdc-icon-button-size: 32px;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--divider-color);
|
||||
}
|
||||
.chart-controls ha-icon-button.inactive,
|
||||
.chart-controls ::slotted(ha-icon-button.inactive) {
|
||||
color: var(--state-inactive-color);
|
||||
}
|
||||
.chart-legend {
|
||||
max-height: 60%;
|
||||
overflow-y: auto;
|
||||
|
@@ -1,299 +0,0 @@
|
||||
import type { EChartsType } from "echarts/core";
|
||||
import type { GraphSeriesOption } from "echarts/charts";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state, query } from "lit/decorators";
|
||||
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
|
||||
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import type { ECOption } from "../../resources/echarts";
|
||||
import "./ha-chart-base";
|
||||
import type { HaChartBase } from "./ha-chart-base";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export interface NetworkNode {
|
||||
id: string;
|
||||
name?: string;
|
||||
category?: number;
|
||||
label?: string;
|
||||
value?: number;
|
||||
symbolSize?: number;
|
||||
symbol?: string;
|
||||
itemStyle?: {
|
||||
color?: string;
|
||||
borderColor?: string;
|
||||
borderWidth?: number;
|
||||
};
|
||||
fixed?: boolean;
|
||||
/**
|
||||
* Distance from the center, where 0 is the center and 1 is the edge
|
||||
*/
|
||||
polarDistance?: number;
|
||||
}
|
||||
|
||||
export interface NetworkLink {
|
||||
source: string;
|
||||
target: string;
|
||||
value?: number;
|
||||
reverseValue?: number;
|
||||
lineStyle?: {
|
||||
width?: number;
|
||||
color?: string;
|
||||
type?: "solid" | "dashed" | "dotted";
|
||||
};
|
||||
symbolSize?: number | number[];
|
||||
symbol?: string;
|
||||
label?: {
|
||||
show?: boolean;
|
||||
formatter?: string;
|
||||
};
|
||||
ignoreForceLayout?: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkData {
|
||||
nodes: NetworkNode[];
|
||||
links: NetworkLink[];
|
||||
categories?: { name: string; symbol: string }[];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
|
||||
let GraphChart: typeof import("echarts/lib/chart/graph/install");
|
||||
|
||||
@customElement("ha-network-graph")
|
||||
export class HaNetworkGraph extends LitElement {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public data!: NetworkData;
|
||||
|
||||
@property({ attribute: false }) public tooltipFormatter?: (
|
||||
params: TopLevelFormatterParams
|
||||
) => string;
|
||||
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@state() private _reducedMotion = false;
|
||||
|
||||
@state() private _physicsEnabled = true;
|
||||
|
||||
@state() private _showLabels = true;
|
||||
|
||||
private _listeners: (() => void)[] = [];
|
||||
|
||||
private _nodePositions: Record<string, { x: number; y: number }> = {};
|
||||
|
||||
@query("ha-chart-base") private _baseChart?: HaChartBase;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
if (!GraphChart) {
|
||||
import("echarts/lib/chart/graph/install").then((module) => {
|
||||
GraphChart = module;
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._listeners.push(
|
||||
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
|
||||
if (this._reducedMotion !== matches) {
|
||||
this._reducedMotion = matches;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!GraphChart) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.data=${this._getSeries(
|
||||
this.data,
|
||||
this._physicsEnabled,
|
||||
this._reducedMotion,
|
||||
this._showLabels
|
||||
)}
|
||||
.options=${this._createOptions(this.data?.categories)}
|
||||
height="100%"
|
||||
.extraComponents=${[GraphChart]}
|
||||
>
|
||||
<slot name="button" slot="button"></slot>
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class=${this._physicsEnabled ? "active" : "inactive"}
|
||||
.path=${mdiGoogleCirclesGroup}
|
||||
@click=${this._togglePhysics}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.common.graph.toggle_physics"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
slot="button"
|
||||
class=${this._showLabels ? "active" : "inactive"}
|
||||
.path=${mdiFormatTextVariant}
|
||||
@click=${this._toggleLabels}
|
||||
label=${this.hass.localize(
|
||||
"ui.panel.config.common.graph.toggle_labels"
|
||||
)}
|
||||
></ha-icon-button>
|
||||
</ha-chart-base>`;
|
||||
}
|
||||
|
||||
private _createOptions = memoizeOne(
|
||||
(categories?: NetworkData["categories"]): ECOption => ({
|
||||
tooltip: {
|
||||
trigger: "item",
|
||||
confine: true,
|
||||
formatter: this.tooltipFormatter,
|
||||
},
|
||||
legend: {
|
||||
show: !!categories?.length,
|
||||
data: categories?.map((category) => ({
|
||||
...category,
|
||||
icon: category.symbol,
|
||||
})),
|
||||
top: 8,
|
||||
},
|
||||
dataZoom: {
|
||||
type: "inside",
|
||||
filterMode: "none",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
private _getSeries = memoizeOne(
|
||||
(
|
||||
data: NetworkData,
|
||||
physicsEnabled: boolean,
|
||||
reducedMotion: boolean,
|
||||
showLabels: boolean
|
||||
) => {
|
||||
const containerWidth = this.clientWidth;
|
||||
const containerHeight = this.clientHeight;
|
||||
return [
|
||||
{
|
||||
id: "network",
|
||||
type: "graph",
|
||||
layout: physicsEnabled ? "force" : "none",
|
||||
draggable: true,
|
||||
roam: true,
|
||||
selectedMode: "single",
|
||||
label: {
|
||||
show: showLabels,
|
||||
position: "right",
|
||||
},
|
||||
emphasis: {
|
||||
focus: "adjacency",
|
||||
},
|
||||
force: {
|
||||
repulsion: [400, 600],
|
||||
edgeLength: [200, 300],
|
||||
gravity: 0.1,
|
||||
layoutAnimation: !reducedMotion && data.nodes.length < 100,
|
||||
},
|
||||
edgeSymbol: ["none", "arrow"],
|
||||
edgeSymbolSize: 10,
|
||||
data: data.nodes.map((node) => {
|
||||
const echartsNode: NonNullable<GraphSeriesOption["data"]>[number] =
|
||||
{
|
||||
id: node.id,
|
||||
name: node.name,
|
||||
category: node.category,
|
||||
value: node.value,
|
||||
symbolSize: node.symbolSize || 30,
|
||||
symbol: node.symbol || "circle",
|
||||
itemStyle: node.itemStyle || {},
|
||||
fixed: node.fixed,
|
||||
};
|
||||
if (this._nodePositions[node.id]) {
|
||||
echartsNode.x = this._nodePositions[node.id].x;
|
||||
echartsNode.y = this._nodePositions[node.id].y;
|
||||
} else if (typeof node.polarDistance === "number") {
|
||||
// set the position of the node at polarDistance from the center in a random direction
|
||||
const angle = Math.random() * 2 * Math.PI;
|
||||
echartsNode.x =
|
||||
containerWidth / 2 +
|
||||
((Math.cos(angle) * containerWidth) / 2) * node.polarDistance;
|
||||
echartsNode.y =
|
||||
containerHeight / 2 +
|
||||
((Math.sin(angle) * containerHeight) / 2) * node.polarDistance;
|
||||
this._nodePositions[node.id] = {
|
||||
x: echartsNode.x,
|
||||
y: echartsNode.y,
|
||||
};
|
||||
}
|
||||
return echartsNode;
|
||||
}),
|
||||
links: data.links.map((link) => ({
|
||||
...link,
|
||||
value: link.reverseValue
|
||||
? Math.max(link.value ?? 0, link.reverseValue)
|
||||
: link.value,
|
||||
// remove arrow for bidirectional links
|
||||
symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work
|
||||
})),
|
||||
categories: data.categories || [],
|
||||
},
|
||||
] as any;
|
||||
}
|
||||
);
|
||||
|
||||
private _togglePhysics() {
|
||||
if (this._baseChart?.chart) {
|
||||
this._baseChart.chart
|
||||
// @ts-ignore private method but no other way to get the graph positions
|
||||
.getModel()
|
||||
.getSeriesByIndex(0)
|
||||
.getGraph()
|
||||
.eachNode((node: any) => {
|
||||
const layout = node.getLayout();
|
||||
if (layout) {
|
||||
this._nodePositions[node.id] = {
|
||||
x: layout[0],
|
||||
y: layout[1],
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
this._physicsEnabled = !this._physicsEnabled;
|
||||
}
|
||||
|
||||
private _toggleLabels() {
|
||||
this._showLabels = !this._showLabels;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
ha-chart-base {
|
||||
height: 100%;
|
||||
--chart-max-height: 100%;
|
||||
}
|
||||
|
||||
ha-icon-button,
|
||||
::slotted(ha-icon-button) {
|
||||
margin-right: 12px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-network-graph": HaNetworkGraph;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"node-selected": { id: string };
|
||||
}
|
||||
}
|
@@ -105,42 +105,7 @@ export class HaSankeyChart extends LitElement {
|
||||
|
||||
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
|
||||
const filteredNodes = data.nodes.filter((n) => n.value > 0);
|
||||
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
|
||||
const depthMap = new Map<number, number>();
|
||||
const sections: Node[][] = [];
|
||||
indexes.forEach((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 indexes = [...new Set(filteredNodes.map((n) => n.index))];
|
||||
const links = this._processLinks(filteredNodes, data.links);
|
||||
const sectionWidth = width / indexes.length;
|
||||
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
|
||||
@@ -148,13 +113,13 @@ export class HaSankeyChart extends LitElement {
|
||||
return {
|
||||
id: "sankey",
|
||||
type: "sankey",
|
||||
nodes: sections.flat().map((node) => ({
|
||||
nodes: filteredNodes.map((node) => ({
|
||||
id: node.id,
|
||||
value: node.value,
|
||||
itemStyle: {
|
||||
color: node.color,
|
||||
},
|
||||
depth: depthMap.get(node.index),
|
||||
depth: node.index,
|
||||
})),
|
||||
links,
|
||||
draggable: false,
|
||||
@@ -258,23 +223,6 @@ export class HaSankeyChart extends LitElement {
|
||||
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`
|
||||
:host {
|
||||
display: block;
|
||||
|
@@ -82,8 +82,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
|
||||
private _chartTime: Date = new Date();
|
||||
|
||||
private _previousYAxisLabelValue = 0;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-chart-base
|
||||
@@ -260,11 +258,35 @@ export class StateHistoryChartLine extends LitElement {
|
||||
},
|
||||
axisLabel: {
|
||||
margin: 5,
|
||||
formatter: this._formatYAxisLabel,
|
||||
formatter: (value: number) => {
|
||||
const formatOptions =
|
||||
value >= 1 || value <= -1
|
||||
? undefined
|
||||
: {
|
||||
// show the first significant digit for tiny values
|
||||
maximumFractionDigits: Math.max(
|
||||
2,
|
||||
-Math.floor(Math.log10(Math.abs(value % 1 || 1)))
|
||||
),
|
||||
};
|
||||
const label = formatNumber(
|
||||
value,
|
||||
this.hass.locale,
|
||||
formatOptions
|
||||
);
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
},
|
||||
} as YAXisOption,
|
||||
legend: {
|
||||
type: "custom",
|
||||
show: this.showNames,
|
||||
},
|
||||
grid: {
|
||||
@@ -722,33 +744,6 @@ export class StateHistoryChartLine extends LitElement {
|
||||
this._visualMap = visualMap.length > 0 ? visualMap : undefined;
|
||||
}
|
||||
|
||||
private _formatYAxisLabel = (value: number) => {
|
||||
const formatOptions =
|
||||
value >= 1 || value <= -1
|
||||
? undefined
|
||||
: {
|
||||
// show the first significant digit for tiny values
|
||||
maximumFractionDigits: Math.max(
|
||||
2,
|
||||
// use the difference to the previous value to determine the number of significant digits #25526
|
||||
-Math.floor(
|
||||
Math.log10(Math.abs(value - this._previousYAxisLabelValue || 1))
|
||||
)
|
||||
),
|
||||
};
|
||||
const label = formatNumber(value, this.hass.locale, formatOptions);
|
||||
const width = measureTextWidth(label, 12) + 5;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
this._previousYAxisLabelValue = value;
|
||||
return label;
|
||||
};
|
||||
|
||||
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||
if (this.logarithmicScale) {
|
||||
// log(0) is -Infinity, so we need to set a minimum value
|
||||
|
@@ -293,7 +293,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
|
||||
.info {
|
||||
text-align: center;
|
||||
line-height: 60px;
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
|
@@ -308,7 +308,6 @@ export class StatisticsChart extends LitElement {
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
type: "custom",
|
||||
show: !this.hideLegend,
|
||||
data: this._legendData,
|
||||
},
|
||||
@@ -635,7 +634,7 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
.info {
|
||||
text-align: center;
|
||||
line-height: 60px;
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
`;
|
||||
|
@@ -60,7 +60,7 @@ export class HaAssistChip extends AssistChip {
|
||||
opacity: var(--ha-assist-chip-active-container-opacity);
|
||||
}
|
||||
.label {
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-family: Roboto, sans-serif;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
@@ -164,8 +164,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@state() private _collapsedGroups: string[] = [];
|
||||
|
||||
@state() private _lastSelectedRowId: string | null = null;
|
||||
|
||||
private _checkableRowsCount?: number;
|
||||
|
||||
private _checkedRows: string[] = [];
|
||||
@@ -189,7 +187,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
public clearSelection(): void {
|
||||
this._checkedRows = [];
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
@@ -197,7 +194,6 @@ export class HaDataTable extends LitElement {
|
||||
this._checkedRows = this._filteredData
|
||||
.filter((data) => data.selectable !== false)
|
||||
.map((data) => data[this.id]);
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
@@ -211,7 +207,6 @@ export class HaDataTable extends LitElement {
|
||||
this._checkedRows.push(id);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
@@ -222,7 +217,6 @@ export class HaDataTable extends LitElement {
|
||||
this._checkedRows.splice(index, 1);
|
||||
}
|
||||
});
|
||||
this._lastSelectedRowId = null;
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
|
||||
@@ -267,7 +261,6 @@ export class HaDataTable extends LitElement {
|
||||
if (this.columns[columnId].direction) {
|
||||
this.sortDirection = this.columns[columnId].direction!;
|
||||
this.sortColumn = columnId;
|
||||
this._lastSelectedRowId = null;
|
||||
|
||||
fireEvent(this, "sorting-changed", {
|
||||
column: columnId,
|
||||
@@ -293,7 +286,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
if (properties.has("filter")) {
|
||||
this._debounceSearch(this.filter);
|
||||
this._lastSelectedRowId = null;
|
||||
}
|
||||
|
||||
if (properties.has("data")) {
|
||||
@@ -304,11 +296,9 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
if (!this.hasUpdated && this.initialCollapsedGroups) {
|
||||
this._collapsedGroups = this.initialCollapsedGroups;
|
||||
this._lastSelectedRowId = null;
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
} else if (properties.has("groupColumn")) {
|
||||
this._collapsedGroups = [];
|
||||
this._lastSelectedRowId = null;
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
}
|
||||
|
||||
@@ -322,14 +312,6 @@ export class HaDataTable extends LitElement {
|
||||
this._sortFilterData();
|
||||
}
|
||||
|
||||
if (
|
||||
properties.has("_filter") ||
|
||||
properties.has("sortColumn") ||
|
||||
properties.has("sortDirection")
|
||||
) {
|
||||
this._lastSelectedRowId = null;
|
||||
}
|
||||
|
||||
if (properties.has("selectable") || properties.has("hiddenColumns")) {
|
||||
this._filteredData = [...this._filteredData];
|
||||
}
|
||||
@@ -560,7 +542,7 @@ export class HaDataTable extends LitElement {
|
||||
>
|
||||
<ha-checkbox
|
||||
class="mdc-data-table__row-checkbox"
|
||||
@click=${this._handleRowCheckboxClicked}
|
||||
@change=${this._handleRowCheckboxClick}
|
||||
.rowId=${row[this.id]}
|
||||
.disabled=${row.selectable === false}
|
||||
.checked=${this._checkedRows.includes(String(row[this.id]))}
|
||||
@@ -621,7 +603,7 @@ export class HaDataTable extends LitElement {
|
||||
.map(
|
||||
([key2, column2], i) =>
|
||||
html`${i !== 0
|
||||
? " · "
|
||||
? " ⸱ "
|
||||
: nothing}${column2.template
|
||||
? column2.template(row)
|
||||
: row[key2]}`
|
||||
@@ -740,10 +722,8 @@ export class HaDataTable extends LitElement {
|
||||
}, {});
|
||||
const groupedItems: DataTableRowData[] = [];
|
||||
Object.entries(sorted).forEach(([groupName, rows]) => {
|
||||
const collapsed = collapsedGroups.includes(groupName);
|
||||
groupedItems.push({
|
||||
append: true,
|
||||
selectable: false,
|
||||
content: html`<div
|
||||
class="mdc-data-table__cell group-header"
|
||||
role="cell"
|
||||
@@ -752,10 +732,9 @@ export class HaDataTable extends LitElement {
|
||||
>
|
||||
<ha-icon-button
|
||||
.path=${mdiChevronUp}
|
||||
.label=${this.hass.localize(
|
||||
`ui.components.data-table.${collapsed ? "expand" : "collapse"}`
|
||||
)}
|
||||
class=${collapsed ? "collapsed" : ""}
|
||||
class=${collapsedGroups.includes(groupName)
|
||||
? "collapsed"
|
||||
: ""}
|
||||
>
|
||||
</ha-icon-button>
|
||||
${groupName === UNDEFINED_GROUP_KEY
|
||||
@@ -771,7 +750,7 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
if (appendRow) {
|
||||
items.push({ append: true, selectable: false, content: appendRow });
|
||||
items.push({ append: true, content: appendRow });
|
||||
}
|
||||
|
||||
if (hasFab) {
|
||||
@@ -821,84 +800,23 @@ export class HaDataTable extends LitElement {
|
||||
this._checkedRows = [];
|
||||
this._checkedRowsChanged();
|
||||
}
|
||||
this._lastSelectedRowId = null;
|
||||
}
|
||||
|
||||
private _handleRowCheckboxClicked = (ev: Event) => {
|
||||
private _handleRowCheckboxClick = (ev: Event) => {
|
||||
const checkbox = ev.currentTarget as HaCheckbox;
|
||||
const rowId = (checkbox as any).rowId;
|
||||
|
||||
const groupedData = this._groupData(
|
||||
this._filteredData,
|
||||
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];
|
||||
if (checkbox.checked) {
|
||||
if (this._checkedRows.includes(rowId)) {
|
||||
return;
|
||||
}
|
||||
this._checkedRows = [...this._checkedRows, rowId];
|
||||
} else {
|
||||
this._checkedRows = this._checkedRows.filter((row) => row !== rowId);
|
||||
}
|
||||
|
||||
if (rowIndex > -1) {
|
||||
this._lastSelectedRowId = rowId;
|
||||
}
|
||||
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) => {
|
||||
if (
|
||||
ev
|
||||
@@ -940,7 +858,6 @@ export class HaDataTable extends LitElement {
|
||||
if (this.filter) {
|
||||
return;
|
||||
}
|
||||
this._lastSelectedRowId = null;
|
||||
this._debounceSearch(ev.detail.value);
|
||||
}
|
||||
|
||||
@@ -977,13 +894,11 @@ export class HaDataTable extends LitElement {
|
||||
} else {
|
||||
this._collapsedGroups = [...this._collapsedGroups, groupName];
|
||||
}
|
||||
this._lastSelectedRowId = null;
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
};
|
||||
|
||||
public expandAllGroups() {
|
||||
this._collapsedGroups = [];
|
||||
this._lastSelectedRowId = null;
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
}
|
||||
|
||||
@@ -1001,7 +916,6 @@ export class HaDataTable extends LitElement {
|
||||
delete grouped.undefined;
|
||||
}
|
||||
this._collapsedGroups = Object.keys(grouped);
|
||||
this._lastSelectedRowId = null;
|
||||
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
|
||||
}
|
||||
|
||||
@@ -1014,10 +928,10 @@ export class HaDataTable extends LitElement {
|
||||
height: 100%;
|
||||
}
|
||||
.mdc-data-table__content {
|
||||
font-family: var(--ha-font-family-body);
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
font-size: 0.875rem;
|
||||
font-family: Roboto, sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
letter-spacing: 0.0178571429em;
|
||||
@@ -1134,11 +1048,11 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table__cell {
|
||||
font-family: var(--ha-font-family-body);
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
font-size: 0.875rem;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
font-family: Roboto, sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
letter-spacing: 0.0178571429em;
|
||||
text-decoration: inherit;
|
||||
@@ -1256,12 +1170,12 @@ export class HaDataTable extends LitElement {
|
||||
}
|
||||
|
||||
.mdc-data-table__header-cell {
|
||||
font-family: var(--ha-font-family-body);
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
font-family: Roboto, sans-serif;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: var(--ha-font-size-s);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
letter-spacing: 0.0071428571em;
|
||||
text-decoration: inherit;
|
||||
text-transform: inherit;
|
||||
@@ -1285,7 +1199,7 @@ export class HaDataTable extends LitElement {
|
||||
padding-inline-start: 12px;
|
||||
padding-inline-end: initial;
|
||||
width: 100%;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
|
@@ -12,7 +12,6 @@ import type { EntityRegistryEntry } from "../../data/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-list-item";
|
||||
import "../ha-select";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
|
||||
const NO_AUTOMATION_KEY = "NO_AUTOMATION";
|
||||
const UNKNOWN_AUTOMATION_KEY = "UNKNOWN_AUTOMATION";
|
||||
@@ -104,7 +103,6 @@ export abstract class HaDeviceAutomationPicker<
|
||||
.label=${this.label}
|
||||
.value=${value}
|
||||
@selected=${this._automationChanged}
|
||||
@closed=${stopPropagation}
|
||||
.disabled=${this._automations.length === 0}
|
||||
>
|
||||
${value === NO_AUTOMATION_KEY
|
||||
|
@@ -1,28 +1,33 @@
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
import {
|
||||
computeDeviceName,
|
||||
computeDeviceNameDisplay,
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { getDeviceContext } from "../../common/entity/context/get_device_context";
|
||||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||||
import {
|
||||
getDeviceEntityDisplayLookup,
|
||||
type DeviceEntityDisplayLookup,
|
||||
type DeviceRegistryEntry,
|
||||
import { stringCompare } from "../../common/string/compare";
|
||||
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
|
||||
import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching";
|
||||
import type {
|
||||
DeviceEntityDisplayLookup,
|
||||
DeviceRegistryEntry,
|
||||
} from "../../data/device_registry";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { brandsUrl } from "../../util/brands-url";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import { getDeviceEntityDisplayLookup } from "../../data/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../../data/entity_registry";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-combo-box";
|
||||
import type { HaComboBox } from "../ha-combo-box";
|
||||
import "../ha-combo-box-item";
|
||||
|
||||
interface Device {
|
||||
name: string;
|
||||
area: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
type ScorableDevice = ScorableTextItem & Device;
|
||||
|
||||
export type HaDevicePickerDeviceFilterFunc = (
|
||||
device: DeviceRegistryEntry
|
||||
@@ -30,35 +35,25 @@ export type HaDevicePickerDeviceFilterFunc = (
|
||||
|
||||
export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean;
|
||||
|
||||
interface DevicePickerItem extends PickerComboBoxItem {
|
||||
domain?: string;
|
||||
domain_name?: string;
|
||||
}
|
||||
const rowRenderer: ComboBoxLitRenderer<Device> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
<span slot="headline">${item.name}</span>
|
||||
${item.area
|
||||
? html`<span slot="supporting-text">${item.area}</span>`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
@customElement("ha-device-picker")
|
||||
export class HaDevicePicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ type: String, attribute: "search-label" })
|
||||
public searchLabel?: string;
|
||||
|
||||
@property({ attribute: false, type: Array }) public createDomains?: string[];
|
||||
|
||||
/**
|
||||
* Show only devices with entities from specific domains.
|
||||
* @type {Array}
|
||||
@@ -97,52 +92,38 @@ export class HaDevicePicker extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaDevicePickerEntityFilterFunc;
|
||||
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
@state() private _opened?: boolean;
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
this._loadConfigEntries();
|
||||
}
|
||||
@query("ha-combo-box", true) public comboBox!: HaComboBox;
|
||||
|
||||
private async _loadConfigEntries() {
|
||||
const configEntries = await getConfigEntries(this.hass);
|
||||
this._configEntryLookup = Object.fromEntries(
|
||||
configEntries.map((entry) => [entry.entry_id, entry])
|
||||
);
|
||||
}
|
||||
|
||||
private _getItems = () =>
|
||||
this._getDevices(
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this._configEntryLookup,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.excludeDevices
|
||||
);
|
||||
private _init = false;
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
configEntryLookup: Record<string, ConfigEntry>,
|
||||
devices: DeviceRegistryEntry[],
|
||||
areas: HomeAssistant["areas"],
|
||||
entities: EntityRegistryDisplayEntry[],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
entityFilter: this["entityFilter"],
|
||||
excludeDevices: this["excludeDevices"]
|
||||
): DevicePickerItem[] => {
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
): ScorableDevice[] => {
|
||||
if (!devices.length) {
|
||||
return [
|
||||
{
|
||||
id: "no_devices",
|
||||
area: "",
|
||||
name: this.hass.localize("ui.components.device-picker.no_devices"),
|
||||
strings: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
|
||||
@@ -233,158 +214,133 @@ export class HaDevicePicker extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
const outputDevices = inputDevices.map<DevicePickerItem>((device) => {
|
||||
const deviceName = computeDeviceNameDisplay(
|
||||
const outputDevices = inputDevices.map((device) => {
|
||||
const name = computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass,
|
||||
deviceEntityLookup[device.id]
|
||||
);
|
||||
|
||||
const { area } = getDeviceContext(device, this.hass);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntryLookup?.[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
const domain = configEntry?.domain;
|
||||
const domainName = domain
|
||||
? domainToName(this.hass.localize, domain)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: device.id,
|
||||
label: "",
|
||||
primary:
|
||||
deviceName ||
|
||||
name:
|
||||
name ||
|
||||
this.hass.localize("ui.components.device-picker.unnamed_device"),
|
||||
secondary: areaName,
|
||||
domain: configEntry?.domain,
|
||||
domain_name: domainName,
|
||||
search_labels: [deviceName, areaName, domain, domainName].filter(
|
||||
Boolean
|
||||
) as string[],
|
||||
sorting_label: deviceName || "zzz",
|
||||
area:
|
||||
device.area_id && areas[device.area_id]
|
||||
? areas[device.area_id].name
|
||||
: this.hass.localize("ui.components.device-picker.no_area"),
|
||||
strings: [name || ""],
|
||||
};
|
||||
});
|
||||
|
||||
return outputDevices;
|
||||
}
|
||||
);
|
||||
|
||||
private _valueRenderer = memoizeOne(
|
||||
(configEntriesLookup: Record<string, ConfigEntry>) => (value: string) => {
|
||||
const deviceId = value;
|
||||
const device = this.hass.devices[deviceId];
|
||||
|
||||
if (!device) {
|
||||
return html`<span slot="headline">${deviceId}</span>`;
|
||||
if (!outputDevices.length) {
|
||||
return [
|
||||
{
|
||||
id: "no_devices",
|
||||
area: "",
|
||||
name: this.hass.localize("ui.components.device-picker.no_match"),
|
||||
strings: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const { area } = getDeviceContext(device, this.hass);
|
||||
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const primary = deviceName;
|
||||
const secondary = areaName;
|
||||
|
||||
const configEntry = device.primary_config_entry
|
||||
? configEntriesLookup[device.primary_config_entry]
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
${configEntry
|
||||
? html`<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl({
|
||||
domain: configEntry.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
})}
|
||||
/>`
|
||||
: nothing}
|
||||
<span slot="headline">${primary}</span>
|
||||
<span slot="supporting-text">${secondary}</span>
|
||||
`;
|
||||
if (outputDevices.length === 1) {
|
||||
return outputDevices;
|
||||
}
|
||||
return outputDevices.sort((a, b) =>
|
||||
stringCompare(a.name || "", b.name || "", this.hass.locale.language)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<DevicePickerItem> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
${item.domain
|
||||
? html`
|
||||
<img
|
||||
slot="start"
|
||||
alt=""
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
src=${brandsUrl({
|
||||
domain: item.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes.darkMode,
|
||||
})}
|
||||
/>
|
||||
`
|
||||
: nothing}
|
||||
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
${item.domain_name
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.device-picker.placeholder");
|
||||
const notFoundLabel = this.hass.localize(
|
||||
"ui.components.device-picker.no_match"
|
||||
);
|
||||
|
||||
const valueRenderer = this._valueRenderer(this._configEntryLookup);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${this.label}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${notFoundLabel}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
await this.comboBox?.open();
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
await this.comboBox?.focus();
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this._init && this.hass) ||
|
||||
(this._init && changedProps.has("_opened") && this._opened)
|
||||
) {
|
||||
this._init = true;
|
||||
const devices = this._getDevices(
|
||||
Object.values(this.hass.devices),
|
||||
this.hass.areas,
|
||||
Object.values(this.hass.entities),
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.excludeDevices
|
||||
);
|
||||
this.comboBox.items = devices;
|
||||
this.comboBox.filteredItems = devices;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-combo-box
|
||||
.hass=${this.hass}
|
||||
.label=${this.label === undefined && this.hass
|
||||
? this.hass.localize("ui.components.device-picker.device")
|
||||
: this.label}
|
||||
.value=${this._value}
|
||||
.helper=${this.helper}
|
||||
.renderer=${rowRenderer}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
item-id-path="id"
|
||||
item-value-path="id"
|
||||
item-label-path="name"
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._deviceChanged}
|
||||
@filter-changed=${this._filterChanged}
|
||||
></ha-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
private _filterChanged(ev: CustomEvent): void {
|
||||
const target = ev.target as HaComboBox;
|
||||
const filterString = ev.detail.value.toLowerCase();
|
||||
target.filteredItems = filterString.length
|
||||
? fuzzyFilterSort<ScorableDevice>(filterString, target.items || [])
|
||||
: target.items;
|
||||
}
|
||||
|
||||
private _deviceChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
let newValue = ev.detail.value;
|
||||
|
||||
if (newValue === "no_devices") {
|
||||
newValue = "";
|
||||
}
|
||||
|
||||
if (newValue !== this._value) {
|
||||
this._setValue(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
this._opened = ev.detail.value;
|
||||
}
|
||||
|
||||
private _setValue(value: string) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
setTimeout(() => {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import type { ValueChangedEvent, HomeAssistant } from "../../types";
|
||||
import "./ha-device-picker";
|
||||
import type {
|
||||
HaDevicePickerDeviceFilterFunc,
|
||||
|
@@ -8,7 +8,7 @@ import "./ha-entity-picker";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "./ha-entity-picker";
|
||||
|
||||
@customElement("ha-entities-picker")
|
||||
class HaEntitiesPicker extends LitElement {
|
||||
class HaEntitiesPickerLight extends LitElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Array }) public value?: string[];
|
||||
@@ -17,10 +17,6 @@ class HaEntitiesPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
/**
|
||||
@@ -71,6 +67,11 @@ class HaEntitiesPicker extends LitElement {
|
||||
@property({ type: Array, attribute: "exclude-entities" })
|
||||
public excludeEntities?: string[];
|
||||
|
||||
@property({ attribute: "picked-entity-label" })
|
||||
public pickedEntityLabel?: string;
|
||||
|
||||
@property({ attribute: "pick-entity-label" }) public pickEntityLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||||
|
||||
@@ -83,7 +84,6 @@ class HaEntitiesPicker extends LitElement {
|
||||
|
||||
const currentEntities = this._currentEntities;
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
${currentEntities.map(
|
||||
(entityId) => html`
|
||||
<div>
|
||||
@@ -99,6 +99,7 @@ class HaEntitiesPicker extends LitElement {
|
||||
.includeUnitOfMeasurement=${this.includeUnitOfMeasurement}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.value=${entityId}
|
||||
.label=${this.pickedEntityLabel}
|
||||
.disabled=${this.disabled}
|
||||
.createDomains=${this.createDomains}
|
||||
@value-changed=${this._entityChanged}
|
||||
@@ -120,7 +121,7 @@ class HaEntitiesPicker extends LitElement {
|
||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||
.includeUnitOfMeasurement=${this.includeUnitOfMeasurement}
|
||||
.entityFilter=${this.entityFilter}
|
||||
.placeholder=${this.placeholder}
|
||||
.label=${this.pickEntityLabel}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.createDomains=${this.createDomains}
|
||||
@@ -197,15 +198,11 @@ class HaEntitiesPicker extends LitElement {
|
||||
div {
|
||||
margin-top: 8px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-entities-picker": HaEntitiesPicker;
|
||||
"ha-entities-picker": HaEntitiesPickerLight;
|
||||
}
|
||||
}
|
||||
|
@@ -73,20 +73,16 @@ class HaEntityAttributePicker extends LitElement {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this.hass.states[this.entityId!] as HassEntity | undefined;
|
||||
|
||||
return html`
|
||||
<ha-combo-box
|
||||
.hass=${this.hass}
|
||||
.value=${this.value
|
||||
? stateObj
|
||||
? computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
stateObj,
|
||||
this.hass.entities,
|
||||
this.value
|
||||
)
|
||||
: this.value
|
||||
? computeAttributeNameDisplay(
|
||||
this.hass.localize,
|
||||
this.hass.states[this.entityId!],
|
||||
this.hass.entities,
|
||||
this.value
|
||||
)
|
||||
: ""}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${this.label ??
|
||||
|
@@ -1,8 +1,11 @@
|
||||
import { mdiPlus, mdiShape } from "@mdi/js";
|
||||
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 { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
@@ -10,36 +13,65 @@ 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 { getEntityContext } from "../../common/entity/get_entity_context";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
isHelperDomain,
|
||||
type HelperDomain,
|
||||
} from "../../panels/config/helpers/const";
|
||||
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 type { HomeAssistant } from "../../types";
|
||||
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-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type {
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "../ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-badge";
|
||||
|
||||
interface EntityComboBoxItem extends PickerComboBoxItem {
|
||||
domain_name?: string;
|
||||
stateObj?: HassEntity;
|
||||
const FAKE_ENTITY: HassEntity = {
|
||||
entity_id: "",
|
||||
state: "",
|
||||
last_changed: "",
|
||||
last_updated: "",
|
||||
context: { id: "", user_id: null, parent_id: null },
|
||||
attributes: {},
|
||||
};
|
||||
|
||||
interface EntityPickerItem extends HassEntity {
|
||||
label: string;
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
translated_domain?: string;
|
||||
show_entity_id?: boolean;
|
||||
entity_name?: string;
|
||||
area_name?: string;
|
||||
device_name?: string;
|
||||
friendly_name?: string;
|
||||
sorting_label?: string;
|
||||
icon_path?: string;
|
||||
}
|
||||
|
||||
export type HaEntityPickerEntityFilterFunc = (entity: HassEntity) => boolean;
|
||||
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
const DOMAIN_STYLE = styleMap({
|
||||
fontSize: "12px",
|
||||
fontWeight: "400",
|
||||
lineHeight: "18px",
|
||||
alignSelf: "flex-end",
|
||||
maxWidth: "30%",
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
});
|
||||
|
||||
const ENTITY_ID_STYLE = styleMap({
|
||||
fontFamily: "var(--code-font-family, monospace)",
|
||||
fontSize: "11px",
|
||||
});
|
||||
|
||||
@customElement("ha-entity-picker")
|
||||
export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -54,20 +86,12 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ type: Boolean, attribute: "allow-custom-entity" })
|
||||
public allowCustomEntity;
|
||||
|
||||
@property({ type: Boolean, attribute: "show-entity-id" })
|
||||
public showEntityId = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@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[];
|
||||
|
||||
/**
|
||||
@@ -124,151 +148,66 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@property({ attribute: "item-label-path" }) public itemLabelPath = "label";
|
||||
|
||||
@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: EntityPickerItem[] = [];
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Load title translations so it is available when the combo-box opens
|
||||
this.hass.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value) => {
|
||||
const entityId = value || "";
|
||||
|
||||
const stateObj = this.hass.states[entityId];
|
||||
|
||||
if (!stateObj) {
|
||||
return html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiShape}
|
||||
style="margin: 0 4px"
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${entityId}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const { area, device } = getEntityContext(stateObj, this.hass);
|
||||
|
||||
const entityName = computeEntityName(stateObj, this.hass);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
return html`
|
||||
<state-badge
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
slot="start"
|
||||
></state-badge>
|
||||
<span slot="headline">${primary}</span>
|
||||
<span slot="supporting-text">${secondary}</span>
|
||||
`;
|
||||
};
|
||||
|
||||
private get _showEntityId() {
|
||||
return this.showEntityId || this.hass.userData?.showEntityIdPicker;
|
||||
}
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<EntityComboBoxItem> = (
|
||||
private _rowRenderer: ComboBoxLitRenderer<EntityPickerItem> = (
|
||||
item,
|
||||
{ index }
|
||||
) => {
|
||||
const showEntityId = this._showEntityId;
|
||||
) => 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}
|
||||
.hass=${this.hass}
|
||||
></state-badge>
|
||||
`}
|
||||
|
||||
return html`
|
||||
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
|
||||
${item.icon_path
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
style="margin: 0 4px"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: html`
|
||||
<state-badge
|
||||
slot="start"
|
||||
.stateObj=${item.stateObj}
|
||||
.hass=${this.hass}
|
||||
></state-badge>
|
||||
`}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
${item.stateObj && showEntityId
|
||||
? html`
|
||||
<span slot="supporting-text" class="code">
|
||||
${item.stateObj.entity_id}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
${item.domain_name && !showEntityId
|
||||
? html`
|
||||
<div slot="trailing-supporting-text" class="domain">
|
||||
${item.domain_name}
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
<span slot="headline">${item.primary} </span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
${item.entity_id && item.show_entity_id
|
||||
? html`<span slot="supporting-text" style=${ENTITY_ID_STYLE}
|
||||
>${item.entity_id}</span
|
||||
>`
|
||||
: nothing}
|
||||
${item.translated_domain && !item.show_entity_id
|
||||
? html`<div slot="trailing-supporting-text" style=${DOMAIN_STYLE}>
|
||||
${item.translated_domain}
|
||||
</div>`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
private _getAdditionalItems = () =>
|
||||
this._getCreateItems(this.hass.localize, this.createDomains);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
(
|
||||
localize: this["hass"]["localize"],
|
||||
createDomains: this["createDomains"]
|
||||
) => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return createDomains.map((domain) => {
|
||||
const primary = localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||||
)
|
||||
: domainToName(localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getEntities(
|
||||
this.hass,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.entityFilter,
|
||||
this.includeDeviceClasses,
|
||||
this.includeUnitOfMeasurement,
|
||||
this.includeEntities,
|
||||
this.excludeEntities
|
||||
);
|
||||
|
||||
private _getEntities = memoizeOne(
|
||||
private _getItems = memoizeOne(
|
||||
(
|
||||
_opened: boolean,
|
||||
hass: this["hass"],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
@@ -276,12 +215,58 @@ export class HaEntityPicker extends LitElement {
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
includeUnitOfMeasurement: this["includeUnitOfMeasurement"],
|
||||
includeEntities: this["includeEntities"],
|
||||
excludeEntities: this["excludeEntities"]
|
||||
): EntityComboBoxItem[] => {
|
||||
let items: EntityComboBoxItem[] = [];
|
||||
excludeEntities: this["excludeEntities"],
|
||||
createDomains: this["createDomains"]
|
||||
): EntityPickerItem[] => {
|
||||
let states: EntityPickerItem[] = [];
|
||||
|
||||
if (!hass) {
|
||||
return [];
|
||||
}
|
||||
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 {
|
||||
...FAKE_ENTITY,
|
||||
entity_id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
label: primary,
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!entityIds.length) {
|
||||
return [
|
||||
{
|
||||
...FAKE_ENTITY,
|
||||
primary: this.hass!.localize(
|
||||
"ui.components.entity.entity-picker.no_entities"
|
||||
),
|
||||
label: this.hass!.localize(
|
||||
"ui.components.entity.entity-picker.no_entities"
|
||||
),
|
||||
icon_path: mdiMagnify,
|
||||
},
|
||||
...createItems,
|
||||
];
|
||||
}
|
||||
|
||||
if (includeEntities) {
|
||||
entityIds = entityIds.filter((entityId) =>
|
||||
includeEntities.includes(entityId)
|
||||
@@ -308,149 +293,180 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
items = entityIds.map<EntityComboBoxItem>((entityId) => {
|
||||
const stateObj = hass!.states[entityId];
|
||||
states = entityIds
|
||||
.map<EntityPickerItem>((entityId) => {
|
||||
const stateObj = hass!.states[entityId];
|
||||
|
||||
const { area, device } = getEntityContext(stateObj, hass);
|
||||
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 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 translatedDomain = domainToName(
|
||||
this.hass.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
|
||||
return {
|
||||
...hass!.states[entityId],
|
||||
primary: primary,
|
||||
secondary:
|
||||
secondary ||
|
||||
this.hass.localize("ui.components.device-picker.no_area"),
|
||||
label: friendlyName,
|
||||
translated_domain: translatedDomain,
|
||||
sorting_label: [deviceName, entityName].filter(Boolean).join("-"),
|
||||
entity_name: entityName || deviceName,
|
||||
area_name: areaName,
|
||||
device_name: deviceName,
|
||||
friendly_name: friendlyName,
|
||||
show_entity_id: hass.userData?.showEntityIdPicker,
|
||||
};
|
||||
})
|
||||
.sort((entityA, entityB) =>
|
||||
caseInsensitiveStringCompare(
|
||||
entityA.sorting_label!,
|
||||
entityB.sorting_label!,
|
||||
this.hass.locale.language
|
||||
)
|
||||
);
|
||||
|
||||
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) =>
|
||||
states = states.filter(
|
||||
(stateObj) =>
|
||||
// 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
|
||||
))
|
||||
stateObj.entity_id === this.value ||
|
||||
(stateObj.attributes.device_class &&
|
||||
includeDeviceClasses.includes(stateObj.attributes.device_class))
|
||||
);
|
||||
}
|
||||
|
||||
if (includeUnitOfMeasurement) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
states = states.filter(
|
||||
(stateObj) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === this.value ||
|
||||
(item.stateObj?.attributes.unit_of_measurement &&
|
||||
stateObj.entity_id === this.value ||
|
||||
(stateObj.attributes.unit_of_measurement &&
|
||||
includeUnitOfMeasurement.includes(
|
||||
item.stateObj.attributes.unit_of_measurement
|
||||
stateObj.attributes.unit_of_measurement
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
if (entityFilter) {
|
||||
items = items.filter(
|
||||
(item) =>
|
||||
states = states.filter(
|
||||
(stateObj) =>
|
||||
// We always want to include the entity of the current value
|
||||
item.id === this.value ||
|
||||
(item.stateObj && entityFilter!(item.stateObj))
|
||||
stateObj.entity_id === this.value || entityFilter!(stateObj)
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
if (!states.length) {
|
||||
return [
|
||||
{
|
||||
...FAKE_ENTITY,
|
||||
primary: this.hass!.localize(
|
||||
"ui.components.entity.entity-picker.no_match"
|
||||
),
|
||||
label: this.hass!.localize(
|
||||
"ui.components.entity.entity-picker.no_match"
|
||||
),
|
||||
icon_path: mdiMagnify,
|
||||
},
|
||||
...createItems,
|
||||
];
|
||||
}
|
||||
|
||||
if (createItems?.length) {
|
||||
states.push(...createItems);
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
);
|
||||
|
||||
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"
|
||||
);
|
||||
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-generic-picker
|
||||
<ha-combo-box
|
||||
item-value-path="entity_id"
|
||||
.itemLabelPath=${this.itemLabelPath}
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.label=${this.label}
|
||||
.value=${this._value}
|
||||
.label=${this.label === undefined
|
||||
? this.hass.localize("ui.components.entity.entity-picker.entity")
|
||||
: this.label}
|
||||
.helper=${this.helper}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${notFoundLabel}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.filteredItems=${this._items}
|
||||
.renderer=${this._rowRenderer}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._valueChanged}
|
||||
@filter-changed=${this._filterChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
</ha-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
|
||||
search,
|
||||
filteredItems
|
||||
) => {
|
||||
// If there is exact match for entity id, put it first
|
||||
const index = filteredItems.findIndex(
|
||||
(item) => item.stateObj?.entity_id === search
|
||||
);
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
private get _value() {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
this._opened = ev.detail.value;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string | undefined>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith(CREATE_ID)) {
|
||||
const domain = value.substring(CREATE_ID.length);
|
||||
const newValue = ev.detail.value?.trim();
|
||||
|
||||
if (newValue && newValue.startsWith(CREATE_ID)) {
|
||||
const domain = newValue.substring(CREATE_ID.length);
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
@@ -460,18 +476,48 @@ export class HaEntityPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isValidEntityId(value)) {
|
||||
return;
|
||||
if (newValue !== this._value) {
|
||||
this._setValue(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
private _fuseIndex = memoizeOne((states: EntityPickerItem[]) =>
|
||||
Fuse.createIndex(
|
||||
[
|
||||
"entity_name",
|
||||
"device_name",
|
||||
"area_name",
|
||||
"translated_domain",
|
||||
"friendly_name", // for backwards compatibility
|
||||
"entity_id", // for technical search
|
||||
],
|
||||
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 | undefined) {
|
||||
this.value = value;
|
||||
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
setTimeout(() => {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -267,7 +267,7 @@ export class HaStateLabelBadge extends LitElement {
|
||||
cursor: pointer;
|
||||
}
|
||||
.big {
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-size: 70%;
|
||||
}
|
||||
ha-label-badge {
|
||||
--ha-label-badge-color: var(--label-badge-red);
|
||||
|
@@ -1,8 +1,11 @@
|
||||
import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js";
|
||||
import { mdiChartLine } 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 { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
@@ -10,60 +13,53 @@ 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 { getEntityContext } from "../../common/entity/get_entity_context";
|
||||
import { caseInsensitiveStringCompare } from "../../common/string/compare";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticIds,
|
||||
getStatisticLabel,
|
||||
type StatisticsMetaData,
|
||||
} from "../../data/recorder";
|
||||
import type { StatisticsMetaData } from "../../data/recorder";
|
||||
import { getStatisticIds, getStatisticLabel } from "../../data/recorder";
|
||||
import { HaFuse } from "../../resources/fuse";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import { documentationUrl } from "../../util/documentation-url";
|
||||
import "../ha-combo-box";
|
||||
import type { HaComboBox } from "../ha-combo-box";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import "../ha-icon-button";
|
||||
import "../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 "./state-badge";
|
||||
|
||||
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
|
||||
|
||||
const MISSING_ID = "___missing-entity___";
|
||||
|
||||
type StatisticItemType = "entity" | "external" | "no_state";
|
||||
|
||||
interface StatisticComboBoxItem extends PickerComboBoxItem {
|
||||
statistic_id?: string;
|
||||
stateObj?: HassEntity;
|
||||
interface StatisticItem {
|
||||
id: string;
|
||||
label: string;
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
show_entity_id?: boolean;
|
||||
entity_name?: string;
|
||||
area_name?: string;
|
||||
device_name?: string;
|
||||
friendly_name?: string;
|
||||
sorting_label?: string;
|
||||
state?: HassEntity;
|
||||
type?: StatisticItemType;
|
||||
iconPath?: string;
|
||||
}
|
||||
|
||||
const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[];
|
||||
|
||||
const ENTITY_ID_STYLE = styleMap({
|
||||
fontFamily: "var(--code-font-family, monospace)",
|
||||
fontSize: "11px",
|
||||
});
|
||||
|
||||
@customElement("ha-statistic-picker")
|
||||
export class HaStatisticPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ attribute: "statistic-types" })
|
||||
public statisticTypes?: "mean" | "sum";
|
||||
|
||||
@@ -73,8 +69,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
@property({ attribute: false, type: Array })
|
||||
public statisticIds?: StatisticsMetaData[];
|
||||
|
||||
@property({ attribute: false }) public helpMissingEntityUrl =
|
||||
"/more-info/statistics/";
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
/**
|
||||
* Show only statistics natively stored with these units of measurements.
|
||||
@@ -117,61 +112,79 @@ export class HaStatisticPicker extends LitElement {
|
||||
@property({ type: Array, attribute: "exclude-statistics" })
|
||||
public excludeStatistics?: string[];
|
||||
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
@property({ attribute: false }) public helpMissingEntityUrl =
|
||||
"/more-info/statistics/";
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@state() private _opened = false;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this.hasUpdated && !this.statisticIds) ||
|
||||
changedProps.has("statisticTypes")
|
||||
) {
|
||||
this._getStatisticIds();
|
||||
}
|
||||
@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 async _getStatisticIds() {
|
||||
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
|
||||
}
|
||||
private _rowRenderer: ComboBoxLitRenderer<StatisticItem> = (
|
||||
item,
|
||||
{ index }
|
||||
) => html`
|
||||
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
|
||||
${!item.state
|
||||
? html`<ha-svg-icon
|
||||
style="margin: 0 4px"
|
||||
slot="start"
|
||||
.path=${item.iconPath}
|
||||
></ha-svg-icon>`
|
||||
: html`
|
||||
<state-badge
|
||||
slot="start"
|
||||
.stateObj=${item.state}
|
||||
.hass=${this.hass}
|
||||
></state-badge>
|
||||
`}
|
||||
|
||||
private _getItems = () =>
|
||||
this._getStatisticsItems(
|
||||
this.hass,
|
||||
this.statisticIds,
|
||||
this.includeStatisticsUnitOfMeasurement,
|
||||
this.includeUnitClass,
|
||||
this.includeDeviceClass,
|
||||
this.entitiesOnly,
|
||||
this.excludeStatistics,
|
||||
this.value
|
||||
);
|
||||
<span slot="headline">${item.primary} </span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
${item.id && item.show_entity_id
|
||||
? html`
|
||||
<span slot="supporting-text" style=${ENTITY_ID_STYLE}>
|
||||
${item.id}
|
||||
</span>
|
||||
`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
private _getAdditionalItems(): StatisticComboBoxItem[] {
|
||||
return [
|
||||
{
|
||||
id: MISSING_ID,
|
||||
primary: this.hass.localize(
|
||||
"ui.components.statistic-picker.missing_entity"
|
||||
),
|
||||
icon_path: mdiHelpCircle,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
private _getStatisticsItems = memoizeOne(
|
||||
private _getItems = memoizeOne(
|
||||
(
|
||||
hass: HomeAssistant,
|
||||
statisticIds?: StatisticsMetaData[],
|
||||
_opened: boolean,
|
||||
hass: this["hass"],
|
||||
statisticIds: StatisticsMetaData[],
|
||||
includeStatisticsUnitOfMeasurement?: string | string[],
|
||||
includeUnitClass?: string | string[],
|
||||
includeDeviceClass?: string | string[],
|
||||
entitiesOnly?: boolean,
|
||||
excludeStatistics?: string[],
|
||||
value?: string
|
||||
): StatisticComboBoxItem[] => {
|
||||
if (!statisticIds) {
|
||||
return [];
|
||||
): StatisticItem[] => {
|
||||
if (!statisticIds.length) {
|
||||
return [
|
||||
{
|
||||
id: "",
|
||||
label: this.hass.localize(
|
||||
"ui.components.statistic-picker.no_statistics"
|
||||
),
|
||||
primary: this.hass.localize(
|
||||
"ui.components.statistic-picker.no_statistics"
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (includeStatisticsUnitOfMeasurement) {
|
||||
@@ -205,8 +218,7 @@ export class HaStatisticPicker extends LitElement {
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
const output: StatisticComboBoxItem[] = [];
|
||||
|
||||
const output: StatisticItem[] = [];
|
||||
statisticIds.forEach((meta) => {
|
||||
if (
|
||||
excludeStatistics &&
|
||||
@@ -215,9 +227,8 @@ export class HaStatisticPicker extends LitElement {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const stateObj = this.hass.states[meta.statistic_id];
|
||||
|
||||
if (!stateObj) {
|
||||
const entityState = this.hass.states[meta.statistic_id];
|
||||
if (!entityState) {
|
||||
if (!entitiesOnly) {
|
||||
const id = meta.statistic_id;
|
||||
const label = getStatisticLabel(this.hass, meta.statistic_id, meta);
|
||||
@@ -227,7 +238,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
? "external"
|
||||
: "no_state";
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf(type)}`;
|
||||
if (type === "no_state") {
|
||||
output.push({
|
||||
id,
|
||||
@@ -235,23 +245,21 @@ export class HaStatisticPicker extends LitElement {
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.statistic-picker.no_state"
|
||||
),
|
||||
label,
|
||||
type,
|
||||
sorting_label: [sortingPrefix, label].join("_"),
|
||||
search_labels: [label, id],
|
||||
icon_path: mdiShape,
|
||||
sorting_label: label,
|
||||
});
|
||||
} 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: [sortingPrefix, label].join("_"),
|
||||
search_labels: [label, domainName, id],
|
||||
icon_path: mdiChartLine,
|
||||
sorting_label: label,
|
||||
iconPath: mdiChartLine,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -259,10 +267,10 @@ export class HaStatisticPicker extends LitElement {
|
||||
}
|
||||
const id = meta.statistic_id;
|
||||
|
||||
const { area, device } = getEntityContext(stateObj, hass);
|
||||
const { area, device } = getEntityContext(entityState, hass);
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
const entityName = computeEntityName(stateObj, hass);
|
||||
const friendlyName = computeStateName(entityState); // Keep this for search
|
||||
const entityName = computeEntityName(entityState, hass);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
@@ -270,254 +278,203 @@ export class HaStatisticPicker extends LitElement {
|
||||
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,
|
||||
label: friendlyName,
|
||||
state: entityState,
|
||||
type: "entity",
|
||||
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
||||
search_labels: [
|
||||
entityName,
|
||||
deviceName,
|
||||
areaName,
|
||||
friendlyName,
|
||||
id,
|
||||
].filter(Boolean) as string[],
|
||||
sorting_label: [deviceName, entityName].join("_"),
|
||||
entity_name: entityName || deviceName,
|
||||
area_name: areaName,
|
||||
device_name: deviceName,
|
||||
friendly_name: friendlyName,
|
||||
show_entity_id: hass.userData?.showEntityIdPicker,
|
||||
});
|
||||
});
|
||||
|
||||
if (!output.length) {
|
||||
return [
|
||||
{
|
||||
id: "",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.statistic-picker.no_match"
|
||||
),
|
||||
label: this.hass.localize(
|
||||
"ui.components.statistic-picker.no_match"
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
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",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.statistic-picker.missing_entity"
|
||||
),
|
||||
label: this.hass.localize(
|
||||
"ui.components.statistic-picker.missing_entity"
|
||||
),
|
||||
});
|
||||
|
||||
return output;
|
||||
}
|
||||
);
|
||||
|
||||
private _statisticMetaData = memoizeOne(
|
||||
(statisticId: string, statisticIds: StatisticsMetaData[]) => {
|
||||
if (!statisticIds) {
|
||||
return undefined;
|
||||
}
|
||||
return statisticIds.find(
|
||||
(statistic) => statistic.statistic_id === statisticId
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value) => {
|
||||
const statisticId = value;
|
||||
|
||||
const item = this._computeItem(statisticId);
|
||||
|
||||
return html`
|
||||
${item.stateObj
|
||||
? html`
|
||||
<state-badge
|
||||
.hass=${this.hass}
|
||||
.stateObj=${item.stateObj}
|
||||
slot="start"
|
||||
></state-badge>
|
||||
`
|
||||
: item.icon_path
|
||||
? html`
|
||||
<ha-svg-icon slot="start" .path=${item.icon_path}></ha-svg-icon>
|
||||
`
|
||||
: nothing}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
${item.secondary
|
||||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
};
|
||||
|
||||
private _computeItem(statisticId: string): StatisticComboBoxItem {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const { area, device } = getEntityContext(stateObj, this.hass);
|
||||
|
||||
const entityName = computeEntityName(stateObj, this.hass);
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
|
||||
const isRTL = computeRTL(this.hass);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||
return {
|
||||
id: statisticId,
|
||||
statistic_id: statisticId,
|
||||
primary,
|
||||
secondary,
|
||||
stateObj: stateObj,
|
||||
type: "entity",
|
||||
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
||||
search_labels: [
|
||||
entityName,
|
||||
deviceName,
|
||||
areaName,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
].filter(Boolean) as string[],
|
||||
};
|
||||
}
|
||||
|
||||
const statistic = this.statisticIds
|
||||
? this._statisticMetaData(statisticId, this.statisticIds)
|
||||
: undefined;
|
||||
|
||||
if (statistic) {
|
||||
const type =
|
||||
statisticId.includes(":") && !statisticId.includes(".")
|
||||
? "external"
|
||||
: "no_state";
|
||||
|
||||
if (type === "external") {
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
|
||||
const label = getStatisticLabel(this.hass, statisticId, statistic);
|
||||
const domain = statisticId.split(":")[0];
|
||||
const domainName = domainToName(this.hass.localize, domain);
|
||||
|
||||
return {
|
||||
id: statisticId,
|
||||
statistic_id: statisticId,
|
||||
primary: label,
|
||||
secondary: domainName,
|
||||
type: "external",
|
||||
sorting_label: [sortingPrefix, label].join("_"),
|
||||
search_labels: [label, domainName, statisticId],
|
||||
icon_path: mdiChartLine,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`;
|
||||
const label = getStatisticLabel(this.hass, statisticId, statistic);
|
||||
|
||||
return {
|
||||
id: statisticId,
|
||||
primary: label,
|
||||
secondary: this.hass.localize("ui.components.statistic-picker.no_state"),
|
||||
type: "no_state",
|
||||
sorting_label: [sortingPrefix, label].join("_"),
|
||||
search_labels: [label, statisticId],
|
||||
icon_path: mdiShape,
|
||||
};
|
||||
}
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<StatisticComboBoxItem> = (
|
||||
item,
|
||||
{ index }
|
||||
) => {
|
||||
const showEntityId = this.hass.userData?.showEntityIdPicker;
|
||||
return html`
|
||||
<ha-combo-box-item type="button" compact .borderTop=${index !== 0}>
|
||||
${item.icon_path
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
style="margin: 0 4px"
|
||||
slot="start"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: item.stateObj
|
||||
? html`
|
||||
<state-badge
|
||||
slot="start"
|
||||
.stateObj=${item.stateObj}
|
||||
.hass=${this.hass}
|
||||
></state-badge>
|
||||
`
|
||||
: nothing}
|
||||
<span slot="headline">${item.primary} </span>
|
||||
${item.secondary || item.type
|
||||
? html`<span slot="supporting-text"
|
||||
>${item.secondary} - ${item.type}</span
|
||||
>`
|
||||
: nothing}
|
||||
${item.statistic_id && showEntityId
|
||||
? html`<span slot="supporting-text" class="code">
|
||||
${item.statistic_id}
|
||||
</span>`
|
||||
: nothing}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
protected render() {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.statistic-picker.placeholder");
|
||||
const notFoundLabel = this.hass.localize(
|
||||
"ui.components.statistic-picker.no_match"
|
||||
);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.label=${this.label}
|
||||
.notFoundLabel=${notFoundLabel}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private _searchFn: PickerComboBoxSearchFn<StatisticComboBoxItem> = (
|
||||
search,
|
||||
filteredItems
|
||||
) => {
|
||||
// If there is exact match for entity id or statistic id, put it first
|
||||
const index = filteredItems.findIndex(
|
||||
(item) =>
|
||||
item.stateObj?.entity_id === search || item.statistic_id === search
|
||||
);
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
};
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (value === MISSING_ID) {
|
||||
window.open(
|
||||
documentationUrl(this.hass, this.helpMissingEntityUrl),
|
||||
"_blank"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
await this.comboBox?.open();
|
||||
}
|
||||
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
await this.comboBox?.focus();
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
changedProps.has("value") ||
|
||||
changedProps.has("label") ||
|
||||
changedProps.has("disabled")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return !(!changedProps.has("_opened") && this._opened);
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this.hasUpdated && !this.statisticIds) ||
|
||||
changedProps.has("statisticTypes")
|
||||
) {
|
||||
this._getStatisticIds();
|
||||
}
|
||||
|
||||
if (
|
||||
this.statisticIds &&
|
||||
(!this._initialItems || (changedProps.has("_opened") && this._opened))
|
||||
) {
|
||||
this._items = this._getItems(
|
||||
this._opened,
|
||||
this.hass,
|
||||
this.statisticIds!,
|
||||
this.includeStatisticsUnitOfMeasurement,
|
||||
this.includeUnitClass,
|
||||
this.includeDeviceClass,
|
||||
this.entitiesOnly,
|
||||
this.excludeStatistics,
|
||||
this.value
|
||||
);
|
||||
if (this._initialItems) {
|
||||
this.comboBox.filteredItems = this._items;
|
||||
}
|
||||
this._initialItems = true;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
if (this._items.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-combo-box
|
||||
.hass=${this.hass}
|
||||
.label=${this.label === undefined && this.hass
|
||||
? this.hass.localize("ui.components.statistic-picker.statistic")
|
||||
: this.label}
|
||||
.value=${this._value}
|
||||
.renderer=${this._rowRenderer}
|
||||
.disabled=${this.disabled}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.filteredItems=${this._items}
|
||||
item-value-path="id"
|
||||
item-id-path="id"
|
||||
item-label-path="label"
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._statisticChanged}
|
||||
@filter-changed=${this._filterChanged}
|
||||
></ha-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _getStatisticIds() {
|
||||
this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes);
|
||||
}
|
||||
|
||||
private get _value() {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
private _statisticChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
let newValue = ev.detail.value;
|
||||
if (newValue === "__missing") {
|
||||
newValue = "";
|
||||
}
|
||||
|
||||
if (newValue !== this._value) {
|
||||
this._setValue(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
this._opened = ev.detail.value;
|
||||
}
|
||||
|
||||
private _fuseIndex = memoizeOne((states: StatisticItem[]) =>
|
||||
Fuse.createIndex(
|
||||
[
|
||||
"label",
|
||||
"entity_name",
|
||||
"device_name",
|
||||
"area_name",
|
||||
"friendly_name", // for backwards compatibility
|
||||
"id", // for technical search
|
||||
],
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -16,11 +16,11 @@ class HaStatisticsPicker extends LitElement {
|
||||
@property({ attribute: "statistic-types" })
|
||||
public statisticTypes?: "mean" | "sum";
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
@property({ attribute: "picked-statistic-label" })
|
||||
public pickedStatisticLabel?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public placeholder?: string;
|
||||
@property({ attribute: "pick-statistic-label" })
|
||||
public pickStatisticLabel?: string;
|
||||
|
||||
@property({ type: Boolean, attribute: "allow-custom-entity" })
|
||||
public allowCustomEntity;
|
||||
@@ -82,7 +82,6 @@ class HaStatisticsPicker extends LitElement {
|
||||
: this.statisticTypes;
|
||||
|
||||
return html`
|
||||
${this.label ? html`<label>${this.label}</label>` : nothing}
|
||||
${repeat(
|
||||
this._currentStatistics,
|
||||
(statisticId) => statisticId,
|
||||
@@ -97,6 +96,7 @@ class HaStatisticsPicker extends LitElement {
|
||||
.value=${statisticId}
|
||||
.statisticTypes=${includeStatisticTypesCurrent}
|
||||
.statisticIds=${this.statisticIds}
|
||||
.label=${this.pickedStatisticLabel}
|
||||
.excludeStatistics=${this.value}
|
||||
.allowCustomEntity=${this.allowCustomEntity}
|
||||
@value-changed=${this._statisticChanged}
|
||||
@@ -113,7 +113,7 @@ class HaStatisticsPicker extends LitElement {
|
||||
.includeDeviceClass=${this.includeDeviceClass}
|
||||
.statisticTypes=${this.statisticTypes}
|
||||
.statisticIds=${this.statisticIds}
|
||||
.placeholder=${this.placeholder}
|
||||
.label=${this.pickStatisticLabel}
|
||||
.excludeStatistics=${this.value}
|
||||
.allowCustomEntity=${this.allowCustomEntity}
|
||||
@value-changed=${this._addStatistic}
|
||||
@@ -181,10 +181,6 @@ class HaStatisticsPicker extends LitElement {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0 0 8px;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
@@ -108,7 +108,7 @@ class StateInfo extends LitElement {
|
||||
|
||||
.name.in-dialog,
|
||||
:host([secondary-line]) .name {
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
|
||||
.time-ago,
|
||||
|
@@ -1,16 +1,16 @@
|
||||
import { mdiTextureBox } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { stringCompare } from "../common/string/compare";
|
||||
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
|
||||
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import type { AreaRegistryEntry } from "../data/area_registry";
|
||||
import type {
|
||||
@@ -19,33 +19,29 @@ import type {
|
||||
} from "../data/device_registry";
|
||||
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
|
||||
import {
|
||||
getFloorAreaLookup,
|
||||
type FloorRegistryEntry,
|
||||
} from "../data/floor_registry";
|
||||
import type { FloorRegistryEntry } from "../data/floor_registry";
|
||||
import { getFloorAreaLookup } from "../data/floor_registry";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-combo-box";
|
||||
import type { HaComboBox } from "./ha-combo-box";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-floor-icon";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-icon-button";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry;
|
||||
|
||||
interface FloorComboBoxItem extends PickerComboBoxItem {
|
||||
type: "floor" | "area";
|
||||
floor?: FloorRegistryEntry;
|
||||
area?: AreaRegistryEntry;
|
||||
}
|
||||
|
||||
interface AreaFloorValue {
|
||||
id: string;
|
||||
interface FloorAreaEntry {
|
||||
id: string | null;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
strings: string[];
|
||||
type: "floor" | "area";
|
||||
level: number | null;
|
||||
hasFloor?: boolean;
|
||||
lastArea?: boolean;
|
||||
}
|
||||
|
||||
@customElement("ha-area-floor-picker")
|
||||
@@ -54,15 +50,12 @@ export class HaAreaFloorPicker extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property({ attribute: false }) public value?: AreaFloorValue;
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ type: String, attribute: "search-label" })
|
||||
public searchLabel?: string;
|
||||
|
||||
/**
|
||||
* Show only areas with entities from specific domains.
|
||||
* @type {Array}
|
||||
@@ -113,53 +106,66 @@ export class HaAreaFloorPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@state() private _opened?: boolean;
|
||||
|
||||
@query("ha-combo-box", true) public comboBox!: HaComboBox;
|
||||
|
||||
private _init = false;
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
await this.comboBox?.open();
|
||||
}
|
||||
|
||||
private _valueRenderer: PickerValueRenderer = (value: string) => {
|
||||
const item = this._parseValue(value);
|
||||
|
||||
const area = item.type === "area" && this.hass.areas[value];
|
||||
|
||||
if (area) {
|
||||
const areaName = computeAreaName(area);
|
||||
return html`
|
||||
${area.icon
|
||||
? html`<ha-icon slot="start" .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
<slot name="headline">${areaName}</slot>
|
||||
`;
|
||||
}
|
||||
|
||||
const floor = item.type === "floor" && this.hass.floors[value];
|
||||
|
||||
if (floor) {
|
||||
const floorName = computeFloorName(floor);
|
||||
return html`
|
||||
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
|
||||
<span slot="headline">${floorName}</span>
|
||||
`;
|
||||
}
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
await this.comboBox?.focus();
|
||||
}
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<FloorAreaEntry> = (item) => {
|
||||
const rtl = computeRTL(this.hass);
|
||||
return html`
|
||||
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
|
||||
<span slot="headline">${value}</span>
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
style=${item.type === "area" && item.hasFloor
|
||||
? "--md-list-item-leading-space: 48px;"
|
||||
: ""}
|
||||
>
|
||||
${item.type === "area" && item.hasFloor
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "48px",
|
||||
position: "absolute",
|
||||
top: "0px",
|
||||
left: rtl ? undefined : "4px",
|
||||
right: rtl ? "4px" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${item.lastArea}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
: nothing}
|
||||
${item.type === "floor"
|
||||
? html`<ha-floor-icon slot="start" .floor=${item}></ha-floor-icon>`
|
||||
: item.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
${item.name}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
private _getAreasAndFloors = memoizeOne(
|
||||
private _getAreas = memoizeOne(
|
||||
(
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
floors: FloorRegistryEntry[],
|
||||
areas: AreaRegistryEntry[],
|
||||
devices: DeviceRegistryEntry[],
|
||||
entities: EntityRegistryDisplayEntry[],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
@@ -167,11 +173,19 @@ export class HaAreaFloorPicker extends LitElement {
|
||||
entityFilter: this["entityFilter"],
|
||||
excludeAreas: this["excludeAreas"],
|
||||
excludeFloors: this["excludeFloors"]
|
||||
): FloorComboBoxItem[] => {
|
||||
const floors = Object.values(haFloors);
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
): FloorAreaEntry[] => {
|
||||
if (!areas.length && !floors.length) {
|
||||
return [
|
||||
{
|
||||
id: "no_areas",
|
||||
type: "area",
|
||||
name: this.hass.localize("ui.components.area-picker.no_areas"),
|
||||
icon: null,
|
||||
strings: [],
|
||||
level: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
@@ -312,6 +326,19 @@ export class HaAreaFloorPicker extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
if (!outputAreas.length) {
|
||||
return [
|
||||
{
|
||||
id: "no_areas",
|
||||
type: "area",
|
||||
name: this.hass.localize("ui.components.area-picker.no_match"),
|
||||
icon: null,
|
||||
strings: [],
|
||||
level: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const floorAreaLookup = getFloorAreaLookup(outputAreas);
|
||||
const unassisgnedAreas = Object.values(outputAreas).filter(
|
||||
(area) => !area.floor_id || !floorAreaLookup[area.floor_id]
|
||||
@@ -333,186 +360,151 @@ export class HaAreaFloorPicker extends LitElement {
|
||||
return stringCompare(floorA.name, floorB.name);
|
||||
});
|
||||
|
||||
const items: FloorComboBoxItem[] = [];
|
||||
const output: FloorAreaEntry[] = [];
|
||||
|
||||
floorAreaEntries.forEach(([floor, floorAreas]) => {
|
||||
if (floor) {
|
||||
const floorName = computeFloorName(floor);
|
||||
|
||||
const areaSearchLabels = floorAreas
|
||||
.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return [area.area_id, areaName, ...area.aliases];
|
||||
})
|
||||
.flat();
|
||||
|
||||
items.push({
|
||||
id: this._formatValue({ id: floor.floor_id, type: "floor" }),
|
||||
output.push({
|
||||
id: floor.floor_id,
|
||||
type: "floor",
|
||||
primary: floorName,
|
||||
floor: floor,
|
||||
search_labels: [
|
||||
floor.floor_id,
|
||||
floorName,
|
||||
...floor.aliases,
|
||||
...areaSearchLabels,
|
||||
],
|
||||
name: floor.name,
|
||||
icon: floor.icon,
|
||||
strings: [floor.floor_id, ...floor.aliases, floor.name],
|
||||
level: floor.level,
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
...floorAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: this._formatValue({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
area: area,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
})
|
||||
output.push(
|
||||
...floorAreas.map((area, index, array) => ({
|
||||
id: area.area_id,
|
||||
type: "area" as const,
|
||||
name: area.name,
|
||||
icon: area.icon,
|
||||
strings: [area.area_id, ...area.aliases, area.name],
|
||||
hasFloor: true,
|
||||
level: null,
|
||||
lastArea: index === array.length - 1,
|
||||
}))
|
||||
);
|
||||
});
|
||||
|
||||
items.push(
|
||||
...unassisgnedAreas.map((area) => {
|
||||
const areaName = computeAreaName(area) || area.area_id;
|
||||
return {
|
||||
id: this._formatValue({ id: area.area_id, type: "area" }),
|
||||
type: "area" as const,
|
||||
primary: areaName,
|
||||
icon: area.icon || undefined,
|
||||
search_labels: [area.area_id, areaName, ...area.aliases],
|
||||
};
|
||||
})
|
||||
if (!output.length && !unassisgnedAreas.length) {
|
||||
output.push({
|
||||
id: "no_areas",
|
||||
type: "area",
|
||||
name: this.hass.localize(
|
||||
"ui.components.area-picker.unassigned_areas"
|
||||
),
|
||||
icon: null,
|
||||
strings: [],
|
||||
level: null,
|
||||
});
|
||||
}
|
||||
|
||||
output.push(
|
||||
...unassisgnedAreas.map((area) => ({
|
||||
id: area.area_id,
|
||||
type: "area" as const,
|
||||
name: area.name,
|
||||
icon: area.icon,
|
||||
strings: [area.area_id, ...area.aliases, area.name],
|
||||
level: null,
|
||||
}))
|
||||
);
|
||||
|
||||
return items;
|
||||
return output;
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (
|
||||
item,
|
||||
{ index },
|
||||
combobox
|
||||
) => {
|
||||
const nextItem = combobox.filteredItems?.[index + 1];
|
||||
const isLastArea =
|
||||
!nextItem ||
|
||||
nextItem.type === "floor" ||
|
||||
(nextItem.type === "area" && !nextItem.area?.floor_id);
|
||||
|
||||
const rtl = computeRTL(this.hass);
|
||||
|
||||
const hasFloor = item.type === "area" && item.area?.floor_id;
|
||||
|
||||
return html`
|
||||
<ha-combo-box-item
|
||||
type="button"
|
||||
style=${item.type === "area" && hasFloor
|
||||
? "--md-list-item-leading-space: 48px;"
|
||||
: ""}
|
||||
>
|
||||
${item.type === "area" && hasFloor
|
||||
? html`
|
||||
<ha-tree-indicator
|
||||
style=${styleMap({
|
||||
width: "48px",
|
||||
position: "absolute",
|
||||
top: "0px",
|
||||
left: rtl ? undefined : "4px",
|
||||
right: rtl ? "4px" : undefined,
|
||||
transform: rtl ? "scaleX(-1)" : "",
|
||||
})}
|
||||
.end=${isLastArea}
|
||||
slot="start"
|
||||
></ha-tree-indicator>
|
||||
`
|
||||
: nothing}
|
||||
${item.type === "floor" && item.floor
|
||||
? html`<ha-floor-icon
|
||||
slot="start"
|
||||
.floor=${item.floor}
|
||||
></ha-floor-icon>`
|
||||
: item.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${item.icon_path || mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
${item.primary}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
};
|
||||
|
||||
private _getItems = () =>
|
||||
this._getAreasAndFloors(
|
||||
this.hass.floors,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.excludeAreas,
|
||||
this.excludeFloors
|
||||
);
|
||||
|
||||
private _formatValue = memoizeOne((value: AreaFloorValue): string =>
|
||||
[value.type, value.id].join(SEPARATOR)
|
||||
);
|
||||
|
||||
private _parseValue = memoizeOne((value: string): AreaFloorValue => {
|
||||
const [type, id] = value.split(SEPARATOR);
|
||||
|
||||
return { id, type: type as "floor" | "area" };
|
||||
});
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this._init && this.hass) ||
|
||||
(this._init && changedProps.has("_opened") && this._opened)
|
||||
) {
|
||||
this._init = true;
|
||||
const areas = this._getAreas(
|
||||
Object.values(this.hass.floors),
|
||||
Object.values(this.hass.areas),
|
||||
Object.values(this.hass.devices),
|
||||
Object.values(this.hass.entities),
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.excludeAreas,
|
||||
this.excludeFloors
|
||||
);
|
||||
this.comboBox.items = areas;
|
||||
this.comboBox.filteredItems = areas;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const placeholder =
|
||||
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
|
||||
|
||||
const value = this.value ? this._formatValue(this.value) : undefined;
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
<ha-combo-box
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${this.label}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${this.hass.localize(
|
||||
"ui.components.area-picker.no_match"
|
||||
)}
|
||||
.placeholder=${placeholder}
|
||||
.value=${value}
|
||||
.getItems=${this._getItems}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
.helper=${this.helper}
|
||||
item-value-path="id"
|
||||
item-id-path="id"
|
||||
item-label-path="name"
|
||||
.value=${this._value}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.label=${this.label === undefined && this.hass
|
||||
? this.hass.localize("ui.components.area-picker.area")
|
||||
: this.label}
|
||||
.placeholder=${this.placeholder
|
||||
? this.hass.areas[this.placeholder]?.name
|
||||
: undefined}
|
||||
.renderer=${this._rowRenderer}
|
||||
@filter-changed=${this._filterChanged}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._areaChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
</ha-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
private _filterChanged(ev: CustomEvent): void {
|
||||
const target = ev.target as HaComboBox;
|
||||
const filterString = ev.detail.value;
|
||||
if (!filterString) {
|
||||
this.comboBox.filteredItems = this.comboBox.items;
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this._parseValue(value);
|
||||
this._setValue(selected);
|
||||
const filteredItems = fuzzyFilterSort<ScorableAreaFloorEntry>(
|
||||
filterString,
|
||||
target.items || []
|
||||
);
|
||||
|
||||
this.comboBox.filteredItems = filteredItems;
|
||||
}
|
||||
|
||||
private _setValue(value?: AreaFloorValue) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
private get _value() {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
this._opened = ev.detail.value;
|
||||
}
|
||||
|
||||
private async _areaChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const newValue = ev.detail.value;
|
||||
|
||||
if (newValue === "no_areas") {
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.comboBox.selectedItem;
|
||||
|
||||
fireEvent(this, "value-changed", {
|
||||
value: {
|
||||
id: selected.id,
|
||||
type: selected.type,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,14 +1,15 @@
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import { mdiTextureBox } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { LitElement, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { getAreaContext } from "../common/entity/context/get_area_context";
|
||||
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
|
||||
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
|
||||
import type { AreaRegistryEntry } from "../data/area_registry";
|
||||
import { createAreaRegistryEntry } from "../data/area_registry";
|
||||
import type {
|
||||
DeviceEntityDisplayLookup,
|
||||
@@ -20,15 +21,26 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-combo-box";
|
||||
import type { HaComboBox } from "./ha-combo-box";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-icon-button";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry;
|
||||
|
||||
const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
${item.icon
|
||||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
|
||||
${item.name}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const ADD_NEW_ID = "___ADD_NEW___";
|
||||
const NO_ITEMS_ID = "___NO_ITEMS___";
|
||||
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
|
||||
|
||||
@customElement("ha-area-picker")
|
||||
export class HaAreaPicker extends LitElement {
|
||||
@@ -87,68 +99,41 @@ export class HaAreaPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@state() private _opened?: boolean;
|
||||
|
||||
@query("ha-combo-box", true) public comboBox!: HaComboBox;
|
||||
|
||||
private _suggestion?: string;
|
||||
|
||||
private _init = false;
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
await this.comboBox?.open();
|
||||
}
|
||||
|
||||
// Recompute value renderer when the areas change
|
||||
private _computeValueRenderer = memoizeOne(
|
||||
(_haAreas: HomeAssistant["areas"]): PickerValueRenderer =>
|
||||
(value) => {
|
||||
const area = this.hass.areas[value];
|
||||
|
||||
if (!area) {
|
||||
return html`
|
||||
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
|
||||
<span slot="headline">${area}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const { floor } = getAreaContext(area, this.hass);
|
||||
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
|
||||
const icon = area.icon;
|
||||
|
||||
return html`
|
||||
${icon
|
||||
? html`<ha-icon slot="start" .icon=${icon}></ha-icon>`
|
||||
: html`<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiTextureBox}
|
||||
></ha-svg-icon>`}
|
||||
<span slot="headline">${areaName}</span>
|
||||
${floorName
|
||||
? html`<span slot="supporting-text">${floorName}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
);
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
await this.comboBox?.focus();
|
||||
}
|
||||
|
||||
private _getAreas = memoizeOne(
|
||||
(
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
areas: AreaRegistryEntry[],
|
||||
devices: DeviceRegistryEntry[],
|
||||
entities: EntityRegistryDisplayEntry[],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
entityFilter: this["entityFilter"],
|
||||
noAdd: this["noAdd"],
|
||||
excludeAreas: this["excludeAreas"]
|
||||
): PickerComboBoxItem[] => {
|
||||
): AreaRegistryEntry[] => {
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
|
||||
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
|
||||
if (
|
||||
includeDomains ||
|
||||
excludeDomains ||
|
||||
@@ -278,147 +263,225 @@ export class HaAreaPicker extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
const items = outputAreas.map<PickerComboBoxItem>((area) => {
|
||||
const { floor } = getAreaContext(area, this.hass);
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
const areaName = computeAreaName(area);
|
||||
return {
|
||||
id: area.area_id,
|
||||
primary: areaName || area.area_id,
|
||||
secondary: floorName,
|
||||
icon: area.icon || undefined,
|
||||
icon_path: area.icon ? undefined : mdiTextureBox,
|
||||
sorting_label: areaName,
|
||||
search_labels: [
|
||||
areaName,
|
||||
floorName,
|
||||
area.area_id,
|
||||
...area.aliases,
|
||||
].filter((v): v is string => Boolean(v)),
|
||||
};
|
||||
});
|
||||
if (!outputAreas.length) {
|
||||
outputAreas = [
|
||||
{
|
||||
area_id: NO_ITEMS_ID,
|
||||
floor_id: null,
|
||||
name: this.hass.localize("ui.components.area-picker.no_areas"),
|
||||
picture: null,
|
||||
icon: null,
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _getItems = () =>
|
||||
this._getAreas(
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.excludeAreas
|
||||
);
|
||||
|
||||
private _allAreaNames = memoizeOne(
|
||||
(areas: HomeAssistant["areas"]) =>
|
||||
Object.values(areas)
|
||||
.map((area) => computeAreaName(area)?.toLowerCase())
|
||||
.filter(Boolean) as string[]
|
||||
);
|
||||
|
||||
private _getAdditionalItems = (
|
||||
searchString?: string
|
||||
): PickerComboBoxItem[] => {
|
||||
if (this.noAdd) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allAreas = this._allAreaNames(this.hass.areas);
|
||||
|
||||
if (searchString && !allAreas.includes(searchString.toLowerCase())) {
|
||||
return [
|
||||
{
|
||||
id: ADD_NEW_ID + searchString,
|
||||
primary: this.hass.localize(
|
||||
"ui.components.area-picker.add_new_sugestion",
|
||||
return noAdd
|
||||
? outputAreas
|
||||
: [
|
||||
...outputAreas,
|
||||
{
|
||||
name: searchString,
|
||||
}
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
},
|
||||
];
|
||||
area_id: ADD_NEW_ID,
|
||||
floor_id: null,
|
||||
name: this.hass.localize("ui.components.area-picker.add_new"),
|
||||
picture: null,
|
||||
icon: "mdi:plus",
|
||||
aliases: [],
|
||||
labels: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: ADD_NEW_ID,
|
||||
primary: this.hass.localize("ui.components.area-picker.add_new"),
|
||||
icon_path: mdiPlus,
|
||||
},
|
||||
];
|
||||
};
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this._init && this.hass) ||
|
||||
(this._init && changedProps.has("_opened") && this._opened)
|
||||
) {
|
||||
this._init = true;
|
||||
const areas = this._getAreas(
|
||||
Object.values(this.hass.areas),
|
||||
Object.values(this.hass.devices),
|
||||
Object.values(this.hass.entities),
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.noAdd,
|
||||
this.excludeAreas
|
||||
).map((area) => ({
|
||||
...area,
|
||||
strings: [area.area_id, ...area.aliases, area.name],
|
||||
}));
|
||||
this.comboBox.items = areas;
|
||||
this.comboBox.filteredItems = areas;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const placeholder =
|
||||
this.placeholder ?? this.hass.localize("ui.components.area-picker.area");
|
||||
|
||||
const valueRenderer = this._computeValueRenderer(this.hass.areas);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
<ha-combo-box
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${this.label}
|
||||
.notFoundLabel=${this.hass.localize(
|
||||
"ui.components.area-picker.no_match"
|
||||
)}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.valueRenderer=${valueRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
.helper=${this.helper}
|
||||
item-value-path="area_id"
|
||||
item-id-path="area_id"
|
||||
item-label-path="name"
|
||||
.value=${this._value}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.label=${this.label === undefined && this.hass
|
||||
? this.hass.localize("ui.components.area-picker.area")
|
||||
: this.label}
|
||||
.placeholder=${this.placeholder
|
||||
? this.hass.areas[this.placeholder]?.name
|
||||
: undefined}
|
||||
.renderer=${rowRenderer}
|
||||
@filter-changed=${this._filterChanged}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._areaChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
</ha-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
private _filterChanged(ev: CustomEvent): void {
|
||||
const target = ev.target as HaComboBox;
|
||||
const filterString = ev.detail.value;
|
||||
if (!filterString) {
|
||||
this.comboBox.filteredItems = this.comboBox.items;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith(ADD_NEW_ID)) {
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
const filteredItems = fuzzyFilterSort<ScorableAreaRegistryEntry>(
|
||||
filterString,
|
||||
target.items?.filter(
|
||||
(item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id)
|
||||
) || []
|
||||
);
|
||||
if (filteredItems.length === 0) {
|
||||
if (!this.noAdd) {
|
||||
this.comboBox.filteredItems = [
|
||||
{
|
||||
area_id: NO_ITEMS_ID,
|
||||
floor_id: null,
|
||||
name: this.hass.localize("ui.components.area-picker.no_match"),
|
||||
icon: null,
|
||||
picture: null,
|
||||
labels: [],
|
||||
aliases: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
] as AreaRegistryEntry[];
|
||||
} else {
|
||||
this._suggestion = filterString;
|
||||
this.comboBox.filteredItems = [
|
||||
{
|
||||
area_id: ADD_NEW_SUGGESTION_ID,
|
||||
floor_id: null,
|
||||
name: this.hass.localize(
|
||||
"ui.components.area-picker.add_new_sugestion",
|
||||
{ name: this._suggestion }
|
||||
),
|
||||
icon: "mdi:plus",
|
||||
picture: null,
|
||||
labels: [],
|
||||
aliases: [],
|
||||
temperature_entity_id: null,
|
||||
humidity_entity_id: null,
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
] as AreaRegistryEntry[];
|
||||
}
|
||||
} else {
|
||||
this.comboBox.filteredItems = filteredItems;
|
||||
}
|
||||
}
|
||||
|
||||
const suggestedName = value.substring(ADD_NEW_ID.length);
|
||||
private get _value() {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
showAreaRegistryDetailDialog(this, {
|
||||
suggestedName: suggestedName,
|
||||
createEntry: async (values) => {
|
||||
try {
|
||||
const area = await createAreaRegistryEntry(this.hass, values);
|
||||
this._setValue(area.area_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.components.area-picker.failed_create_area"
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
this._opened = ev.detail.value;
|
||||
}
|
||||
|
||||
private _areaChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
let newValue = ev.detail.value;
|
||||
|
||||
if (newValue === NO_ITEMS_ID) {
|
||||
newValue = "";
|
||||
this.comboBox.setInputValue("");
|
||||
return;
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
|
||||
if (newValue !== this._value) {
|
||||
this._setValue(newValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
(ev.target as any).value = this._value;
|
||||
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
|
||||
showAreaRegistryDetailDialog(this, {
|
||||
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
|
||||
createEntry: async (values) => {
|
||||
try {
|
||||
const area = await createAreaRegistryEntry(this.hass, values);
|
||||
const areas = [...Object.values(this.hass.areas), area];
|
||||
this.comboBox.filteredItems = this._getAreas(
|
||||
areas,
|
||||
Object.values(this.hass.devices)!,
|
||||
Object.values(this.hass.entities)!,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.noAdd,
|
||||
this.excludeAreas
|
||||
);
|
||||
await this.updateComplete;
|
||||
await this.comboBox.updateComplete;
|
||||
this._setValue(area.area_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.components.area-picker.failed_create_area"
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this._suggestion = undefined;
|
||||
this.comboBox.setInputValue("");
|
||||
}
|
||||
|
||||
private _setValue(value?: string) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
setTimeout(() => {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -44,7 +44,7 @@ export class HaAreasDisplayEditor extends LitElement {
|
||||
);
|
||||
|
||||
const items: DisplayItem[] = areas.map((area) => {
|
||||
const { floor } = getAreaContext(area, this.hass!);
|
||||
const { floor } = getAreaContext(area.area_id, this.hass!);
|
||||
return {
|
||||
value: area.area_id,
|
||||
label: area.name,
|
||||
|
@@ -5,11 +5,8 @@ import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import {
|
||||
type PipelineRunEvent,
|
||||
runAssistPipeline,
|
||||
type AssistPipeline,
|
||||
type ConversationChatLogAssistantDelta,
|
||||
type ConversationChatLogToolResultDelta,
|
||||
} from "../data/assist_pipeline";
|
||||
import { supportsFeature } from "../common/entity/supports-feature";
|
||||
import { ConversationEntityFeature } from "../data/conversation";
|
||||
@@ -93,7 +90,7 @@ export class HaAssistChat extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
this._audioRecorder?.close();
|
||||
this._audioRecorder = undefined;
|
||||
this._unloadAudio();
|
||||
this._audio?.pause();
|
||||
this._conversation = [];
|
||||
this._conversationId = null;
|
||||
}
|
||||
@@ -112,24 +109,25 @@ export class HaAssistChat extends LitElement {
|
||||
const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech;
|
||||
|
||||
return html`
|
||||
<div class="messages" id="scroll-container">
|
||||
${controlHA
|
||||
? nothing
|
||||
: html`
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.voice_command.conversation_no_control"
|
||||
)}
|
||||
</ha-alert>
|
||||
`}
|
||||
<div class="spacer"></div>
|
||||
${this._conversation!.map(
|
||||
// New lines matter for messages
|
||||
// prettier-ignore
|
||||
(message) => html`
|
||||
${controlHA
|
||||
? nothing
|
||||
: html`
|
||||
<ha-alert>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.voice_command.conversation_no_control"
|
||||
)}
|
||||
</ha-alert>
|
||||
`}
|
||||
<div class="messages">
|
||||
<div class="messages-container" id="scroll-container">
|
||||
${this._conversation!.map(
|
||||
// New lines matter for messages
|
||||
// prettier-ignore
|
||||
(message) => html`
|
||||
<div class="message ${classMap({ error: !!message.error, [message.who]: true })}">${message.text}</div>
|
||||
`
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="input" slot="primaryAction">
|
||||
<ha-textfield
|
||||
@@ -275,8 +273,8 @@ export class HaAssistChat extends LitElement {
|
||||
}
|
||||
|
||||
private async _startListening() {
|
||||
this._unloadAudio();
|
||||
this._processing = true;
|
||||
this._audio?.pause();
|
||||
if (!this._audioRecorder) {
|
||||
this._audioRecorder = new AudioRecorder((audio) => {
|
||||
if (this._audioBuffer) {
|
||||
@@ -295,36 +293,27 @@ export class HaAssistChat extends LitElement {
|
||||
await this._audioRecorder.start();
|
||||
|
||||
this._addMessage(userMessage);
|
||||
this.requestUpdate("_audioRecorder");
|
||||
|
||||
const hassMessageProcesser = this._createAddHassMessageProcessor();
|
||||
|
||||
let continueConversation = false;
|
||||
let hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
error: false,
|
||||
};
|
||||
let currentDeltaRole = "";
|
||||
// To make sure the answer is placed at the right user text, we add it before we process it
|
||||
try {
|
||||
const unsub = await runAssistPipeline(
|
||||
this.hass,
|
||||
(event: PipelineRunEvent) => {
|
||||
(event) => {
|
||||
if (event.type === "run-start") {
|
||||
this._stt_binary_handler_id =
|
||||
event.data.runner_data.stt_binary_handler_id;
|
||||
this._audio = new Audio(event.data.tts_output!.url);
|
||||
this._audio.play();
|
||||
this._audio.addEventListener("ended", () => {
|
||||
this._unloadAudio();
|
||||
if (hassMessageProcesser.continueConversation) {
|
||||
this._startListening();
|
||||
}
|
||||
});
|
||||
this._audio.addEventListener("pause", this._unloadAudio);
|
||||
this._audio.addEventListener("canplaythrough", () =>
|
||||
this._audio?.play()
|
||||
);
|
||||
this._audio.addEventListener("error", () => {
|
||||
this._unloadAudio();
|
||||
showAlertDialog(this, { title: "Error playing audio." });
|
||||
});
|
||||
}
|
||||
|
||||
// When we start STT stage, the WS has a binary handler
|
||||
else if (event.type === "stt-start" && this._audioBuffer) {
|
||||
if (event.type === "stt-start" && this._audioBuffer) {
|
||||
// Send the buffer over the WS to the STT engine.
|
||||
for (const buffer of this._audioBuffer) {
|
||||
this._sendAudioChunk(buffer);
|
||||
@@ -333,26 +322,91 @@ export class HaAssistChat extends LitElement {
|
||||
}
|
||||
|
||||
// Stop recording if the server is done with STT stage
|
||||
else if (event.type === "stt-end") {
|
||||
if (event.type === "stt-end") {
|
||||
this._stt_binary_handler_id = undefined;
|
||||
this._stopListening();
|
||||
userMessage.text = event.data.stt_output.text;
|
||||
this.requestUpdate("_conversation");
|
||||
// Add the response message placeholder to the chat when we know the STT is done
|
||||
hassMessageProcesser.addMessage();
|
||||
} else if (event.type.startsWith("intent-")) {
|
||||
hassMessageProcesser.processEvent(event);
|
||||
} else if (event.type === "run-end") {
|
||||
// To make sure the answer is placed at the right user text, we add it before we process it
|
||||
this._addMessage(hassMessage);
|
||||
}
|
||||
|
||||
if (event.type === "intent-progress") {
|
||||
const delta = event.data.chat_log_delta;
|
||||
|
||||
// new message
|
||||
if (delta.role) {
|
||||
// If currentDeltaRole exists, it means we're receiving our
|
||||
// second or later message. Let's add it to the chat.
|
||||
if (currentDeltaRole && delta.role && hassMessage.text !== "…") {
|
||||
// Remove progress indicator of previous message
|
||||
hassMessage.text = hassMessage.text.substring(
|
||||
0,
|
||||
hassMessage.text.length - 1
|
||||
);
|
||||
|
||||
hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
error: false,
|
||||
};
|
||||
this._addMessage(hassMessage);
|
||||
}
|
||||
currentDeltaRole = delta.role;
|
||||
}
|
||||
|
||||
if (
|
||||
currentDeltaRole === "assistant" &&
|
||||
"content" in delta &&
|
||||
delta.content
|
||||
) {
|
||||
hassMessage.text =
|
||||
hassMessage.text.substring(0, hassMessage.text.length - 1) +
|
||||
delta.content +
|
||||
"…";
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
continueConversation =
|
||||
event.data.intent_output.continue_conversation;
|
||||
const plain = event.data.intent_output.response.speech?.plain;
|
||||
if (plain) {
|
||||
hassMessage.text = plain.speech;
|
||||
}
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
|
||||
if (event.type === "tts-end") {
|
||||
const url = event.data.tts_output.url;
|
||||
this._audio = new Audio(url);
|
||||
this._audio.play();
|
||||
this._audio.addEventListener("ended", () => {
|
||||
this._unloadAudio();
|
||||
if (continueConversation) {
|
||||
this._startListening();
|
||||
}
|
||||
});
|
||||
this._audio.addEventListener("pause", this._unloadAudio);
|
||||
this._audio.addEventListener("canplaythrough", this._playAudio);
|
||||
this._audio.addEventListener("error", this._audioError);
|
||||
}
|
||||
|
||||
if (event.type === "run-end") {
|
||||
this._stt_binary_handler_id = undefined;
|
||||
unsub();
|
||||
} else if (event.type === "error") {
|
||||
this._unloadAudio();
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
this._stt_binary_handler_id = undefined;
|
||||
if (userMessage.text === "…") {
|
||||
userMessage.text = event.data.message;
|
||||
userMessage.error = true;
|
||||
} else {
|
||||
hassMessageProcesser.setError(event.data.message);
|
||||
hassMessage.text = event.data.message;
|
||||
hassMessage.error = true;
|
||||
}
|
||||
this._stopListening();
|
||||
this.requestUpdate("_conversation");
|
||||
@@ -410,33 +464,90 @@ export class HaAssistChat extends LitElement {
|
||||
this.hass.connection.socket!.send(data);
|
||||
}
|
||||
|
||||
private _playAudio = () => {
|
||||
this._audio?.play();
|
||||
};
|
||||
|
||||
private _audioError = () => {
|
||||
showAlertDialog(this, { title: "Error playing audio." });
|
||||
this._audio?.removeAttribute("src");
|
||||
};
|
||||
|
||||
private _unloadAudio = () => {
|
||||
if (!this._audio) {
|
||||
return;
|
||||
}
|
||||
this._audio.pause();
|
||||
this._audio.removeAttribute("src");
|
||||
this._audio?.removeAttribute("src");
|
||||
this._audio = undefined;
|
||||
};
|
||||
|
||||
private async _processText(text: string) {
|
||||
this._unloadAudio();
|
||||
this._processing = true;
|
||||
this._audio?.pause();
|
||||
this._addMessage({ who: "user", text });
|
||||
const hassMessageProcesser = this._createAddHassMessageProcessor();
|
||||
hassMessageProcesser.addMessage();
|
||||
let hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
error: false,
|
||||
};
|
||||
let currentDeltaRole = "";
|
||||
// To make sure the answer is placed at the right user text, we add it before we process it
|
||||
this._addMessage(hassMessage);
|
||||
try {
|
||||
const unsub = await runAssistPipeline(
|
||||
this.hass,
|
||||
(event) => {
|
||||
if (event.type.startsWith("intent-")) {
|
||||
hassMessageProcesser.processEvent(event);
|
||||
if (event.type === "intent-progress") {
|
||||
const delta = event.data.chat_log_delta;
|
||||
|
||||
// new message and previous message has content
|
||||
if (delta.role) {
|
||||
// If currentDeltaRole exists, it means we're receiving our
|
||||
// second or later message. Let's add it to the chat.
|
||||
if (
|
||||
currentDeltaRole &&
|
||||
delta.role === "assistant" &&
|
||||
hassMessage.text !== "…"
|
||||
) {
|
||||
// Remove progress indicator of previous message
|
||||
hassMessage.text = hassMessage.text.substring(
|
||||
0,
|
||||
hassMessage.text.length - 1
|
||||
);
|
||||
|
||||
hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
error: false,
|
||||
};
|
||||
this._addMessage(hassMessage);
|
||||
}
|
||||
currentDeltaRole = delta.role;
|
||||
}
|
||||
|
||||
if (
|
||||
currentDeltaRole === "assistant" &&
|
||||
"content" in delta &&
|
||||
delta.content
|
||||
) {
|
||||
hassMessage.text =
|
||||
hassMessage.text.substring(0, hassMessage.text.length - 1) +
|
||||
delta.content +
|
||||
"…";
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
const plain = event.data.intent_output.response.speech?.plain;
|
||||
if (plain) {
|
||||
hassMessage.text = plain.speech;
|
||||
}
|
||||
this.requestUpdate("_conversation");
|
||||
unsub();
|
||||
}
|
||||
if (event.type === "error") {
|
||||
hassMessageProcesser.setError(event.data.message);
|
||||
hassMessage.text = event.data.message;
|
||||
hassMessage.error = true;
|
||||
this.requestUpdate("_conversation");
|
||||
unsub();
|
||||
}
|
||||
},
|
||||
@@ -449,126 +560,20 @@ export class HaAssistChat extends LitElement {
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
hassMessageProcesser.setError(
|
||||
this.hass.localize("ui.dialogs.voice_command.error")
|
||||
);
|
||||
hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error");
|
||||
hassMessage.error = true;
|
||||
this.requestUpdate("_conversation");
|
||||
} finally {
|
||||
this._processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _createAddHassMessageProcessor() {
|
||||
let currentDeltaRole = "";
|
||||
|
||||
const progressToNextMessage = () => {
|
||||
if (progress.hassMessage.text === "…") {
|
||||
return;
|
||||
}
|
||||
progress.hassMessage.text = progress.hassMessage.text.substring(
|
||||
0,
|
||||
progress.hassMessage.text.length - 1
|
||||
);
|
||||
|
||||
progress.hassMessage = {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
error: false,
|
||||
};
|
||||
this._addMessage(progress.hassMessage);
|
||||
};
|
||||
|
||||
const isAssistantDelta = (
|
||||
_delta: any
|
||||
): _delta is Partial<ConversationChatLogAssistantDelta> =>
|
||||
currentDeltaRole === "assistant";
|
||||
|
||||
const isToolResult = (
|
||||
_delta: any
|
||||
): _delta is ConversationChatLogToolResultDelta =>
|
||||
currentDeltaRole === "tool_result";
|
||||
|
||||
const tools: Record<
|
||||
string,
|
||||
ConversationChatLogAssistantDelta["tool_calls"][0]
|
||||
> = {};
|
||||
|
||||
const progress = {
|
||||
continueConversation: false,
|
||||
hassMessage: {
|
||||
who: "hass",
|
||||
text: "…",
|
||||
error: false,
|
||||
},
|
||||
addMessage: () => {
|
||||
this._addMessage(progress.hassMessage);
|
||||
},
|
||||
setError: (error: string) => {
|
||||
progressToNextMessage();
|
||||
progress.hassMessage.text = error;
|
||||
progress.hassMessage.error = true;
|
||||
this.requestUpdate("_conversation");
|
||||
},
|
||||
processEvent: (event: PipelineRunEvent) => {
|
||||
if (event.type === "intent-progress") {
|
||||
const delta = event.data.chat_log_delta;
|
||||
|
||||
// new message
|
||||
if (delta.role) {
|
||||
progressToNextMessage();
|
||||
currentDeltaRole = delta.role;
|
||||
}
|
||||
|
||||
if (isAssistantDelta(delta)) {
|
||||
if (delta.content) {
|
||||
progress.hassMessage.text =
|
||||
progress.hassMessage.text.substring(
|
||||
0,
|
||||
progress.hassMessage.text.length - 1
|
||||
) +
|
||||
delta.content +
|
||||
"…";
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
if (delta.tool_calls) {
|
||||
for (const toolCall of delta.tool_calls) {
|
||||
tools[toolCall.id] = toolCall;
|
||||
}
|
||||
}
|
||||
} else if (isToolResult(delta)) {
|
||||
if (tools[delta.tool_call_id]) {
|
||||
delete tools[delta.tool_call_id];
|
||||
}
|
||||
}
|
||||
} else if (event.type === "intent-end") {
|
||||
this._conversationId = event.data.intent_output.conversation_id;
|
||||
progress.continueConversation =
|
||||
event.data.intent_output.continue_conversation;
|
||||
const response =
|
||||
event.data.intent_output.response.speech?.plain.speech;
|
||||
if (!response) {
|
||||
return;
|
||||
}
|
||||
if (event.data.intent_output.response.response_type === "error") {
|
||||
progress.setError(response);
|
||||
} else {
|
||||
progress.hassMessage.text = response;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
return progress;
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
ha-alert {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
ha-textfield {
|
||||
display: block;
|
||||
}
|
||||
@@ -576,14 +581,17 @@ export class HaAssistChat extends LitElement {
|
||||
flex: 1;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
.messages-container {
|
||||
position: absolute;
|
||||
bottom: 0px;
|
||||
right: 0px;
|
||||
left: 0px;
|
||||
padding: 0px 10px 16px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 12px 16px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.message {
|
||||
white-space: pre-line;
|
||||
@@ -593,9 +601,6 @@ export class HaAssistChat extends LitElement {
|
||||
padding: 8px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.message:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media all and (max-width: 450px), all and (max-height: 500px) {
|
||||
.message {
|
||||
@@ -614,7 +619,7 @@ export class HaAssistChat extends LitElement {
|
||||
margin-left: 24px;
|
||||
margin-inline-start: 24px;
|
||||
margin-inline-end: initial;
|
||||
align-self: flex-end;
|
||||
float: var(--float-end);
|
||||
text-align: right;
|
||||
border-bottom-right-radius: 0px;
|
||||
background-color: var(--chat-background-color-user, var(--primary-color));
|
||||
@@ -626,7 +631,7 @@ export class HaAssistChat extends LitElement {
|
||||
margin-right: 24px;
|
||||
margin-inline-end: 24px;
|
||||
margin-inline-start: initial;
|
||||
align-self: flex-start;
|
||||
float: var(--float-start);
|
||||
border-bottom-left-radius: 0px;
|
||||
background-color: var(
|
||||
--chat-background-color-hass,
|
||||
|
@@ -97,21 +97,21 @@ export class HaBadge extends LitElement {
|
||||
.label {
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-style: normal;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: 10px;
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: 0.1px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.content {
|
||||
font-size: var(--ha-badge-font-size, var(--ha-font-size-s));
|
||||
font-size: var(--ha-font-size-badge, var(--ha-font-size-s));
|
||||
font-style: normal;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
letter-spacing: 0.1px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
::slotted([slot="icon"]) {
|
||||
--mdc-icon-size: var(--ha-badge-icon-size, 18px);
|
||||
--mdc-icon-size: var(--ha-icon-size-badge, 18px);
|
||||
color: var(--badge-color);
|
||||
line-height: 0;
|
||||
margin-left: -4px;
|
||||
|
@@ -81,27 +81,27 @@ export class HaBaseTimeInput extends LitElement {
|
||||
/**
|
||||
* Label for the day input
|
||||
*/
|
||||
@property({ type: String, attribute: "day-label" }) dayLabel = "";
|
||||
@property({ attribute: false }) dayLabel = "";
|
||||
|
||||
/**
|
||||
* Label for the hour input
|
||||
*/
|
||||
@property({ type: String, attribute: "hour-label" }) hourLabel = "";
|
||||
@property({ attribute: false }) hourLabel = "";
|
||||
|
||||
/**
|
||||
* Label for the min input
|
||||
*/
|
||||
@property({ type: String, attribute: "min-label" }) minLabel = "";
|
||||
@property({ attribute: false }) minLabel = "";
|
||||
|
||||
/**
|
||||
* Label for the sec input
|
||||
*/
|
||||
@property({ type: String, attribute: "sec-label" }) secLabel = "";
|
||||
@property({ attribute: false }) secLabel = "";
|
||||
|
||||
/**
|
||||
* Label for the milli sec input
|
||||
*/
|
||||
@property({ type: String, attribute: "ms-label" }) millisecLabel = "";
|
||||
@property({ attribute: false }) millisecLabel = "";
|
||||
|
||||
/**
|
||||
* show the sec field
|
||||
@@ -342,7 +342,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
padding-right: 3px;
|
||||
}
|
||||
ha-textfield {
|
||||
width: 60px;
|
||||
width: 55px;
|
||||
flex-grow: 1;
|
||||
text-align: center;
|
||||
--mdc-shape-small: 0;
|
||||
@@ -381,16 +381,16 @@ export class HaBaseTimeInput extends LitElement {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
label {
|
||||
-moz-osx-font-smoothing: var(--ha-moz-osx-font-smoothing);
|
||||
-webkit-font-smoothing: var(--ha-font-smoothing);
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-family: var(
|
||||
--mdc-typography-body2-font-family,
|
||||
var(--mdc-typography-font-family, var(--ha-font-family-body))
|
||||
var(--mdc-typography-font-family, Roboto, sans-serif)
|
||||
);
|
||||
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)
|
||||
var(--ha-line-height-normal)
|
||||
);
|
||||
font-weight: var(
|
||||
--mdc-typography-body2-font-weight,
|
||||
@@ -409,7 +409,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
}
|
||||
ha-input-helper-text {
|
||||
padding-top: 8px;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
@@ -59,7 +59,7 @@ export class HaBigNumber extends LitElement {
|
||||
css`
|
||||
:host {
|
||||
font-size: 57px;
|
||||
line-height: 1.12;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
letter-spacing: -0.25px;
|
||||
}
|
||||
.value {
|
||||
@@ -87,7 +87,7 @@ export class HaBigNumber extends LitElement {
|
||||
}
|
||||
.value .decimal {
|
||||
font-size: 0.42em;
|
||||
line-height: 1.33;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
min-height: 1.33em;
|
||||
}
|
||||
.value .unit {
|
||||
|
@@ -17,9 +17,6 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
:host([border-top]) md-item {
|
||||
border-top: 1px solid var(--divider-color);
|
||||
}
|
||||
[slot="start"] {
|
||||
--state-icon-color: var(--secondary-text-color);
|
||||
}
|
||||
[slot="headline"] {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
font-size: var(--ha-font-size-m);
|
||||
@@ -35,20 +32,6 @@ export class HaComboBoxItem extends HaMdListItem {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
::slotted(.code) {
|
||||
font-family: var(--ha-font-family-code);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
}
|
||||
::slotted(.domain) {
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
align-self: flex-end;
|
||||
max-width: 30%;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
@@ -1,24 +0,0 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { HaTextField } from "./ha-textfield";
|
||||
|
||||
@customElement("ha-combo-box-textfield")
|
||||
export class HaComboBoxTextField extends HaTextField {
|
||||
@property({ type: Boolean, attribute: "disable-set-value" })
|
||||
public disableSetValue = false;
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("value")) {
|
||||
if (this.disableSetValue) {
|
||||
this.value = changedProps.get("value") as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-combo-box-textfield": HaComboBoxTextField;
|
||||
}
|
||||
}
|
@@ -12,12 +12,11 @@ import type {
|
||||
import { registerStyles } from "@vaadin/vaadin-themable-mixin/register-styles";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-combo-box-textfield";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-textfield";
|
||||
import type { HaTextField } from "./ha-textfield";
|
||||
@@ -109,14 +108,9 @@ export class HaComboBox extends LitElement {
|
||||
@property({ type: Boolean, attribute: "hide-clear-icon" })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "clear-initial-value" })
|
||||
public clearInitialValue = false;
|
||||
|
||||
@query("vaadin-combo-box-light", true) private _comboBox!: ComboBoxLight;
|
||||
|
||||
@query("ha-combo-box-textfield", true) private _inputElement!: HaTextField;
|
||||
|
||||
@state({ type: Boolean }) private _disableSetValue = false;
|
||||
@query("ha-textfield", true) private _inputElement!: HaTextField;
|
||||
|
||||
private _overlayMutationObserver?: MutationObserver;
|
||||
|
||||
@@ -153,10 +147,6 @@ export class HaComboBox extends LitElement {
|
||||
this._comboBox.value = value;
|
||||
}
|
||||
|
||||
public setTextFieldValue(value: string) {
|
||||
this._inputElement.value = value;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<!-- @ts-ignore Tag definition is not included in theme folder -->
|
||||
@@ -177,7 +167,7 @@ export class HaComboBox extends LitElement {
|
||||
@value-changed=${this._valueChanged}
|
||||
attr-for-value="value"
|
||||
>
|
||||
<ha-combo-box-textfield
|
||||
<ha-textfield
|
||||
label=${ifDefined(this.label)}
|
||||
placeholder=${ifDefined(this.placeholder)}
|
||||
?disabled=${this.disabled}
|
||||
@@ -197,10 +187,9 @@ export class HaComboBox extends LitElement {
|
||||
.invalid=${this.invalid}
|
||||
.helper=${this.helper}
|
||||
helperPersistent
|
||||
.disableSetValue=${this._disableSetValue}
|
||||
>
|
||||
<slot name="icon" slot="leadingIcon"></slot>
|
||||
</ha-combo-box-textfield>
|
||||
</ha-textfield>
|
||||
${this.value && !this.hideClearIcon
|
||||
? html`<ha-svg-icon
|
||||
role="button"
|
||||
@@ -253,20 +242,8 @@ export class HaComboBox extends LitElement {
|
||||
// delay this so we can handle click event for toggle button before setting _opened
|
||||
setTimeout(() => {
|
||||
this.opened = opened;
|
||||
fireEvent(this, "opened-changed", { value: ev.detail.value });
|
||||
}, 0);
|
||||
|
||||
if (this.clearInitialValue) {
|
||||
this.setTextFieldValue("");
|
||||
if (opened) {
|
||||
// Wait 100ms to be sure vaddin-combo-box-light already tried to set the value
|
||||
setTimeout(() => {
|
||||
this._disableSetValue = false;
|
||||
}, 100);
|
||||
} else {
|
||||
this._disableSetValue = true;
|
||||
}
|
||||
}
|
||||
fireEvent(this, "opened-changed", { value: ev.detail.value });
|
||||
|
||||
if (opened) {
|
||||
const overlay = document.querySelector<HTMLElement>(
|
||||
@@ -361,10 +338,10 @@ export class HaComboBox extends LitElement {
|
||||
position: relative;
|
||||
--vaadin-combo-box-overlay-max-height: calc(45vh - 56px);
|
||||
}
|
||||
ha-combo-box-textfield {
|
||||
ha-textfield {
|
||||
width: 100%;
|
||||
}
|
||||
ha-combo-box-textfield > ha-icon-button {
|
||||
ha-textfield > ha-icon-button {
|
||||
--mdc-icon-button-size: 24px;
|
||||
padding: 2px;
|
||||
color: var(--secondary-text-color);
|
||||
|
@@ -58,8 +58,8 @@ export class HaControlButton extends LitElement {
|
||||
padding: var(--control-button-padding);
|
||||
box-sizing: border-box;
|
||||
line-height: inherit;
|
||||
font-family: var(--ha-font-family-body);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-family: Roboto;
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
outline: none;
|
||||
overflow: hidden;
|
||||
background: none;
|
||||
|
@@ -194,7 +194,7 @@ export class HaControlNumberButton extends LitElement {
|
||||
color: var(--primary-text-color);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
font-style: normal;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
transition: color 180ms ease-in-out;
|
||||
}
|
||||
:host([disabled]) {
|
||||
|
@@ -180,7 +180,7 @@ export class HaControlSelectMenu extends SelectBase {
|
||||
--mdc-icon-size: 20px;
|
||||
--ha-ripple-color: var(--secondary-text-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: 1.4;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
width: auto;
|
||||
color: var(--primary-text-color);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
@@ -207,7 +207,7 @@ export class HaControlSelect extends LitElement {
|
||||
outline: none;
|
||||
transition: box-shadow 180ms ease-in-out;
|
||||
font-style: normal;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
color: var(--primary-text-color);
|
||||
user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
@@ -54,12 +54,12 @@ export class HaDialogHeader extends LitElement {
|
||||
}
|
||||
.header-title {
|
||||
font-size: var(--ha-font-size-xl);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
}
|
||||
.header-subtitle {
|
||||
font-size: var(--ha-font-size-m);
|
||||
line-height: 20px;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
@media all and (min-width: 450px) and (min-height: 500px) {
|
||||
|
@@ -86,11 +86,11 @@ export class HaDialog extends DialogBase {
|
||||
);
|
||||
--mdc-dialog-box-shadow: var(--dialog-box-shadow, none);
|
||||
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
|
||||
--mdc-typography-headline6-font-size: 1.574rem;
|
||||
--mdc-typography-headline6-font-size: var(--ha-font-size-xl);
|
||||
}
|
||||
.mdc-dialog__actions {
|
||||
justify-content: var(--justify-action-buttons, flex-end);
|
||||
padding: 12px 24px max(var(--safe-area-inset-bottom), 12px) 24px;
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 24px);
|
||||
}
|
||||
.mdc-dialog__actions span:nth-child(1) {
|
||||
flex: var(--secondary-action-button-flex, unset);
|
||||
@@ -107,6 +107,9 @@ export class HaDialog extends DialogBase {
|
||||
.mdc-dialog__title:has(span) {
|
||||
padding: 12px 12px 0;
|
||||
}
|
||||
.mdc-dialog__actions {
|
||||
padding: 12px 24px 12px 24px;
|
||||
}
|
||||
.mdc-dialog__title::before {
|
||||
content: unset;
|
||||
}
|
||||
@@ -117,7 +120,7 @@ export class HaDialog extends DialogBase {
|
||||
:host([hideactions]) .mdc-dialog .mdc-dialog__content {
|
||||
padding-bottom: max(
|
||||
var(--dialog-content-padding, 24px),
|
||||
var(--safe-area-inset-bottom)
|
||||
env(safe-area-inset-bottom)
|
||||
);
|
||||
}
|
||||
.mdc-dialog .mdc-dialog__surface {
|
||||
|
@@ -52,11 +52,11 @@ class HaDurationInput extends LitElement {
|
||||
.milliseconds=${this._milliseconds}
|
||||
@value-changed=${this._durationChanged}
|
||||
no-hours-limit
|
||||
day-label="dd"
|
||||
hour-label="hh"
|
||||
min-label="mm"
|
||||
sec-label="ss"
|
||||
ms-label="ms"
|
||||
dayLabel="dd"
|
||||
hourLabel="hh"
|
||||
minLabel="mm"
|
||||
secLabel="ss"
|
||||
millisecLabel="ms"
|
||||
></ha-base-time-input>
|
||||
`;
|
||||
}
|
||||
|
@@ -188,7 +188,7 @@ export class HaExpansionPanel extends LitElement {
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
outline: none;
|
||||
}
|
||||
#summary.noCollapse {
|
||||
@@ -202,7 +202,6 @@ export class HaExpansionPanel extends LitElement {
|
||||
.header,
|
||||
::slotted([slot="header"]) {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.container {
|
||||
|
@@ -324,7 +324,7 @@ export class HaFileUpload extends LitElement {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.header {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
}
|
||||
.progress {
|
||||
color: var(--secondary-text-color);
|
||||
|
@@ -208,8 +208,8 @@ export class HaFilterBlueprints extends LitElement {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -303,8 +303,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -232,8 +232,8 @@ export class HaFilterDevices extends LitElement {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -189,8 +189,8 @@ export class HaFilterDomains extends LitElement {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -246,8 +246,8 @@ export class HaFilterEntities extends LitElement {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -303,8 +303,8 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -195,8 +195,8 @@ export class HaFilterIntegrations extends LitElement {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -233,8 +233,8 @@ export class HaFilterLabels extends SubscribeMixin(LitElement) {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -177,8 +177,8 @@ export class HaFilterStates extends LitElement {
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
|
@@ -1,13 +1,14 @@
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type { TemplateResult } from "lit";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, html } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import type { ScorableTextItem } from "../common/string/filter/sequence-matching";
|
||||
import { fuzzyFilterSort } from "../common/string/filter/sequence-matching";
|
||||
import type { AreaRegistryEntry } from "../data/area_registry";
|
||||
import { updateAreaRegistryEntry } from "../data/area_registry";
|
||||
import type {
|
||||
DeviceEntityDisplayLookup,
|
||||
@@ -15,29 +16,33 @@ import type {
|
||||
} from "../data/device_registry";
|
||||
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
|
||||
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
|
||||
import type { FloorRegistryEntry } from "../data/floor_registry";
|
||||
import {
|
||||
createFloorRegistryEntry,
|
||||
getFloorAreaLookup,
|
||||
type FloorRegistryEntry,
|
||||
} from "../data/floor_registry";
|
||||
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
|
||||
import { showFloorRegistryDetailDialog } from "../panels/config/areas/show-dialog-floor-registry-detail";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-combo-box";
|
||||
import type { HaComboBox } from "./ha-combo-box";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-floor-icon";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-icon-button";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import type { PickerValueRenderer } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
type ScorableFloorRegistryEntry = ScorableTextItem & FloorRegistryEntry;
|
||||
|
||||
const ADD_NEW_ID = "___ADD_NEW___";
|
||||
const NO_FLOORS_ID = "___NO_FLOORS___";
|
||||
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
|
||||
|
||||
interface FloorComboBoxItem extends PickerComboBoxItem {
|
||||
floor?: FloorRegistryEntry;
|
||||
}
|
||||
const rowRenderer: ComboBoxLitRenderer<FloorRegistryEntry> = (item) => html`
|
||||
<ha-combo-box-item type="button">
|
||||
<ha-floor-icon slot="start" .floor=${item}></ha-floor-icon>
|
||||
${item.name}
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
@customElement("ha-floor-picker")
|
||||
export class HaFloorPicker extends LitElement {
|
||||
@@ -83,7 +88,7 @@ export class HaFloorPicker extends LitElement {
|
||||
* @type {Array}
|
||||
* @attr exclude-floors
|
||||
*/
|
||||
@property({ type: Array, attribute: "exclude-floors" })
|
||||
@property({ type: Array, attribute: "exclude-floor" })
|
||||
public excludeFloors?: string[];
|
||||
|
||||
@property({ attribute: false })
|
||||
@@ -96,53 +101,38 @@ export class HaFloorPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@state() private _opened?: boolean;
|
||||
|
||||
@query("ha-combo-box", true) public comboBox!: HaComboBox;
|
||||
|
||||
private _suggestion?: string;
|
||||
|
||||
private _init = false;
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
await this.comboBox?.open();
|
||||
}
|
||||
|
||||
// Recompute value renderer when the areas change
|
||||
private _computeValueRenderer = memoizeOne(
|
||||
(_haAreas: HomeAssistant["floors"]): PickerValueRenderer =>
|
||||
(value) => {
|
||||
const floor = this.hass.floors[value];
|
||||
|
||||
if (!floor) {
|
||||
return html`
|
||||
<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>
|
||||
<span slot="headline">${floor}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
const floorName = floor ? computeFloorName(floor) : undefined;
|
||||
|
||||
return html`
|
||||
<ha-floor-icon slot="start" .floor=${floor}></ha-floor-icon>
|
||||
<span slot="headline">${floorName}</span>
|
||||
`;
|
||||
}
|
||||
);
|
||||
public async focus() {
|
||||
await this.updateComplete;
|
||||
await this.comboBox?.focus();
|
||||
}
|
||||
|
||||
private _getFloors = memoizeOne(
|
||||
(
|
||||
haFloors: HomeAssistant["floors"],
|
||||
haAreas: HomeAssistant["areas"],
|
||||
haDevices: HomeAssistant["devices"],
|
||||
haEntities: HomeAssistant["entities"],
|
||||
floors: FloorRegistryEntry[],
|
||||
areas: AreaRegistryEntry[],
|
||||
devices: DeviceRegistryEntry[],
|
||||
entities: EntityRegistryDisplayEntry[],
|
||||
includeDomains: this["includeDomains"],
|
||||
excludeDomains: this["excludeDomains"],
|
||||
includeDeviceClasses: this["includeDeviceClasses"],
|
||||
deviceFilter: this["deviceFilter"],
|
||||
entityFilter: this["entityFilter"],
|
||||
noAdd: this["noAdd"],
|
||||
excludeFloors: this["excludeFloors"]
|
||||
): FloorComboBoxItem[] => {
|
||||
const floors = Object.values(haFloors);
|
||||
const areas = Object.values(haAreas);
|
||||
const devices = Object.values(haDevices);
|
||||
const entities = Object.values(haEntities);
|
||||
|
||||
): FloorRegistryEntry[] => {
|
||||
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
|
||||
let inputDevices: DeviceRegistryEntry[] | undefined;
|
||||
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
|
||||
@@ -279,169 +269,216 @@ export class HaFloorPicker extends LitElement {
|
||||
);
|
||||
}
|
||||
|
||||
const items = outputFloors.map<FloorComboBoxItem>((floor) => {
|
||||
const floorName = computeFloorName(floor);
|
||||
return {
|
||||
id: floor.floor_id,
|
||||
primary: floorName,
|
||||
floor: floor,
|
||||
sorting_label: floor.level?.toString() || "zzzzz",
|
||||
search_labels: [floorName, floor.floor_id, ...floor.aliases].filter(
|
||||
(v): v is string => Boolean(v)
|
||||
),
|
||||
};
|
||||
});
|
||||
if (!outputFloors.length) {
|
||||
outputFloors = [
|
||||
{
|
||||
floor_id: NO_FLOORS_ID,
|
||||
name: this.hass.localize("ui.components.floor-picker.no_floors"),
|
||||
icon: null,
|
||||
level: null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
private _rowRenderer: ComboBoxLitRenderer<FloorComboBoxItem> = (item) => html`
|
||||
<ha-combo-box-item type="button" compact>
|
||||
${item.icon_path
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
style="margin: 0 4px"
|
||||
.path=${item.icon_path}
|
||||
></ha-svg-icon>
|
||||
`
|
||||
: html`
|
||||
<ha-floor-icon
|
||||
slot="start"
|
||||
.floor=${item.floor}
|
||||
style="margin: 0 4px"
|
||||
></ha-floor-icon>
|
||||
`}
|
||||
<span slot="headline">${item.primary}</span>
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
private _getItems = () =>
|
||||
this._getFloors(
|
||||
this.hass.floors,
|
||||
this.hass.areas,
|
||||
this.hass.devices,
|
||||
this.hass.entities,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.excludeFloors
|
||||
);
|
||||
|
||||
private _allFloorNames = memoizeOne(
|
||||
(floors: HomeAssistant["floors"]) =>
|
||||
Object.values(floors)
|
||||
.map((floor) => computeFloorName(floor)?.toLowerCase())
|
||||
.filter(Boolean) as string[]
|
||||
);
|
||||
|
||||
private _getAdditionalItems = (
|
||||
searchString?: string
|
||||
): PickerComboBoxItem[] => {
|
||||
if (this.noAdd) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allFloors = this._allFloorNames(this.hass.floors);
|
||||
|
||||
if (searchString && !allFloors.includes(searchString.toLowerCase())) {
|
||||
return [
|
||||
{
|
||||
id: ADD_NEW_ID + searchString,
|
||||
primary: this.hass.localize(
|
||||
"ui.components.floor-picker.add_new_sugestion",
|
||||
return noAdd
|
||||
? outputFloors
|
||||
: [
|
||||
...outputFloors,
|
||||
{
|
||||
name: searchString,
|
||||
}
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
},
|
||||
];
|
||||
floor_id: ADD_NEW_ID,
|
||||
name: this.hass.localize("ui.components.floor-picker.add_new"),
|
||||
icon: "mdi:plus",
|
||||
level: null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
return [
|
||||
{
|
||||
id: ADD_NEW_ID,
|
||||
primary: this.hass.localize("ui.components.floor-picker.add_new"),
|
||||
icon_path: mdiPlus,
|
||||
},
|
||||
];
|
||||
};
|
||||
protected updated(changedProps: PropertyValues) {
|
||||
if (
|
||||
(!this._init && this.hass) ||
|
||||
(this._init && changedProps.has("_opened") && this._opened)
|
||||
) {
|
||||
this._init = true;
|
||||
const floors = this._getFloors(
|
||||
Object.values(this.hass.floors),
|
||||
Object.values(this.hass.areas),
|
||||
Object.values(this.hass.devices),
|
||||
Object.values(this.hass.entities),
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.noAdd,
|
||||
this.excludeFloors
|
||||
).map((floor) => ({
|
||||
...floor,
|
||||
strings: [floor.floor_id, floor.name, ...floor.aliases],
|
||||
}));
|
||||
this.comboBox.items = floors;
|
||||
this.comboBox.filteredItems = floors;
|
||||
}
|
||||
}
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.floor-picker.floor");
|
||||
|
||||
const valueRenderer = this._computeValueRenderer(this.hass.floors);
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
<ha-combo-box
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.label=${this.label}
|
||||
.notFoundLabel=${this.hass.localize(
|
||||
"ui.components.floor-picker.no_match"
|
||||
)}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.valueRenderer=${valueRenderer}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
@value-changed=${this._valueChanged}
|
||||
.helper=${this.helper}
|
||||
item-value-path="floor_id"
|
||||
item-id-path="floor_id"
|
||||
item-label-path="name"
|
||||
.value=${this._value}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.label=${this.label === undefined && this.hass
|
||||
? this.hass.localize("ui.components.floor-picker.floor")
|
||||
: this.label}
|
||||
.placeholder=${this.placeholder
|
||||
? this.hass.floors[this.placeholder]?.name
|
||||
: undefined}
|
||||
.renderer=${rowRenderer}
|
||||
@filter-changed=${this._filterChanged}
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._floorChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
</ha-combo-box>
|
||||
`;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
private _filterChanged(ev: CustomEvent): void {
|
||||
const target = ev.target as HaComboBox;
|
||||
const filterString = ev.detail.value;
|
||||
if (!filterString) {
|
||||
this.comboBox.filteredItems = this.comboBox.items;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith(ADD_NEW_ID)) {
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
const filteredItems = fuzzyFilterSort<ScorableFloorRegistryEntry>(
|
||||
filterString,
|
||||
target.items?.filter(
|
||||
(item) => ![NO_FLOORS_ID, ADD_NEW_ID].includes(item.label_id)
|
||||
) || []
|
||||
);
|
||||
if (filteredItems.length === 0) {
|
||||
if (this.noAdd) {
|
||||
this.comboBox.filteredItems = [
|
||||
{
|
||||
floor_id: NO_FLOORS_ID,
|
||||
name: this.hass.localize("ui.components.floor-picker.no_match"),
|
||||
icon: null,
|
||||
level: null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
] as FloorRegistryEntry[];
|
||||
} else {
|
||||
this._suggestion = filterString;
|
||||
this.comboBox.filteredItems = [
|
||||
{
|
||||
floor_id: ADD_NEW_SUGGESTION_ID,
|
||||
name: this.hass.localize(
|
||||
"ui.components.floor-picker.add_new_sugestion",
|
||||
{ name: this._suggestion }
|
||||
),
|
||||
icon: "mdi:plus",
|
||||
level: null,
|
||||
aliases: [],
|
||||
created_at: 0,
|
||||
modified_at: 0,
|
||||
},
|
||||
] as FloorRegistryEntry[];
|
||||
}
|
||||
} else {
|
||||
this.comboBox.filteredItems = filteredItems;
|
||||
}
|
||||
}
|
||||
|
||||
const suggestedName = value.substring(ADD_NEW_ID.length);
|
||||
private get _value() {
|
||||
return this.value || "";
|
||||
}
|
||||
|
||||
showFloorRegistryDetailDialog(this, {
|
||||
suggestedName: suggestedName,
|
||||
createEntry: async (values, addedAreas) => {
|
||||
try {
|
||||
const floor = await createFloorRegistryEntry(this.hass, values);
|
||||
addedAreas.forEach((areaId) => {
|
||||
updateAreaRegistryEntry(this.hass, areaId, {
|
||||
floor_id: floor.floor_id,
|
||||
});
|
||||
});
|
||||
this._setValue(floor.floor_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.components.floor-picker.failed_create_floor"
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
private _openedChanged(ev: ValueChangedEvent<boolean>) {
|
||||
this._opened = ev.detail.value;
|
||||
}
|
||||
|
||||
private _floorChanged(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
let newValue = ev.detail.value;
|
||||
|
||||
if (newValue === NO_FLOORS_ID) {
|
||||
newValue = "";
|
||||
this.comboBox.setInputValue("");
|
||||
return;
|
||||
}
|
||||
|
||||
this._setValue(value);
|
||||
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
|
||||
if (newValue !== this._value) {
|
||||
this._setValue(newValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
(ev.target as any).value = this._value;
|
||||
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
|
||||
showFloorRegistryDetailDialog(this, {
|
||||
suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "",
|
||||
createEntry: async (values, addedAreas) => {
|
||||
try {
|
||||
const floor = await createFloorRegistryEntry(this.hass, values);
|
||||
addedAreas.forEach((areaId) => {
|
||||
updateAreaRegistryEntry(this.hass, areaId, {
|
||||
floor_id: floor.floor_id,
|
||||
});
|
||||
});
|
||||
const floors = [...Object.values(this.hass.floors), floor];
|
||||
this.comboBox.filteredItems = this._getFloors(
|
||||
floors,
|
||||
Object.values(this.hass.areas)!,
|
||||
Object.values(this.hass.devices)!,
|
||||
Object.values(this.hass.entities)!,
|
||||
this.includeDomains,
|
||||
this.excludeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.deviceFilter,
|
||||
this.entityFilter,
|
||||
this.noAdd,
|
||||
this.excludeFloors
|
||||
);
|
||||
await this.updateComplete;
|
||||
await this.comboBox.updateComplete;
|
||||
this._setValue(floor.floor_id);
|
||||
} catch (err: any) {
|
||||
showAlertDialog(this, {
|
||||
title: this.hass.localize(
|
||||
"ui.components.floor-picker.failed_create_floor"
|
||||
),
|
||||
text: err.message,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this._suggestion = undefined;
|
||||
this.comboBox.setInputValue("");
|
||||
}
|
||||
|
||||
private _setValue(value?: string) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
setTimeout(() => {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -70,7 +70,7 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
|
||||
padding-top: 4px;
|
||||
box-sizing: border-box;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875rem;
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(
|
||||
--mdc-typography-body2-font-weight,
|
||||
var(--ha-font-weight-normal)
|
||||
|
@@ -20,7 +20,7 @@ export class HaFormConstant extends LitElement implements HaFormElement {
|
||||
display: block;
|
||||
}
|
||||
.label {
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
font-weight: var(--ha-font-weight-semibold);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
@@ -34,8 +34,6 @@ const getWarning = (obj, item) => (obj && item.name ? obj[item.name] : null);
|
||||
export class HaForm extends LitElement implements HaFormElement {
|
||||
@property({ attribute: false }) public hass?: HomeAssistant;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ attribute: false }) public data!: HaFormDataContainer;
|
||||
|
||||
@property({ attribute: false }) public schema!: readonly HaFormSchema[];
|
||||
@@ -137,7 +135,6 @@ export class HaForm extends LitElement implements HaFormElement {
|
||||
? html`<ha-selector
|
||||
.schema=${item}
|
||||
.hass=${this.hass}
|
||||
.narrow=${this.narrow}
|
||||
.name=${item.name}
|
||||
.selector=${item.selector}
|
||||
.value=${getValue(this.data, item)}
|
||||
|
@@ -1,192 +0,0 @@
|
||||
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
|
||||
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
|
||||
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-combo-box-item";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-input-helper-text";
|
||||
import "./ha-picker-combo-box";
|
||||
import type {
|
||||
HaPickerComboBox,
|
||||
PickerComboBoxItem,
|
||||
PickerComboBoxSearchFn,
|
||||
} from "./ha-picker-combo-box";
|
||||
import "./ha-picker-field";
|
||||
import type { HaPickerField, PickerValueRenderer } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-generic-picker")
|
||||
export class HaGenericPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "allow-custom-value" })
|
||||
public allowCustomValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ type: String, attribute: "search-label" })
|
||||
public searchLabel?: string;
|
||||
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@property({ attribute: false, type: Array })
|
||||
public getItems?: () => PickerComboBoxItem[];
|
||||
|
||||
@property({ attribute: false, type: Array })
|
||||
public getAdditionalItems?: (searchString?: string) => PickerComboBoxItem[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: ComboBoxLitRenderer<PickerComboBoxItem>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public valueRenderer?: PickerValueRenderer;
|
||||
|
||||
@property({ attribute: false })
|
||||
public searchFn?: PickerComboBoxSearchFn<PickerComboBoxItem>;
|
||||
|
||||
@property({ attribute: "not-found-label", type: String })
|
||||
public notFoundLabel?: string;
|
||||
|
||||
@query("ha-picker-field") private _field?: HaPickerField;
|
||||
|
||||
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
|
||||
|
||||
@state() private _opened = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
${this.label
|
||||
? html`<label ?disabled=${this.disabled}>${this.label}</label>`
|
||||
: nothing}
|
||||
<div class="container">
|
||||
${!this._opened
|
||||
? html`
|
||||
<ha-picker-field
|
||||
type="button"
|
||||
compact
|
||||
aria-label=${ifDefined(this.label)}
|
||||
@click=${this.open}
|
||||
@clear=${this._clear}
|
||||
.placeholder=${this.placeholder}
|
||||
.value=${this.value}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.hideClearIcon=${this.hideClearIcon}
|
||||
.valueRenderer=${this.valueRenderer}
|
||||
>
|
||||
</ha-picker-field>
|
||||
`
|
||||
: html`
|
||||
<ha-picker-combo-box
|
||||
.hass=${this.hass}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomValue}
|
||||
.label=${this.searchLabel ??
|
||||
this.hass.localize("ui.common.search")}
|
||||
.value=${this.value}
|
||||
hide-clear-icon
|
||||
@opened-changed=${this._openedChanged}
|
||||
@value-changed=${this._valueChanged}
|
||||
.rowRenderer=${this.rowRenderer}
|
||||
.notFoundLabel=${this.notFoundLabel}
|
||||
.getItems=${this.getItems}
|
||||
.getAdditionalItems=${this.getAdditionalItems}
|
||||
.searchFn=${this.searchFn}
|
||||
></ha-picker-combo-box>
|
||||
`}
|
||||
</div>
|
||||
${this._renderHelper()}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _valueChanged(ev: CustomEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _clear(e) {
|
||||
e.stopPropagation();
|
||||
this._setValue(undefined);
|
||||
}
|
||||
|
||||
private _setValue(value: string | undefined) {
|
||||
this.value = value;
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
public async open() {
|
||||
if (this.disabled) {
|
||||
return;
|
||||
}
|
||||
this._opened = true;
|
||||
await this.updateComplete;
|
||||
this._comboBox?.focus();
|
||||
this._comboBox?.open();
|
||||
}
|
||||
|
||||
private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) {
|
||||
const opened = ev.detail.value;
|
||||
if (this._opened && !opened) {
|
||||
this._opened = false;
|
||||
await this.updateComplete;
|
||||
this._field?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
css`
|
||||
.container {
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
label[disabled] {
|
||||
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
ha-input-helper-text {
|
||||
display: block;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-generic-picker": HaGenericPicker;
|
||||
}
|
||||
}
|
@@ -35,7 +35,10 @@ export class HaBadge extends LitElement {
|
||||
gap: 3px;
|
||||
color: var(--ha-heading-badge-text-color, var(--secondary-text-color));
|
||||
font-size: var(--ha-heading-badge-font-size, var(--ha-font-size-m));
|
||||
font-weight: var(--ha-heading-badge-font-weight, 400);
|
||||
font-weight: var(
|
||||
--ha-heading-badge-font-weight,
|
||||
var(--ha-font-weight-normal)
|
||||
);
|
||||
line-height: var(--ha-heading-badge-line-height, 20px);
|
||||
letter-spacing: 0.1px;
|
||||
--mdc-icon-size: 14px;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user