mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 14:33:23 +00:00
Compare commits
121
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb1ac2161f | ||
|
|
182563d943 | ||
|
|
e5f1cb0b1f | ||
|
|
b31da04913 | ||
|
|
d35c398bfd | ||
|
|
71f9285e70 | ||
|
|
117392d71e | ||
|
|
666f88c4da | ||
|
|
b12f78c652 | ||
|
|
8e10d53012 | ||
|
|
1813f386de | ||
|
|
80202cd645 | ||
|
|
c786f7d2e6 | ||
|
|
1ac4382dc5 | ||
|
|
d0220b6a72 | ||
|
|
bf21bfa777 | ||
|
|
1fc565b602 | ||
|
|
7ac932ff7e | ||
|
|
996ccd50d6 | ||
|
|
24fde161c4 | ||
|
|
d005f694e1 | ||
|
|
299e612646 | ||
|
|
5fa14d4173 | ||
|
|
a4d1cb0a57 | ||
|
|
43c3b51829 | ||
|
|
66a454821a | ||
|
|
4f713aa645 | ||
|
|
cedc2d5324 | ||
|
|
15290231ea | ||
|
|
5960dfea0e | ||
|
|
d830220eff | ||
|
|
c6f72c9cf5 | ||
|
|
60f7728d54 | ||
|
|
6014ee7744 | ||
|
|
f32833fcbe | ||
|
|
a9295ac21b | ||
|
|
66cc3f62c5 | ||
|
|
fb1a75edd4 | ||
|
|
8f7184023a | ||
|
|
f3aba8f142 | ||
|
|
9c28c21792 | ||
|
|
ccceddfd35 | ||
|
|
e633e5fb3e | ||
|
|
9a311b0518 | ||
|
|
c07f421f74 | ||
|
|
62acda8c08 | ||
|
|
193cbcbfac | ||
|
|
d43f7e58b6 | ||
|
|
fb419538c7 | ||
|
|
7c7549349a | ||
|
|
051f51dd26 | ||
|
|
9a9d747899 | ||
|
|
5b99462fdf | ||
|
|
14ef770c44 | ||
|
|
6a9fca2cb9 | ||
|
|
55ba59074e | ||
|
|
eeb674dd5a | ||
|
|
afe6e84e3c | ||
|
|
3ddbc8f4e3 | ||
|
|
216468cf50 | ||
|
|
0ef3e9c71a | ||
|
|
1266b0c87a | ||
|
|
a695710beb | ||
|
|
f991efbc0a | ||
|
|
47306d1afd | ||
|
|
1692067392 | ||
|
|
546315ca5b | ||
|
|
7c98912b73 | ||
|
|
8c72e9c22d | ||
|
|
892514ed21 | ||
|
|
06be6f433d | ||
|
|
e9a6e2936d | ||
|
|
d3b7d787b4 | ||
|
|
0662f8238f | ||
|
|
8a59516d8f | ||
|
|
0afe6bb136 | ||
|
|
61b487f995 | ||
|
|
91abff2d0c | ||
|
|
53d16c97fe | ||
|
|
7493bfc86c | ||
|
|
ca1eb4ed7d | ||
|
|
c1dd17c4f9 | ||
|
|
5c74a84d01 | ||
|
|
a51d255ec3 | ||
|
|
8c0dec0d9a | ||
|
|
2c4b3c9109 | ||
|
|
c75bd2b5f0 | ||
|
|
9b1665f5bf | ||
|
|
603528bf47 | ||
|
|
030b962e26 | ||
|
|
85a34aeeee | ||
|
|
44dd2249b5 | ||
|
|
f073a12cbd | ||
|
|
7dec4a57e8 | ||
|
|
aa78f368b1 | ||
|
|
06901dedaa | ||
|
|
43f194dad2 | ||
|
|
e7acf97b1a | ||
|
|
9409db7fda | ||
|
|
d917df359f | ||
|
|
6dcca89185 | ||
|
|
3f1b2ef65f | ||
|
|
34afbe37c3 | ||
|
|
848e81313e | ||
|
|
24e7bd306a | ||
|
|
3791548067 | ||
|
|
01cef9a99b | ||
|
|
8eb179a0d9 | ||
|
|
34e9b50920 | ||
|
|
2ebe452334 | ||
|
|
3052989e9d | ||
|
|
fcab4004b2 | ||
|
|
4610adb368 | ||
|
|
e0dba17b66 | ||
|
|
eee0f9447b | ||
|
|
8d149a914f | ||
|
|
b2bb3e9d9d | ||
|
|
13ef9712bc | ||
|
|
40b27608c0 | ||
|
|
e0f03254b5 | ||
|
|
35dcba4f3f |
@@ -7,13 +7,17 @@ body:
|
||||
value: |
|
||||
Make sure you are running the [latest version of Home Assistant][releases] before reporting an issue.
|
||||
|
||||
Please read our [issue reporting guide][reporting-issues] before submitting a report.
|
||||
|
||||
If you have a feature or enhancement request for the frontend, please [start a discussion][fr] instead of creating an issue.
|
||||
|
||||
**Please do not report issues for custom cards.**
|
||||
|
||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
||||
[releases]: https://github.com/home-assistant/home-assistant/releases
|
||||
[reporting-issues]: https://www.home-assistant.io/help/reporting_issues/
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please verify that you've followed these steps
|
||||
@@ -30,18 +34,21 @@ body:
|
||||
value: |
|
||||
## The problem
|
||||
- type: textarea
|
||||
id: problem
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe the issue you are experiencing
|
||||
description: Provide a clear and concise description of what the bug is.
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe the behavior you expected
|
||||
description: Describe what you expected to happen or it should look/behave.
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
@@ -59,6 +66,7 @@ body:
|
||||
value: |
|
||||
## Environment
|
||||
- type: input
|
||||
id: core_version
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
@@ -67,18 +75,21 @@ body:
|
||||
description: >
|
||||
Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
|
||||
- type: input
|
||||
id: last_working_version
|
||||
attributes:
|
||||
label: What was the last working version of Home Assistant Core?
|
||||
placeholder: core-
|
||||
description: >
|
||||
If known, otherwise leave blank.
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: In which browser are you experiencing the issue?
|
||||
placeholder: Google Chrome 88.0.4324.150
|
||||
description: >
|
||||
Provide the full name and don't forget to add the version!
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Which operating system are you using to run this browser?
|
||||
placeholder: macOS Big Sur (1.11)
|
||||
@@ -90,6 +101,7 @@ body:
|
||||
# Details
|
||||
|
||||
- type: textarea
|
||||
id: entity_state
|
||||
attributes:
|
||||
label: State of relevant entities
|
||||
description: >
|
||||
@@ -98,6 +110,7 @@ body:
|
||||
information in the Details view of the More info dialog.
|
||||
render: txt
|
||||
- type: textarea
|
||||
id: configuration
|
||||
attributes:
|
||||
label: Problem-relevant frontend configuration
|
||||
description: >
|
||||
@@ -107,6 +120,7 @@ body:
|
||||
passwords, private URLs and other credentials.
|
||||
render: yaml
|
||||
- type: textarea
|
||||
id: javascript_errors
|
||||
attributes:
|
||||
label: JavaScript errors shown in your browser console/inspector
|
||||
description: >
|
||||
@@ -114,6 +128,7 @@ body:
|
||||
browser console/inspector please provide them.
|
||||
render: txt
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: >
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
|
||||
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
@@ -12,19 +12,29 @@ const CONNECTIONS: ModbusConnection[] = [
|
||||
{
|
||||
endpoint: ["serial", RS485_PORT],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
|
||||
},
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [1] },
|
||||
},
|
||||
// A second inverter behind a gateway that is not answering
|
||||
{
|
||||
endpoint: ["tcp", "modbus-gateway.local", 502],
|
||||
connected: false,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [2] },
|
||||
},
|
||||
// A YAML hub keeps a link of its own to a device an integration also talks to
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "yaml",
|
||||
units: { modbus_hub: [10, 11] },
|
||||
},
|
||||
];
|
||||
|
||||
export const mockModbus = (hass: MockHomeAssistant) => {
|
||||
|
||||
@@ -13,6 +13,7 @@ const HARDWARE_INFO: HardwareInfo = {
|
||||
hassio_board_id: "green",
|
||||
manufacturer: "homeassistant",
|
||||
model: "green",
|
||||
revision: null,
|
||||
},
|
||||
dongle: null,
|
||||
config_entries: [],
|
||||
|
||||
@@ -32,11 +32,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
|
||||
detached: false,
|
||||
homeassistant: "2025.7.0",
|
||||
icon: false,
|
||||
installed: true,
|
||||
logo: false,
|
||||
repository: "d5369777",
|
||||
stage: "stable",
|
||||
state: "started",
|
||||
system_managed: false,
|
||||
update_available: false,
|
||||
url: "https://github.com/music-assistant/home-assistant-addon",
|
||||
version: "2.6.3",
|
||||
@@ -53,11 +53,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
|
||||
detached: false,
|
||||
homeassistant: "2025.7.0",
|
||||
icon: false,
|
||||
installed: true,
|
||||
logo: false,
|
||||
repository: "5c53de3b",
|
||||
stage: "stable",
|
||||
state: "started",
|
||||
system_managed: false,
|
||||
update_available: false,
|
||||
url: "https://esphome.io/",
|
||||
version: "2025.7.3",
|
||||
@@ -114,13 +114,14 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
audio_output: null,
|
||||
audio: false,
|
||||
auth_api: false,
|
||||
auto_uart: false,
|
||||
auto_update: false,
|
||||
boot: "auto",
|
||||
boot_config: "auto",
|
||||
changelog: false,
|
||||
devices: [],
|
||||
devicetree: false,
|
||||
discovery: [],
|
||||
dns: [],
|
||||
docker_api: false,
|
||||
documentation: false,
|
||||
full_access: false,
|
||||
@@ -133,8 +134,10 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
host_ipc: false,
|
||||
host_network: false,
|
||||
host_pid: false,
|
||||
host_uts: false,
|
||||
ingress_entry: null,
|
||||
ingress_panel: false,
|
||||
ingress_port: null,
|
||||
ingress_url: null,
|
||||
ingress: false,
|
||||
ip_address: "172.30.33.2",
|
||||
@@ -148,13 +151,16 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
protected: true,
|
||||
rating: 6,
|
||||
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
|
||||
services_role: [],
|
||||
services: [],
|
||||
signed: false,
|
||||
startup: "application",
|
||||
stdin: false,
|
||||
system_managed: false,
|
||||
system_managed_config_entry: null,
|
||||
translations: {},
|
||||
uart: false,
|
||||
udev: false,
|
||||
usb: false,
|
||||
video: false,
|
||||
watchdog: true,
|
||||
webui: null,
|
||||
});
|
||||
@@ -181,11 +187,14 @@ const ADDON_STATS: HassioStats = {
|
||||
blk_read: 12300000,
|
||||
blk_write: 4500000,
|
||||
cpu_percent: 1.4,
|
||||
cpu_system_usage: 1420000000000,
|
||||
cpu_usage: 19800000000,
|
||||
memory_limit: 3900000000,
|
||||
memory_percent: 4.2,
|
||||
memory_usage: 163000000,
|
||||
network_rx: 8900000,
|
||||
network_tx: 2300000,
|
||||
online_cpus: 4,
|
||||
};
|
||||
|
||||
export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
@@ -209,9 +218,13 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
wait_boot: 5,
|
||||
timezone: "Europe/Amsterdam",
|
||||
logging: "info",
|
||||
auto_update: true,
|
||||
country: "NL",
|
||||
debug: false,
|
||||
debug_block: false,
|
||||
detect_blocking_io: false,
|
||||
diagnostics: true,
|
||||
feature_flags: {},
|
||||
addons: DEMO_ADDONS as any,
|
||||
addons_repositories: [
|
||||
"https://github.com/music-assistant/home-assistant-addon",
|
||||
@@ -224,22 +237,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
if (msg.endpoint === "/addons") {
|
||||
const data: HassioAddonsInfo = {
|
||||
addons: DEMO_ADDONS,
|
||||
repositories: [
|
||||
{
|
||||
slug: "d5369777",
|
||||
name: "Music Assistant",
|
||||
source: "https://github.com/music-assistant/home-assistant-addon",
|
||||
url: "https://github.com/music-assistant/home-assistant-addon",
|
||||
maintainer: "Music Assistant",
|
||||
},
|
||||
{
|
||||
slug: "5c53de3b",
|
||||
name: "ESPHome",
|
||||
source: "https://github.com/esphome/home-assistant-addon",
|
||||
url: "https://esphome.io/",
|
||||
maintainer: "ESPHome",
|
||||
},
|
||||
],
|
||||
};
|
||||
return data;
|
||||
}
|
||||
@@ -264,6 +261,7 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
hostname: "homeassistant",
|
||||
logging: "info",
|
||||
machine: "green",
|
||||
machine_id: "0123456789abcdef0123456789abcdef",
|
||||
state: "running",
|
||||
operating_system: "Home Assistant OS 18.2",
|
||||
supervisor: "2026.07.1",
|
||||
@@ -301,6 +299,7 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
update_available: false,
|
||||
version: "18.2",
|
||||
version_latest: "18.2",
|
||||
version_pending: null,
|
||||
data_disk: "Home Assistant Green (mmcblk0)",
|
||||
};
|
||||
return data;
|
||||
@@ -334,8 +333,9 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
const data: NetworkInfo = {
|
||||
interfaces: [
|
||||
{
|
||||
connected: true,
|
||||
mac: "aa:bb:cc:dd:ee:ff",
|
||||
primary: true,
|
||||
privacy: false,
|
||||
interface: "eth0",
|
||||
enabled: true,
|
||||
type: "ethernet",
|
||||
@@ -354,10 +354,18 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
gateway: "172.30.32.1",
|
||||
interface: "hassio",
|
||||
},
|
||||
host_internet: true,
|
||||
supervisor_internet: true,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/ingress/panels") {
|
||||
// The navigation picker waits for this collection before it enables
|
||||
// itself, so it has to answer even when no add-on exposes a panel.
|
||||
return { panels: {} };
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/store/reload") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { customElement, property } from "lit/decorators";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/dialogs/more-info/more-info-content";
|
||||
import "../../../src/state-summary/state-card-content";
|
||||
import "../ha-demo-options";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { computeShowNewMoreInfo } from "../../../src/dialogs/more-info/const";
|
||||
|
||||
|
||||
@@ -126,7 +126,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -171,7 +170,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -189,7 +187,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -207,7 +204,6 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { html, css, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import "../../src/components/ha-icon-button";
|
||||
import "../../src/managers/notification-manager";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
import "./components/page-description";
|
||||
|
||||
@customElement("ha-demo-options")
|
||||
class HaDemoOptions extends LitElement {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ifDefined } from "lit/directives/if-defined";
|
||||
import { applyThemesOnElement } from "../../src/common/dom/apply_themes_on_element";
|
||||
import { dynamicElement } from "../../src/common/dom/dynamic-element-directive";
|
||||
import type { HASSDomEvent } from "../../src/common/dom/fire_event";
|
||||
import { computeEntityNameDisplayWithoutContext } from "../../src/common/entity/compute_entity_name_display";
|
||||
import { setDirectionStyles } from "../../src/common/util/compute_rtl";
|
||||
import "../../src/components/ha-button";
|
||||
import "../../src/components/ha-drawer";
|
||||
@@ -682,10 +683,7 @@ class HaGallery extends LitElement {
|
||||
formatEntityAttributeName: (_stateObj, attribute) => attribute,
|
||||
formatEntityAttributeValue: (stateObj, attribute, value) =>
|
||||
value != null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
formatEntityName: computeEntityNameDisplayWithoutContext,
|
||||
} as unknown as HomeAssistant;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ This component is based on the webawesome input component.
|
||||
| invalid | Boolean | false | Marks the input as invalid. |
|
||||
| inset-label | Boolean | false | Uses an inset label style where the label stays inside the input. |
|
||||
| validation-message | String | "" | Custom validation message shown when the input is invalid. |
|
||||
| input-id | String | - | Sets the native input's `id` and associated label `for` on the inner `wa-input`. Defaults to `"input"`. |
|
||||
| pattern | String | - | A regular expression pattern to validate input against. |
|
||||
| minlength | Number | - | The minimum length of input that will be considered valid. |
|
||||
| maxlength | Number | - | The maximum length of input that will be considered valid. |
|
||||
|
||||
@@ -21,6 +21,8 @@ import "../../../../src/components/ha-settings-row";
|
||||
import type { BlueprintInput } from "../../../../src/data/blueprint";
|
||||
import type { DeviceRegistryEntry } from "../../../../src/data/device/device_registry";
|
||||
import type { LabelRegistryEntry } from "../../../../src/data/label/label_registry";
|
||||
import { StatisticMeanType } from "../../../../src/data/recorder";
|
||||
import type { SerialPort } from "../../../../src/data/usb";
|
||||
import {
|
||||
showDialog,
|
||||
type ShowDialogParams,
|
||||
@@ -293,9 +295,72 @@ const LABELS: LabelRegistryEntry[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const serialPort = (port: Partial<SerialPort>): SerialPort => ({
|
||||
device: "/dev/ttyUSB0",
|
||||
resolved_device: null,
|
||||
serial_number: null,
|
||||
manufacturer: null,
|
||||
description: null,
|
||||
matching_integrations: [],
|
||||
present: true,
|
||||
...port,
|
||||
});
|
||||
|
||||
// One port per section the picker groups by, relative to the "zha" domain the
|
||||
// serial port selector below is given as its flow context
|
||||
const SERIAL_PORTS: SerialPort[] = [
|
||||
serialPort({
|
||||
device:
|
||||
"/dev/serial/by-id/usb-Nabu_Casa_SkyConnect_v1.0_9e2adbd75b8beb119fe564a0f320645d-if00-port0",
|
||||
description: "SkyConnect v1.0",
|
||||
manufacturer: "Nabu Casa",
|
||||
serial_number: "9e2adbd75b8beb119fe564a0f320645d",
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
matching_integrations: ["zha"],
|
||||
}),
|
||||
serialPort({
|
||||
device: "esphome-hass://01JQ8Z5X9WQ0/?port_name=UART0",
|
||||
}),
|
||||
serialPort({
|
||||
device: "socket://192.168.1.10:6638",
|
||||
}),
|
||||
serialPort({
|
||||
device: "/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_AB0KVD1L-if00-port0",
|
||||
description: "FT232R USB UART",
|
||||
manufacturer: "FTDI",
|
||||
serial_number: "AB0KVD1L",
|
||||
vid: "0403",
|
||||
pid: "6001",
|
||||
}),
|
||||
serialPort({
|
||||
device: "/dev/ttyS0",
|
||||
description: "ttyS0",
|
||||
manufacturer: "Intel",
|
||||
}),
|
||||
serialPort({ device: "/dev/ttyAMA0" }),
|
||||
serialPort({
|
||||
device:
|
||||
"/dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0",
|
||||
description: "CP2102 USB to UART Bridge Controller",
|
||||
manufacturer: "Silicon Labs",
|
||||
serial_number: "0001",
|
||||
vid: "10C4",
|
||||
pid: "EA60",
|
||||
matching_integrations: ["matter"],
|
||||
}),
|
||||
];
|
||||
|
||||
const SCHEMAS: {
|
||||
name: string;
|
||||
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
|
||||
input: Record<
|
||||
string,
|
||||
| (BlueprintInput & {
|
||||
required?: boolean;
|
||||
context?: Record<string, unknown>;
|
||||
})
|
||||
| null
|
||||
>;
|
||||
}[] = [
|
||||
{
|
||||
name: "One of each",
|
||||
@@ -320,6 +385,13 @@ const SCHEMAS: {
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
serial_port: {
|
||||
name: "Serial port",
|
||||
selector: { serial_port: {} },
|
||||
// A config flow passes its own domain as context, which is what the
|
||||
// picker groups recommended and not recommended ports by
|
||||
context: { domain: "zha" },
|
||||
},
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
selector: {
|
||||
@@ -504,6 +576,99 @@ const SCHEMAS: {
|
||||
},
|
||||
},
|
||||
},
|
||||
addon: { name: "Add-on", selector: { addon: {} } },
|
||||
areas_display: {
|
||||
name: "Areas display",
|
||||
selector: { areas_display: {} },
|
||||
},
|
||||
assist_pipeline: {
|
||||
name: "Assist pipeline",
|
||||
selector: { assist_pipeline: {} },
|
||||
},
|
||||
automation_behavior: {
|
||||
name: "Automation behavior",
|
||||
selector: { automation_behavior: { mode: "trigger" } },
|
||||
},
|
||||
backup_location: {
|
||||
name: "Backup location",
|
||||
selector: { backup_location: {} },
|
||||
},
|
||||
button_toggle: {
|
||||
name: "Button toggle",
|
||||
selector: {
|
||||
button_toggle: {
|
||||
options: [
|
||||
{ label: "Left", value: "left" },
|
||||
{ label: "Center", value: "center" },
|
||||
{ label: "Right", value: "right" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
condition: { name: "Condition", selector: { condition: {} } },
|
||||
conversation_agent: {
|
||||
name: "Conversation agent",
|
||||
selector: { conversation_agent: {} },
|
||||
},
|
||||
country: {
|
||||
name: "Country",
|
||||
selector: { country: { countries: ["DE", "GB", "NL", "US"] } },
|
||||
},
|
||||
entity_name: {
|
||||
name: "Entity name",
|
||||
selector: { entity_name: { entity_id: "light.bedroom" } },
|
||||
},
|
||||
file: {
|
||||
name: "File",
|
||||
selector: { file: { accept: "image/png,image/jpeg" } },
|
||||
},
|
||||
language: { name: "Language", selector: { language: {} } },
|
||||
navigation: { name: "Navigation", selector: { navigation: {} } },
|
||||
numeric_threshold: {
|
||||
name: "Numeric threshold",
|
||||
selector: { numeric_threshold: {} },
|
||||
},
|
||||
period: {
|
||||
name: "Period",
|
||||
selector: {
|
||||
period: {
|
||||
options: ["today", "yesterday", "this_week", "this_month"],
|
||||
},
|
||||
},
|
||||
// Without a value that matches one of the options the selector offers
|
||||
// its custom-period editor instead of the list
|
||||
default: { calendar: { period: "day" } },
|
||||
},
|
||||
selector: {
|
||||
name: "Selector",
|
||||
selector: { selector: {} },
|
||||
default: { text: {} },
|
||||
},
|
||||
state_class: { name: "State class", selector: { state_class: {} } },
|
||||
statistic: { name: "Statistic", selector: { statistic: {} } },
|
||||
stt: { name: "Speech-to-text", selector: { stt: {} } },
|
||||
theme: { name: "Theme", selector: { theme: {} } },
|
||||
timezone: { name: "Time zone", selector: { timezone: {} } },
|
||||
trigger: { name: "Trigger", selector: { trigger: {} } },
|
||||
tts: { name: "Text-to-speech", selector: { tts: {} } },
|
||||
tts_voice: {
|
||||
name: "Text-to-speech voice",
|
||||
selector: { tts_voice: { engineId: "tts.cloud", language: "en-US" } },
|
||||
},
|
||||
ui_action: { name: "UI action", selector: { ui_action: {} } },
|
||||
ui_clock_date_format: {
|
||||
name: "Clock date format",
|
||||
selector: { ui_clock_date_format: {} },
|
||||
},
|
||||
ui_color: { name: "UI color", selector: { ui_color: {} } },
|
||||
ui_state_content: {
|
||||
name: "State content",
|
||||
selector: { ui_state_content: { entity_id: "light.bedroom" } },
|
||||
},
|
||||
ui_time_format: {
|
||||
name: "Time format",
|
||||
selector: { ui_time_format: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -605,8 +770,98 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
mockFloorRegistry(hass, FLOORS);
|
||||
mockLabelRegistry(hass, LABELS);
|
||||
mockHassioSupervisor(hass);
|
||||
hass.addTranslations({
|
||||
"component.matter.title": "Matter",
|
||||
"component.zha.title": "Zigbee Home Automation",
|
||||
});
|
||||
hass.updateHass({
|
||||
config: {
|
||||
...hass.config,
|
||||
components: [...hass.config.components, "usb"],
|
||||
},
|
||||
});
|
||||
hass.mockWS("auth/sign_path", (params) => params);
|
||||
hass.mockWS("media_player/browse_media", this._browseMedia);
|
||||
hass.mockWS("usb/list_serial_ports", () => SERIAL_PORTS);
|
||||
hass.mockWS("recorder/list_statistic_ids", () => [
|
||||
{
|
||||
statistic_id: "sensor.energy_consumption",
|
||||
statistics_unit_of_measurement: "kWh",
|
||||
source: "recorder",
|
||||
name: null,
|
||||
has_sum: true,
|
||||
mean_type: StatisticMeanType.NONE,
|
||||
unit_class: "energy",
|
||||
},
|
||||
{
|
||||
statistic_id: "sensor.outside_temperature",
|
||||
statistics_unit_of_measurement: "\u00b0C",
|
||||
source: "recorder",
|
||||
name: null,
|
||||
has_sum: false,
|
||||
mean_type: StatisticMeanType.ARITHMETIC,
|
||||
unit_class: "temperature",
|
||||
},
|
||||
]);
|
||||
hass.mockWS("assist_pipeline/pipeline/list", () => ({
|
||||
pipelines: [
|
||||
{
|
||||
id: "pipeline_home",
|
||||
name: "Home Assistant",
|
||||
language: "en",
|
||||
conversation_engine: "conversation.home_assistant",
|
||||
conversation_language: "en",
|
||||
stt_engine: "stt.cloud",
|
||||
stt_language: "en-US",
|
||||
tts_engine: "tts.cloud",
|
||||
tts_language: "en-US",
|
||||
tts_voice: "JennyNeural",
|
||||
wake_word_entity: null,
|
||||
wake_word_id: null,
|
||||
},
|
||||
],
|
||||
preferred_pipeline: "pipeline_home",
|
||||
}));
|
||||
hass.mockWS("conversation/agent/list", () => ({
|
||||
agents: [
|
||||
{
|
||||
id: "conversation.home_assistant",
|
||||
name: "Home Assistant",
|
||||
supported_languages: "*",
|
||||
},
|
||||
{
|
||||
id: "conversation.openai",
|
||||
name: "OpenAI Conversation",
|
||||
supported_languages: ["en"],
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("stt/engine/list", () => ({
|
||||
providers: [
|
||||
{
|
||||
engine_id: "stt.cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
supported_languages: ["en-US"],
|
||||
deprecated: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("tts/engine/list", () => ({
|
||||
providers: [
|
||||
{
|
||||
engine_id: "tts.cloud",
|
||||
name: "Home Assistant Cloud",
|
||||
supported_languages: ["en-US"],
|
||||
deprecated: false,
|
||||
},
|
||||
],
|
||||
}));
|
||||
hass.mockWS("tts/engine/voices", () => ({
|
||||
voices: [
|
||||
{ voice_id: "JennyNeural", name: "Jenny" },
|
||||
{ voice_id: "GuyNeural", name: "Guy" },
|
||||
],
|
||||
}));
|
||||
}
|
||||
|
||||
public provideHass(el) {
|
||||
@@ -776,6 +1031,7 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
|
||||
<ha-selector
|
||||
.hass=${this.hass}
|
||||
.selector=${value!.selector}
|
||||
.context=${value!.context}
|
||||
.key=${key}
|
||||
.label=${this._label ? value!.name : undefined}
|
||||
.value=${data[key] ?? value!.default}
|
||||
|
||||
@@ -21,7 +21,8 @@ const ENTITIES = [
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_features: 1,
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
brightness: 100,
|
||||
},
|
||||
},
|
||||
@@ -30,7 +31,7 @@ const ENTITIES = [
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_features: 1,
|
||||
supported_color_modes: ["brightness"],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -38,7 +39,7 @@ const ENTITIES = [
|
||||
state: "unavailable",
|
||||
attributes: {
|
||||
friendly_name: "Lost Light",
|
||||
supported_features: 1,
|
||||
supported_color_modes: ["brightness"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,7 +12,10 @@ import "../../components/demo-cards";
|
||||
import { mockIcons } from "../../../../demo/src/stubs/icons";
|
||||
import { ClimateEntityFeature } from "../../../../src/data/climate";
|
||||
import { FanEntityFeature } from "../../../../src/data/fan";
|
||||
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
import type {
|
||||
GridCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
@@ -163,6 +166,39 @@ const ENTITIES = [
|
||||
FanEntityFeature.OSCILLATE,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.three_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Desk fan",
|
||||
device_class: "fan",
|
||||
percentage: 67,
|
||||
percentage_step: 100 / 3,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.four_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Bedroom fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 25,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.five_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Attic fan",
|
||||
device_class: "fan",
|
||||
percentage: 80,
|
||||
percentage_step: 20,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CONFIGS = [
|
||||
@@ -332,6 +368,50 @@ const CONFIGS = [
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (3 speeds)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.three_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (4 speeds)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.four_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (5 speeds)",
|
||||
config: {
|
||||
type: "tile",
|
||||
entity: "fan.five_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan speed feature (half width)",
|
||||
config: {
|
||||
type: "grid",
|
||||
columns: 2,
|
||||
square: false,
|
||||
cards: [
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.four_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
{
|
||||
type: "tile",
|
||||
entity: "fan.five_speed_fan",
|
||||
features: [{ type: "fan-speed" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
heading: "Fan oscillate feature",
|
||||
config: {
|
||||
@@ -418,7 +498,7 @@ const CONFIGS = [
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
] satisfies DemoCardConfig<TileCardConfig | GridCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
class DemoTile extends LitElement {
|
||||
|
||||
@@ -21,6 +21,61 @@ const ENTITIES = [
|
||||
FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.one_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "One speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 100,
|
||||
percentage_step: 100,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.two_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Two speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 50,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.three_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Three speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 67,
|
||||
percentage_step: 100 / 3,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.four_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Four speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 50,
|
||||
percentage_step: 25,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "fan.five_speed_fan",
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Five speed fan",
|
||||
device_class: "fan",
|
||||
percentage: 80,
|
||||
percentage_step: 20,
|
||||
supported_features: FanEntityFeature.SET_SPEED,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-fan")
|
||||
|
||||
@@ -11,6 +11,7 @@ import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
|
||||
const ALL_FEATURES =
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.STOP +
|
||||
LawnMowerEntityFeature.DOCK;
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -49,6 +50,14 @@ const ENTITIES = [
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.idle",
|
||||
state: "idle",
|
||||
attributes: {
|
||||
friendly_name: "Idle",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.error",
|
||||
state: "error",
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { IntersectionController } from "@lit-labs/observers/intersection-controller.js";
|
||||
import { mdiArrowCollapseDown, mdiDownload } from "@mdi/js";
|
||||
import { LitElement, type PropertyValues, css, html, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import type {
|
||||
LandingPageKeys,
|
||||
@@ -34,9 +35,6 @@ const SCHEDULE_FETCH_OBSERVER_LOGS = 5;
|
||||
|
||||
@customElement("landing-page-logs")
|
||||
class LandingPageLogs extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
@query("ha-ansi-to-html") private _ansiToHtmlElement?: HaAnsiToHtml;
|
||||
|
||||
@query(".logs") private _logElement?: HTMLElement;
|
||||
@@ -44,6 +42,10 @@ class LandingPageLogs extends LitElement {
|
||||
@query("#scroll-bottom-marker")
|
||||
private _scrollBottomMarkerElement?: HTMLElement;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
@state() private _show = false;
|
||||
|
||||
@state() private _scrolledToBottomController =
|
||||
@@ -63,12 +65,12 @@ class LandingPageLogs extends LitElement {
|
||||
return html`
|
||||
<div class="actions">
|
||||
<ha-button appearance="plain" @click=${this._toggleLogDetails}>
|
||||
${this.localize(this._show ? "hide_details" : "show_details")}
|
||||
${this._localize(this._show ? "hide_details" : "show_details")}
|
||||
</ha-button>
|
||||
${
|
||||
this._show
|
||||
? html`<ha-icon-button
|
||||
.label=${this.localize("logs.download_logs")}
|
||||
.label=${this._localize("logs.download_logs")}
|
||||
.path=${mdiDownload}
|
||||
@click=${this._downloadLogs}
|
||||
></ha-icon-button>`
|
||||
@@ -80,14 +82,14 @@ class LandingPageLogs extends LitElement {
|
||||
? html`
|
||||
<ha-alert
|
||||
alert-type="error"
|
||||
.title=${this.localize("logs.fetch_error")}
|
||||
.title=${this._localize("logs.fetch_error")}
|
||||
>
|
||||
<ha-button
|
||||
size="small"
|
||||
variant="danger"
|
||||
@click=${this._startLogStream}
|
||||
>
|
||||
${this.localize("logs.retry")}
|
||||
${this._localize("logs.retry")}
|
||||
</ha-button>
|
||||
</ha-alert>
|
||||
`
|
||||
@@ -115,7 +117,7 @@ class LandingPageLogs extends LitElement {
|
||||
@click=${this._scrollToBottom}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="start"></ha-svg-icon>
|
||||
${this.localize("logs.scroll_down_button")}
|
||||
${this._localize("logs.scroll_down_button")}
|
||||
<ha-svg-icon .path=${mdiArrowCollapseDown} slot="end"></ha-svg-icon>
|
||||
</ha-button>
|
||||
`;
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import type {
|
||||
LandingPageKeys,
|
||||
LocalizeFunc,
|
||||
} from "../../../src/common/translations/localize";
|
||||
import "../../../src/components/ha-button";
|
||||
import "../../../src/components/ha-alert";
|
||||
import "../../../src/components/ha-button";
|
||||
import type { NetworkInterface } from "../../../src/data/hassio/network";
|
||||
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
|
||||
import {
|
||||
ALTERNATIVE_DNS_SERVERS,
|
||||
setSupervisorNetworkDns,
|
||||
type NetworkInfo,
|
||||
} from "../data/supervisor";
|
||||
import { showAlertDialog } from "../../../src/dialogs/generic/show-dialog-box";
|
||||
import type { NetworkInterface } from "../../../src/data/hassio/network";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
|
||||
@customElement("landing-page-network")
|
||||
class LandingPageNetwork extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
@property({ attribute: false }) public networkInfo?: NetworkInfo;
|
||||
|
||||
@property({ type: Boolean }) public error = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
protected render() {
|
||||
if (this.error) {
|
||||
return html`
|
||||
<ha-alert alert-type="error">
|
||||
<p>${this.localize("network_issue.error_get_network_info")}</p>
|
||||
<p>${this._localize("network_issue.error_get_network_info")}</p>
|
||||
</ha-alert>
|
||||
`;
|
||||
}
|
||||
@@ -47,19 +49,21 @@ class LandingPageNetwork extends LitElement {
|
||||
return html`
|
||||
<ha-alert
|
||||
alert-type="warning"
|
||||
.title=${this.localize("network_issue.title")}
|
||||
.title=${this._localize("network_issue.title")}
|
||||
>
|
||||
<p>
|
||||
${this.localize("network_issue.description", {
|
||||
${this._localize("network_issue.description", {
|
||||
dns: dnsPrimaryInterfaceNameservers || "?",
|
||||
})}
|
||||
</p>
|
||||
<p>${this.localize("network_issue.resolve_different")}</p>
|
||||
<p>${this._localize("network_issue.resolve_different")}</p>
|
||||
${
|
||||
!dnsPrimaryInterfaceNameservers
|
||||
? html`
|
||||
<p>
|
||||
<b>${this.localize("network_issue.no_primary_interface")} </b>
|
||||
<b
|
||||
>${this._localize("network_issue.no_primary_interface")}
|
||||
</b>
|
||||
</p>
|
||||
`
|
||||
: nothing
|
||||
@@ -72,7 +76,7 @@ class LandingPageNetwork extends LitElement {
|
||||
.index=${key}
|
||||
.disabled=${!dnsPrimaryInterfaceNameservers}
|
||||
@click=${this._setDns}
|
||||
>${this.localize(translationKey)}</ha-button
|
||||
>${this._localize(translationKey)}</ha-button
|
||||
>`
|
||||
)}
|
||||
</div>
|
||||
@@ -118,12 +122,12 @@ class LandingPageNetwork extends LitElement {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
showAlertDialog(this, {
|
||||
title: this.localize("network_issue.failed"),
|
||||
title: this._localize("network_issue.failed"),
|
||||
warning: true,
|
||||
text: `${this.localize(
|
||||
text: `${this._localize(
|
||||
"network_issue.set_dns_failed"
|
||||
)}${err?.message ? ` ${this.localize("network_issue.error")}: ${err.message}` : ""}`,
|
||||
confirmText: this.localize("network_issue.close"),
|
||||
)}${err?.message ? ` ${this._localize("network_issue.error")}: ${err.message}` : ""}`,
|
||||
confirmText: this._localize("network_issue.close"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import "../../src/components/ha-spinner";
|
||||
import "../../src/components/ha-svg-icon";
|
||||
import "../../src/components/progress/ha-progress-bar";
|
||||
import { makeDialogManager } from "../../src/dialogs/make-dialog-manager";
|
||||
import { provideLiteI18nMixin } from "../../src/mixins/provide-lite-i18n-mixin";
|
||||
import "../../src/onboarding/onboarding-welcome-links";
|
||||
import { onBoardingStyles } from "../../src/onboarding/styles";
|
||||
import { haStyle } from "../../src/resources/styles";
|
||||
@@ -28,7 +29,7 @@ const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
|
||||
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
|
||||
|
||||
@customElement("ha-landing-page")
|
||||
class HaLandingPage extends LandingPageBaseElement {
|
||||
class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
@property({ attribute: false }) public translationFragment = "landing-page";
|
||||
|
||||
@state() private _supervisorError = false;
|
||||
@@ -82,7 +83,6 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
networkIssue || this._networkInfoError
|
||||
? html`
|
||||
<landing-page-network
|
||||
.localize=${this.localize}
|
||||
.networkInfo=${this._networkInfo}
|
||||
.error=${this._networkInfoError}
|
||||
@dns-set=${this._fetchSupervisorInfo}
|
||||
@@ -103,13 +103,11 @@ class HaLandingPage extends LandingPageBaseElement {
|
||||
: nothing
|
||||
}
|
||||
<landing-page-logs
|
||||
.localize=${this.localize}
|
||||
@landing-page-error=${this._showError}
|
||||
></landing-page-logs>
|
||||
</div>
|
||||
</ha-card>
|
||||
<onboarding-welcome-links
|
||||
.localize=${this.localize}
|
||||
.mobileApp=${this._mobileApp}
|
||||
></onboarding-welcome-links>
|
||||
<div class="footer">
|
||||
|
||||
+23
-23
@@ -39,7 +39,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@babel/runtime": "8.0.5",
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.3",
|
||||
"@codemirror/commands": "6.11.0",
|
||||
@@ -52,22 +52,22 @@
|
||||
"@codemirror/view": "6.43.11",
|
||||
"@date-fns/tz": "1.5.0",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.6.1",
|
||||
"@formatjs/intl-displaynames": "7.3.13",
|
||||
"@formatjs/intl-durationformat": "0.10.18",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.11",
|
||||
"@formatjs/intl-listformat": "8.3.13",
|
||||
"@formatjs/intl-locale": "5.3.10",
|
||||
"@formatjs/intl-numberformat": "9.4.0",
|
||||
"@formatjs/intl-pluralrules": "6.3.13",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.13",
|
||||
"@formatjs/intl-datetimeformat": "7.6.2",
|
||||
"@formatjs/intl-displaynames": "7.3.14",
|
||||
"@formatjs/intl-durationformat": "0.11.0",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.12",
|
||||
"@formatjs/intl-listformat": "8.3.14",
|
||||
"@formatjs/intl-locale": "5.3.11",
|
||||
"@formatjs/intl-numberformat": "9.4.1",
|
||||
"@formatjs/intl-pluralrules": "6.3.14",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.14",
|
||||
"@fullcalendar/core": "6.1.21",
|
||||
"@fullcalendar/daygrid": "6.1.21",
|
||||
"@fullcalendar/interaction": "6.1.21",
|
||||
"@fullcalendar/list": "6.1.21",
|
||||
"@fullcalendar/luxon3": "6.1.21",
|
||||
"@fullcalendar/timegrid": "6.1.21",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.0",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.1",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -84,7 +84,6 @@
|
||||
"@mdi/svg": "7.4.47",
|
||||
"@replit/codemirror-indentation-markers": "6.5.3",
|
||||
"@swc/helpers": "0.5.23",
|
||||
"@thomasloven/round-slider": "0.6.0",
|
||||
"@tsparticles/engine": "4.4.0",
|
||||
"@tsparticles/preset-links": "4.4.0",
|
||||
"@vibrant/color": "4.0.4",
|
||||
@@ -104,10 +103,10 @@
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.2",
|
||||
"hls.js": "1.7.3",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"intl-messageformat": "11.2.15",
|
||||
"js-yaml": "5.4.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
@@ -115,8 +114,9 @@
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.11",
|
||||
"marked": "18.0.12",
|
||||
"memoize-one": "6.0.0",
|
||||
"nanoid": "6.0.1",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
"punycode": "2.3.1",
|
||||
@@ -139,10 +139,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.1",
|
||||
"@babel/core": "8.0.5",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@babel/preset-env": "8.0.5",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
@@ -152,8 +152,8 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.2.2",
|
||||
"@rsdoctor/rspack-plugin": "1.6.4",
|
||||
"@rspack/core": "2.2.3",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -195,10 +195,10 @@
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.0.1",
|
||||
"jszip": "3.10.1",
|
||||
"jszip": "3.10.2",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.5.0",
|
||||
"lint-staged": "17.5.1",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -213,8 +213,8 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "8.2.2",
|
||||
"typescript-eslint": "8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
|
||||
+20
-18
@@ -23,6 +23,7 @@ import type {
|
||||
} from "../data/data_entry_flow";
|
||||
import "./ha-auth-form";
|
||||
import type { HaAuthForm } from "./ha-auth-form";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
|
||||
type State = "loading" | "error" | "step";
|
||||
|
||||
@@ -36,12 +37,14 @@ export class HaAuthFlow extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public oauth2State?: string;
|
||||
|
||||
@property({ attribute: false }) public localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public step?: DataEntryFlowStep;
|
||||
|
||||
@property({ attribute: false }) public initStoreToken = false;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state() private _storeToken = false;
|
||||
|
||||
@state() private _state: State = "loading";
|
||||
@@ -184,8 +187,8 @@ export class HaAuthFlow extends LitElement {
|
||||
>
|
||||
${
|
||||
this.step.type === "form"
|
||||
? this.localize("ui.panel.page-authorize.form.next")
|
||||
: this.localize("ui.panel.page-authorize.form.start_over")
|
||||
? this._localize("ui.panel.page-authorize.form.next")
|
||||
: this._localize("ui.panel.page-authorize.form.start_over")
|
||||
}
|
||||
</ha-button>
|
||||
</div>
|
||||
@@ -193,20 +196,20 @@ export class HaAuthFlow extends LitElement {
|
||||
case "error":
|
||||
return html`
|
||||
<ha-alert alert-type="error">
|
||||
${this.localize("ui.panel.page-authorize.form.error", {
|
||||
${this._localize("ui.panel.page-authorize.form.error", {
|
||||
error: this._errorMessage,
|
||||
})}
|
||||
</ha-alert>
|
||||
<div class="action">
|
||||
<ha-button @click=${this._startOver}>
|
||||
${this.localize("ui.panel.page-authorize.form.start_over")}
|
||||
${this._localize("ui.panel.page-authorize.form.start_over")}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
case "loading":
|
||||
return html`
|
||||
<ha-alert alert-type="info">
|
||||
${this.localize("ui.panel.page-authorize.form.working")}
|
||||
${this._localize("ui.panel.page-authorize.form.working")}
|
||||
</ha-alert>
|
||||
`;
|
||||
default:
|
||||
@@ -218,8 +221,8 @@ export class HaAuthFlow extends LitElement {
|
||||
switch (step.type) {
|
||||
case "abort":
|
||||
return html`
|
||||
${this.localize("ui.panel.page-authorize.abort_intro")}:
|
||||
${this.localize(
|
||||
${this._localize("ui.panel.page-authorize.abort_intro")}:
|
||||
${this._localize(
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.abort.${step.reason}`
|
||||
)}
|
||||
`;
|
||||
@@ -228,15 +231,14 @@ export class HaAuthFlow extends LitElement {
|
||||
<h1>
|
||||
${
|
||||
!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? this.localize("ui.panel.page-authorize.welcome_home")
|
||||
: this.localize("ui.panel.page-authorize.just_checking")
|
||||
? this._localize("ui.panel.page-authorize.welcome_home")
|
||||
: this._localize("ui.panel.page-authorize.just_checking")
|
||||
}
|
||||
</h1>
|
||||
${this._computeStepDescription(step)}
|
||||
${keyed(
|
||||
step.step_id,
|
||||
html`<ha-auth-form
|
||||
.localize=${this.localize}
|
||||
.data=${this._stepData!}
|
||||
.schema=${autocompleteLoginFields(step.data_schema)}
|
||||
.error=${step.errors}
|
||||
@@ -256,7 +258,7 @@ export class HaAuthFlow extends LitElement {
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
>
|
||||
${this.localize("ui.panel.page-authorize.store_token")}
|
||||
${this._localize("ui.panel.page-authorize.store_token")}
|
||||
</ha-checkbox>
|
||||
`
|
||||
: ""
|
||||
@@ -266,7 +268,7 @@ export class HaAuthFlow extends LitElement {
|
||||
href="https://www.home-assistant.io/docs/locked_out/#forgot-password"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>${this.localize("ui.panel.page-authorize.forgot_password")}</a
|
||||
>${this._localize("ui.panel.page-authorize.forgot_password")}</a
|
||||
>
|
||||
</div>
|
||||
`;
|
||||
@@ -336,13 +338,13 @@ export class HaAuthFlow extends LitElement {
|
||||
private _computeStepDescription(step: DataEntryFlowStepForm) {
|
||||
const resourceKey =
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.description` as const;
|
||||
return this.localize(resourceKey, step.description_placeholders);
|
||||
return this._localize(resourceKey, step.description_placeholders);
|
||||
}
|
||||
|
||||
private _computeLabelCallback(step: DataEntryFlowStepForm) {
|
||||
// Returns a callback for ha-form to calculate labels per schema object
|
||||
return (schema) =>
|
||||
this.localize(
|
||||
this._localize(
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.step.${step.step_id}.data.${schema.name}`
|
||||
);
|
||||
}
|
||||
@@ -350,13 +352,13 @@ export class HaAuthFlow extends LitElement {
|
||||
private _computeErrorCallback(step: DataEntryFlowStepForm) {
|
||||
// Returns a callback for ha-form to calculate error messages
|
||||
return (error) =>
|
||||
this.localize(
|
||||
this._localize(
|
||||
`ui.panel.page-authorize.form.providers.${step.handler[0]}.error.${error}`
|
||||
);
|
||||
}
|
||||
|
||||
private _unknownError() {
|
||||
return this.localize("ui.panel.page-authorize.form.unknown_error");
|
||||
return this._localize("ui.panel.page-authorize.form.unknown_error");
|
||||
}
|
||||
|
||||
private _startOver() {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable lit/prefer-static-styles */
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { HaForm } from "../components/ha-form/ha-form";
|
||||
import "./ha-auth-form-string";
|
||||
@@ -9,11 +10,13 @@ const localizeBaseKey = "ui.panel.page-authorize.form";
|
||||
|
||||
@customElement("ha-auth-form")
|
||||
export class HaAuthForm extends HaForm {
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected getFormProperties(): Record<string, any> {
|
||||
return {
|
||||
localize: this.localize,
|
||||
localize: this._localize,
|
||||
localizeBaseKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import "../components/ha-svg-icon";
|
||||
import type { AuthProvider, AuthUrlSearchParams } from "../data/auth";
|
||||
import { fetchAuthProviders } from "../data/auth";
|
||||
import { litLocalizeLiteMixin } from "../mixins/lit-localize-lite-mixin";
|
||||
import { provideLiteI18nMixin } from "../mixins/provide-lite-i18n-mixin";
|
||||
import { registerServiceWorker } from "../util/register-service-worker";
|
||||
import "./ha-auth-flow";
|
||||
|
||||
@@ -23,7 +24,9 @@ const appNames = {
|
||||
};
|
||||
|
||||
@customElement("ha-authorize")
|
||||
export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
|
||||
export class HaAuthorize extends provideLiteI18nMixin(
|
||||
litLocalizeLiteMixin(LitElement)
|
||||
) {
|
||||
@property({ attribute: false }) public clientId?: string;
|
||||
|
||||
@property({ attribute: false }) public redirectUri?: string;
|
||||
@@ -183,14 +186,12 @@ export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
|
||||
.redirectUri=${this.redirectUri}
|
||||
.oauth2State=${this.oauth2State}
|
||||
.authProvider=${this._authProvider}
|
||||
.localize=${this.localize}
|
||||
.initStoreToken=${this._preselectStoreToken}
|
||||
></ha-auth-flow>
|
||||
${
|
||||
inactiveProviders!.length > 0
|
||||
? html`
|
||||
<ha-pick-auth-provider
|
||||
.localize=${this.localize}
|
||||
.clientId=${this.clientId}
|
||||
.authProviders=${inactiveProviders!}
|
||||
@pick-auth-provider=${this._handleAuthProviderPick}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-icon-next";
|
||||
@@ -20,13 +21,15 @@ declare global {
|
||||
export class HaPickAuthProvider extends LitElement {
|
||||
@property({ attribute: false }) public authProviders: AuthProvider[] = [];
|
||||
|
||||
@property({ attribute: false }) public localize!: LocalizeFunc;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<h3>
|
||||
<span
|
||||
>${this.localize("ui.panel.page-authorize.pick_auth_provider")}</span
|
||||
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
|
||||
>
|
||||
</h3>
|
||||
<ha-list>
|
||||
|
||||
@@ -9,36 +9,34 @@ export const createDurationData = (
|
||||
}
|
||||
if (typeof duration !== "object") {
|
||||
if (typeof duration === "string" || isNaN(duration)) {
|
||||
const parts = duration?.toString().split(":") || [];
|
||||
const durationString = duration.toString().trim();
|
||||
// A leading "-" negates the whole period, matching
|
||||
// `cv.time_period_str` in core.
|
||||
const negative = durationString[0] === "-";
|
||||
const parts = durationString
|
||||
.split(":")
|
||||
.map((part) =>
|
||||
negative && part ? -Math.abs(Number(part)) : Number(part)
|
||||
);
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: Number(parts[0]) };
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length > 3) {
|
||||
return undefined;
|
||||
}
|
||||
const seconds = Number(parts[2]) || 0;
|
||||
const seconds_whole = Math.floor(seconds);
|
||||
const seconds = parts[2] || 0;
|
||||
const secondsWhole = Math.trunc(seconds);
|
||||
return {
|
||||
hours: Number(parts[0]) || 0,
|
||||
minutes: Number(parts[1]) || 0,
|
||||
seconds: seconds_whole,
|
||||
milliseconds: Math.floor(
|
||||
Number((seconds - seconds_whole).toFixed(4)) * 1000
|
||||
hours: parts[0] || 0,
|
||||
minutes: parts[1] || 0,
|
||||
seconds: secondsWhole,
|
||||
milliseconds: Math.trunc(
|
||||
Number((seconds - secondsWhole).toFixed(4)) * 1000
|
||||
),
|
||||
};
|
||||
}
|
||||
return { seconds: duration };
|
||||
}
|
||||
if (!("days" in duration)) {
|
||||
return duration;
|
||||
}
|
||||
const { days, minutes, seconds, milliseconds } = duration;
|
||||
let hours = duration.hours || 0;
|
||||
hours = (hours || 0) + (days || 0) * 24;
|
||||
return {
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
milliseconds,
|
||||
};
|
||||
return duration;
|
||||
};
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export const stopPropagation = (ev) => ev.stopPropagation();
|
||||
|
||||
export const stopKeydownEnterSpacePropagation = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,44 +1,70 @@
|
||||
import type { HassEntity } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
EntityRegistryDisplayEntry,
|
||||
EntityRegistryEntry,
|
||||
} from "../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { ensureArray } from "../array/ensure-array";
|
||||
import { computeRTL } from "../util/compute_rtl";
|
||||
import { computeAreaName } from "./compute_area_name";
|
||||
import { computeDeviceName } from "./compute_device_name";
|
||||
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
|
||||
import {
|
||||
computeEntityEntryName,
|
||||
computeEntityName,
|
||||
entityUseDeviceName,
|
||||
} from "./compute_entity_name";
|
||||
import { computeFloorName } from "./compute_floor_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import { getEntityContext } from "./context/get_entity_context";
|
||||
import {
|
||||
getEntityContext,
|
||||
getEntityEntryContext,
|
||||
} from "./context/get_entity_context";
|
||||
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
|
||||
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
export { DEFAULT_ENTITY_NAME, ENTITY_NAME_TYPES } from "./entity_name_config";
|
||||
export type {
|
||||
EntityNameItem,
|
||||
EntityNameOptions,
|
||||
EntityNameType,
|
||||
} from "./entity_name_config";
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"floor",
|
||||
"area",
|
||||
"parent_device",
|
||||
"device",
|
||||
"entity",
|
||||
] as const;
|
||||
// Joins items that need no entity context. Returns undefined as soon as one
|
||||
// item references the registries (device, area, ...).
|
||||
const computeTextOnlyName = (
|
||||
items: EntityNameItem[],
|
||||
options?: EntityNameOptions
|
||||
): string | undefined =>
|
||||
items.every((n) => n.type === "text")
|
||||
? items
|
||||
.map((item) => item.text)
|
||||
.join(options?.separator ?? DEFAULT_SEPARATOR)
|
||||
: undefined;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export interface EntityNameOptions {
|
||||
separator?: string;
|
||||
}
|
||||
/**
|
||||
* Name formatter used before the registry-aware one is installed
|
||||
* (see state/connection-mixin and state/state-display-mixin). It honours a
|
||||
* configured string or text-only name and falls back to the friendly name for
|
||||
* anything that needs entity context, so a configured name is never dropped
|
||||
* while the real formatter is still loading.
|
||||
*/
|
||||
export const computeEntityNameDisplayWithoutContext = (
|
||||
stateObj: HassEntity,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
options?: EntityNameOptions
|
||||
): string => {
|
||||
if (typeof name === "string") {
|
||||
return name;
|
||||
}
|
||||
if (!name) {
|
||||
return computeStateName(stateObj);
|
||||
}
|
||||
return (
|
||||
computeTextOnlyName(ensureArray(name), options) ??
|
||||
computeStateName(stateObj)
|
||||
);
|
||||
};
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
@@ -63,8 +89,9 @@ export const computeEntityNameDisplay = (
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
// If all items are text, just join them
|
||||
if (items.every((n) => n.type === "text")) {
|
||||
return items.map((item) => item.text).join(separator);
|
||||
const textOnlyName = computeTextOnlyName(items, options);
|
||||
if (textOnlyName !== undefined) {
|
||||
return textOnlyName;
|
||||
}
|
||||
|
||||
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
|
||||
@@ -102,18 +129,49 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, parentDevice, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
const entry = entities[stateObj.entity_id] as
|
||||
EntityRegistryDisplayEntry | undefined;
|
||||
|
||||
if (!entry) {
|
||||
return name.map((item) =>
|
||||
item.type === "entity"
|
||||
? computeStateName(stateObj)
|
||||
: item.type === "text"
|
||||
? item.text
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
return computeEntityEntryNameList(
|
||||
entry,
|
||||
name,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
};
|
||||
|
||||
export const computeEntityEntryNameList = (
|
||||
entry: EntityRegistryDisplayEntry | EntityRegistryEntry,
|
||||
name: EntityNameItem[],
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
const { device, parentDevice, area, floor } = getEntityEntryContext(
|
||||
entry,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const names = name.map((item) => {
|
||||
return name.map((item) => {
|
||||
switch (item.type) {
|
||||
case "entity":
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
return computeEntityEntryName(entry, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
@@ -128,8 +186,29 @@ export const computeEntityNameList = (
|
||||
return "";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return names;
|
||||
export const computeEntitySearchLabels = (
|
||||
stateObj: HassEntity,
|
||||
entities: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"],
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
) => {
|
||||
const { device, parentDevice, area } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
return {
|
||||
entityName: computeEntityName(stateObj, entities, devices) || null,
|
||||
friendlyName: computeStateName(stateObj) || null,
|
||||
deviceName: (device && computeDeviceName(device)) || null,
|
||||
parentDeviceName: (parentDevice && computeDeviceName(parentDevice)) || null,
|
||||
areaName: (area && computeAreaName(area)) || null,
|
||||
};
|
||||
};
|
||||
|
||||
export interface EntityPickerDisplay {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shape of a composed entity name, kept free of runtime dependencies so that
|
||||
* modules in the core entry chunk can name an entity without pulling in the
|
||||
* registry-aware formatters from `compute_entity_name_display`.
|
||||
*/
|
||||
|
||||
export const ENTITY_NAME_TYPES = [
|
||||
"floor",
|
||||
"area",
|
||||
"parent_device",
|
||||
"device",
|
||||
"entity",
|
||||
] as const;
|
||||
|
||||
export type EntityNameType = (typeof ENTITY_NAME_TYPES)[number];
|
||||
|
||||
export type EntityNameItem =
|
||||
| {
|
||||
type: EntityNameType;
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export interface EntityNameOptions {
|
||||
separator?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "parent_device" },
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
@@ -29,7 +29,7 @@ export const FIXED_DOMAIN_STATES = {
|
||||
fan: ["on", "off"],
|
||||
humidifier: ["on", "off"],
|
||||
input_boolean: ["on", "off"],
|
||||
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
|
||||
lawn_mower: ["error", "paused", "mowing", "returning", "docked", "idle"],
|
||||
light: ["on", "off"],
|
||||
lock: [
|
||||
"jammed",
|
||||
@@ -95,6 +95,7 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
|
||||
"door",
|
||||
"garage_door",
|
||||
"gas",
|
||||
"glass_break",
|
||||
"heat",
|
||||
"light",
|
||||
"lock",
|
||||
|
||||
@@ -36,7 +36,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
case "person":
|
||||
return compareState !== "not_home";
|
||||
case "lawn_mower":
|
||||
return !["docked", "paused"].includes(compareState);
|
||||
return !["docked", "paused", "idle"].includes(compareState);
|
||||
case "lock":
|
||||
return compareState !== "locked";
|
||||
case "media_player":
|
||||
|
||||
@@ -186,6 +186,11 @@ export class LeafletMapEngine implements MapEngine {
|
||||
this.leafletMap.fitBounds(bounds, {
|
||||
maxZoom: options?.maxZoom,
|
||||
animate: options?.animate,
|
||||
paddingTopLeft: [options?.padding?.left ?? 0, options?.padding?.top ?? 0],
|
||||
paddingBottomRight: [
|
||||
options?.padding?.right ?? 0,
|
||||
options?.padding?.bottom ?? 0,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -123,8 +123,6 @@ interface ManagedMarker {
|
||||
draggable?: boolean;
|
||||
onDragEnd?: (location: MapLatLng) => void;
|
||||
dragging?: boolean;
|
||||
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
|
||||
staleLocation?: MapLatLng;
|
||||
mlMarker?: MapLibreMarker;
|
||||
decoration?: MapItemHandle;
|
||||
removed?: boolean;
|
||||
@@ -133,6 +131,8 @@ interface ManagedMarker {
|
||||
interface ClusterGroup {
|
||||
/** Members are shown in a bubble at their spot instead of an icon */
|
||||
open?: boolean;
|
||||
/** Grouped by key (a zone), so it bubbles even with a single member */
|
||||
keyed?: boolean;
|
||||
members: ManagedMarker[];
|
||||
center: MapLatLng;
|
||||
iconMarker?: MapLibreMarker;
|
||||
@@ -518,13 +518,26 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
options?.maxZoom !== undefined
|
||||
? options.maxZoom - ZOOM_OFFSET
|
||||
: undefined;
|
||||
// Passed per fit: easeTo's padding would stick to the map
|
||||
const padding = {
|
||||
top: options?.padding?.top ?? 0,
|
||||
right: options?.padding?.right ?? 0,
|
||||
bottom: options?.padding?.bottom ?? 0,
|
||||
left: options?.padding?.left ?? 0,
|
||||
};
|
||||
if (minLat === maxLat && minLng === maxLng) {
|
||||
// Zero-area bounds: center on the point
|
||||
this._map.easeTo({
|
||||
center: [minLng, minLat],
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
});
|
||||
// Zero-area bounds: center on the point, keeping the zoom unless given
|
||||
this._map.fitBounds(
|
||||
[
|
||||
[minLng, minLat],
|
||||
[minLng, minLat],
|
||||
],
|
||||
{
|
||||
maxZoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
padding,
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
@@ -535,7 +548,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[minLng - lngPad, minLat - latPad],
|
||||
[maxLng + lngPad, maxLat + latPad],
|
||||
],
|
||||
{ maxZoom, animate: options?.animate }
|
||||
{ maxZoom, animate: options?.animate, padding }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -600,19 +613,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
},
|
||||
clusterData: options.clusterData,
|
||||
setLocation: (newLocation) => {
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
if (managed.dragging) {
|
||||
return;
|
||||
}
|
||||
// Skip one update echoing the pre-drag location (the save has not returned yet)
|
||||
const stale = managed.staleLocation;
|
||||
managed.staleLocation = undefined;
|
||||
if (
|
||||
stale &&
|
||||
newLocation[0] === stale[0] &&
|
||||
newLocation[1] === stale[1]
|
||||
) {
|
||||
return;
|
||||
}
|
||||
managed.location = newLocation;
|
||||
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
|
||||
},
|
||||
@@ -663,7 +667,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (managed.draggable) {
|
||||
managed.mlMarker.on("dragstart", () => {
|
||||
managed.dragging = true;
|
||||
managed.staleLocation = managed.location;
|
||||
});
|
||||
managed.mlMarker.on("dragend", () => {
|
||||
managed.dragging = false;
|
||||
@@ -801,10 +804,8 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
resizeHandle?.setAttribute("aria-valuetext", radiusText);
|
||||
};
|
||||
|
||||
// Skip one update echoing the pre-edit values (the save has not returned yet)
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
let dragging = false;
|
||||
let staleCenter: MapLatLng | undefined;
|
||||
let staleRadius: number | undefined;
|
||||
|
||||
if (options.resizable) {
|
||||
const east = pointEastOf(center, options.radius);
|
||||
@@ -847,8 +848,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (keyboardRadius === undefined) {
|
||||
// Host updates treat a key resize like a drag
|
||||
dragging = true;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
}
|
||||
currentRadius = Math.max(
|
||||
1,
|
||||
@@ -875,19 +874,11 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[centerMarker, resizeMarker].forEach((handleMarker) => {
|
||||
handleMarker?.on("dragstart", () => {
|
||||
dragging = true;
|
||||
staleCenter = currentCenter;
|
||||
staleRadius = currentRadius;
|
||||
});
|
||||
handleMarker?.on("dragend", () => {
|
||||
dragging = false;
|
||||
});
|
||||
});
|
||||
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
|
||||
staleCenter !== undefined &&
|
||||
staleRadius !== undefined &&
|
||||
candidateCenter[0] === staleCenter[0] &&
|
||||
candidateCenter[1] === staleCenter[1] &&
|
||||
candidateRadius === staleRadius;
|
||||
|
||||
if (options.moveable) {
|
||||
centerMarker.on("drag", () => {
|
||||
@@ -944,12 +935,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
const stale = isStale(newCenter, newRadius);
|
||||
staleCenter = undefined;
|
||||
staleRadius = undefined;
|
||||
if (stale) {
|
||||
return;
|
||||
}
|
||||
currentCenter = newCenter;
|
||||
currentRadius = newRadius;
|
||||
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
|
||||
@@ -1219,13 +1204,16 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const clusterable = this._markers.filter(
|
||||
(managed) => managed.options.cluster && !managed.removed
|
||||
);
|
||||
// A member that had focus hands it to the icon replacing it; read before
|
||||
// the open bubble holding it is removed
|
||||
// A member or bubble that had focus hands it to the icon replacing it;
|
||||
// read before the bubble holding it is removed
|
||||
const active = (
|
||||
this._map.getContainer().getRootNode() as Document | ShadowRoot
|
||||
).activeElement;
|
||||
const focusedMember = active
|
||||
? clusterable.find((managed) => managed.element.contains(active))
|
||||
? (clusterable.find((managed) => managed.element.contains(active)) ??
|
||||
this._clusterGroups.find((group) =>
|
||||
group.iconMarker?.getElement().contains(active)
|
||||
)?.members[0])
|
||||
: undefined;
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
|
||||
@@ -1250,6 +1238,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const groups: {
|
||||
seed: { x: number; y: number };
|
||||
members: ManagedMarker[];
|
||||
keyed?: boolean;
|
||||
}[] = [];
|
||||
|
||||
// Keyed groups first; one spread too wide falls through to proximity
|
||||
@@ -1272,8 +1261,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
Math.max(...xs) - Math.min(...xs),
|
||||
Math.max(...ys) - Math.min(...ys)
|
||||
);
|
||||
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members });
|
||||
// A keyed group (a zone) bubbles even with a single member, so a lone
|
||||
// person or device in a zone still shows in a bubble pinned to it.
|
||||
if (members.length === 1 || spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members, keyed: true });
|
||||
} else {
|
||||
ungrouped.push(...members);
|
||||
}
|
||||
@@ -1299,6 +1290,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
this._clusterGroups = groups.map((group) => ({
|
||||
members: group.members,
|
||||
keyed: group.keyed,
|
||||
center: [
|
||||
group.members.reduce((sum, m) => sum + m.location[0], 0) /
|
||||
group.members.length,
|
||||
@@ -1310,7 +1302,9 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
for (const group of this._clusterGroups) {
|
||||
group.iconMarker = undefined;
|
||||
if (group.members.length === 1) {
|
||||
// A lone non-keyed marker shows plainly; a lone zone occupant falls
|
||||
// through to the bubble path so it renders in a bubble at its zone.
|
||||
if (group.members.length === 1 && !group.keyed) {
|
||||
this._showMarker(group.members[0]);
|
||||
continue;
|
||||
}
|
||||
@@ -1379,8 +1373,10 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const group = this._clusterGroups.find((candidate) =>
|
||||
candidate.members.includes(focusedMember)
|
||||
);
|
||||
if (group?.iconMarker && !group.open) {
|
||||
group.iconMarker.getElement().focus();
|
||||
if (group && !group.open) {
|
||||
// A singleton group has no bubble icon; the member shows on its own,
|
||||
// so send focus back to that member rather than to the document.
|
||||
(group.iconMarker?.getElement() ?? focusedMember.element).focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import memoizeOne from "memoize-one";
|
||||
import { getColorByIndex } from "../color/colors";
|
||||
import { computeDomain } from "../entity/compute_domain";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
|
||||
/**
|
||||
* Map colors for entities, by registry creation order, so an entity has the
|
||||
* same color on every map. The registry comes from the fullEntitiesContext;
|
||||
* entities without an entry (e.g. YAML zones) get a color derived from their
|
||||
* id.
|
||||
*/
|
||||
|
||||
export const HOME_ZONE_ENTITY_ID = "zone.home";
|
||||
|
||||
/** Domains whose entities are colored by creation order */
|
||||
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
|
||||
|
||||
// One index per registry update, shared by every map on the page. Zones,
|
||||
// persons and trackers share one sequence, so a person never has the color
|
||||
// of the zone it is in.
|
||||
const creationIndex = memoizeOne(
|
||||
(entries: EntityRegistryEntry[]): Record<string, number> => {
|
||||
const index: Record<string, number> = {};
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
|
||||
// The home zone has a fixed color and does not take a palette slot
|
||||
entry.entity_id !== HOME_ZONE_ENTITY_ID
|
||||
)
|
||||
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
|
||||
.forEach((entry, i) => {
|
||||
index[entry.entity_id] = i;
|
||||
});
|
||||
return index;
|
||||
}
|
||||
);
|
||||
|
||||
// For entities without a registry entry
|
||||
const hashIndex = (entityId: string): number => {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < entityId.length; i++) {
|
||||
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
|
||||
}
|
||||
return hash;
|
||||
};
|
||||
|
||||
/** The palette color of an entity on a map */
|
||||
export const entityMapColor = (
|
||||
entityId: string,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string =>
|
||||
getColorByIndex(
|
||||
creationIndex(entries)[entityId] ?? hashIndex(entityId),
|
||||
computedStyles
|
||||
);
|
||||
|
||||
/** The color a new zone will take once created, from the next creation-order slot */
|
||||
export const nextZoneColor = (
|
||||
passive: boolean,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string => {
|
||||
if (passive) {
|
||||
return computedStyles.getPropertyValue("--secondary-text-color");
|
||||
}
|
||||
return getColorByIndex(
|
||||
Object.keys(creationIndex(entries)).length,
|
||||
computedStyles
|
||||
);
|
||||
};
|
||||
|
||||
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
|
||||
export const zoneColor = (
|
||||
entityId: string,
|
||||
passive: boolean,
|
||||
entries: EntityRegistryEntry[],
|
||||
computedStyles: CSSStyleDeclaration
|
||||
): string => {
|
||||
if (entityId === HOME_ZONE_ENTITY_ID) {
|
||||
return computedStyles.getPropertyValue("--primary-color");
|
||||
}
|
||||
if (passive) {
|
||||
return computedStyles.getPropertyValue("--secondary-text-color");
|
||||
}
|
||||
return entityMapColor(entityId, entries, computedStyles);
|
||||
};
|
||||
@@ -42,6 +42,15 @@ export interface MapFitOptions {
|
||||
pad?: number;
|
||||
/** Ease the camera to the bounds instead of jumping; defaults to true */
|
||||
animate?: boolean;
|
||||
/** Viewport pixels covered by overlays; the bounds fit inside the rest */
|
||||
padding?: MapFitPadding;
|
||||
}
|
||||
|
||||
export interface MapFitPadding {
|
||||
top?: number;
|
||||
right?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
}
|
||||
|
||||
export interface MapMarkerOptions {
|
||||
|
||||
@@ -16,7 +16,11 @@ export const setMarkerAccessibility = (
|
||||
): void => {
|
||||
clearMarkerAccessibility(element);
|
||||
const owned: string[] = [];
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
if (
|
||||
title &&
|
||||
!element.hasAttribute("aria-label") &&
|
||||
!element.hasAttribute("aria-labelledby")
|
||||
) {
|
||||
element.setAttribute("aria-label", title);
|
||||
owned.push("aria-label");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getContrastedColorHex } from "../color/rgb";
|
||||
|
||||
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
|
||||
|
||||
export const ZONE_CIRCLE_SIZE = 36;
|
||||
|
||||
// Content color contrasting the fill; not every theme color parses
|
||||
export const contrastingZoneContent = (color: string): string => {
|
||||
try {
|
||||
return getContrastedColorHex(color.trim());
|
||||
} catch {
|
||||
return "#ffffff";
|
||||
}
|
||||
};
|
||||
|
||||
export const zoneInitials = (name: string): string =>
|
||||
name
|
||||
.split(" ")
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.slice(0, 2);
|
||||
|
||||
export const createZoneMarkerElement = (options: {
|
||||
color: string;
|
||||
icon?: string;
|
||||
/** Path for an ha-svg-icon, when there is no icon name */
|
||||
iconPath?: string;
|
||||
name: string;
|
||||
}): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "zone-circle";
|
||||
element.style.backgroundColor = options.color;
|
||||
element.style.color = contrastingZoneContent(options.color);
|
||||
if (options.icon) {
|
||||
const icon = document.createElement("ha-icon");
|
||||
icon.setAttribute("icon", options.icon);
|
||||
element.appendChild(icon);
|
||||
} else if (options.iconPath) {
|
||||
const icon = document.createElement("ha-svg-icon");
|
||||
icon.setAttribute("path", options.iconPath);
|
||||
element.appendChild(icon);
|
||||
} else {
|
||||
const initials = document.createElement("span");
|
||||
initials.textContent = zoneInitials(options.name);
|
||||
element.appendChild(initials);
|
||||
}
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the zone marker, included by ha-map */
|
||||
export const zoneMarkerStyles = `
|
||||
.zone-circle {
|
||||
width: ${ZONE_CIRCLE_SIZE}px;
|
||||
height: ${ZONE_CIRCLE_SIZE}px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
overflow: hidden;
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
|
||||
}
|
||||
`;
|
||||
@@ -33,7 +33,7 @@ export type FormatEntityAttributeNameFunc = (
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
options?: EntityNameOptions
|
||||
) => string;
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
DateFormat,
|
||||
FirstWeekday,
|
||||
NumberFormat,
|
||||
TimeFormat,
|
||||
TimeZone,
|
||||
} from "../../data/translation";
|
||||
import { translationMetadata } from "../../resources/translations-metadata";
|
||||
import type { HomeAssistantInternationalization } from "../../types";
|
||||
import type { LocalizeFunc } from "./localize";
|
||||
|
||||
/**
|
||||
* Builds an internationalization context value for the "lite" localize mode
|
||||
* used before a `hass` object exists. Locale falls back to browser defaults and
|
||||
* the lazy loaders are stubs that warn when called.
|
||||
*/
|
||||
export const buildLiteInternationalization = (
|
||||
language: string,
|
||||
localize: LocalizeFunc
|
||||
): HomeAssistantInternationalization => ({
|
||||
language,
|
||||
selectedLanguage: language,
|
||||
locale: {
|
||||
language,
|
||||
number_format: NumberFormat.language,
|
||||
time_format: TimeFormat.language,
|
||||
date_format: DateFormat.language,
|
||||
time_zone: TimeZone.local,
|
||||
first_weekday: FirstWeekday.language,
|
||||
},
|
||||
localize,
|
||||
translationMetadata,
|
||||
loadBackendTranslation: async (category, integrations, configFlow) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"loadBackendTranslation called in lite i18n mode; backend strings are not available",
|
||||
{ category, integrations, configFlow }
|
||||
);
|
||||
return localize;
|
||||
},
|
||||
loadFragmentTranslation: async (fragment) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`loadFragmentTranslation("${fragment}") called in lite i18n mode; fragment not loaded`
|
||||
);
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
export const GITHUB_CORE_ISSUES_URL =
|
||||
"https://github.com/home-assistant/core/issues";
|
||||
|
||||
export const GITHUB_FRONTEND_ISSUES_URL =
|
||||
"https://github.com/home-assistant/frontend/issues";
|
||||
@@ -90,6 +90,7 @@ export class HaAutomationConditionSummary extends LitElement {
|
||||
></ha-automation-row-options>`
|
||||
: nothing
|
||||
}
|
||||
<slot name="references"></slot>
|
||||
${
|
||||
note
|
||||
? html`
|
||||
|
||||
@@ -93,9 +93,9 @@ export class HaAutomationRowEventChip extends LitElement {
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
--background-color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--background-color-hover: var(--ha-color-fill-neutral-normal-hover);
|
||||
--text-color: var(--ha-color-on-neutral-normal);
|
||||
--background-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
--background-color-hover: var(--ha-color-fill-neutral-loud-hover);
|
||||
--text-color: var(--ha-color-on-neutral-loud);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
|
||||
@@ -152,7 +152,7 @@ export class HaAutomationRow extends LitElement {
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
:host([building-block]) .leading-icon-wrapper {
|
||||
background-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
border: 1px solid var(--ha-color-border-neutral-normal);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: var(--ha-space-1);
|
||||
margin-top: 10px;
|
||||
@@ -172,7 +172,6 @@ export class HaAutomationRow extends LitElement {
|
||||
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
|
||||
:host([building-block]) ::slotted(#condition-icon) {
|
||||
--mdc-icon-size: var(--ha-space-5);
|
||||
color: var(--white-color);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
:host([collapsed]) .expand-button {
|
||||
@@ -185,9 +184,16 @@ export class HaAutomationRow extends LitElement {
|
||||
outline-offset: -2px;
|
||||
outline-width: 2px;
|
||||
}
|
||||
:host([disabled]) .row {
|
||||
border-top-right-radius: var(--ha-border-radius-square);
|
||||
border-top-left-radius: var(--ha-border-radius-square);
|
||||
:host([disabled]) .row,
|
||||
:host([disabled]) ::slotted([slot="leading-icon"]) {
|
||||
color: var(--ha-color-text-disabled);
|
||||
}
|
||||
:host([disabled]) .leading-icon-wrapper {
|
||||
opacity: 0.6;
|
||||
}
|
||||
:host([disabled]) ::slotted([slot="header"]) {
|
||||
opacity: 0.6;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
.header {
|
||||
position: relative;
|
||||
|
||||
@@ -39,7 +39,7 @@ const FRAME_SIZES = [
|
||||
].flat();
|
||||
|
||||
// Always rounds down, so no chart ends up with fewer frames than it asked for.
|
||||
function snapFrameSize(step: number): number {
|
||||
export function snapFrameSize(step: number): number {
|
||||
if (step >= DAY) {
|
||||
return Math.floor(step / DAY) * DAY;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { listenMediaQuery } from "../../common/dom/media_query";
|
||||
import { afterNextRender } from "../../common/util/render-status";
|
||||
import { MobileAwareMixin } from "../../mixins/mobile-aware-mixin";
|
||||
import { uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type {
|
||||
@@ -57,7 +58,17 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||
const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
const DEFAULT_CHART_WIDTH = 500;
|
||||
export const DEFAULT_CHART_WIDTH = 500;
|
||||
// Slack so a chart is up to date before a scroll can reach it. A phone screen
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
] as const;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -106,7 +117,7 @@ export type CustomLegendOption = ECOption["legend"] & {
|
||||
};
|
||||
|
||||
@customElement("ha-chart-base")
|
||||
export class HaChartBase extends LitElement {
|
||||
export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -183,20 +194,38 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private _layoutTransitionActive = false;
|
||||
|
||||
// Both start visible so a chart on screen renders on its first update rather
|
||||
// than waiting a frame for the observers. A chart that starts off screen is
|
||||
// therefore still built once; only its later updates are gated.
|
||||
private _intersecting = true;
|
||||
|
||||
private _hasSize = true;
|
||||
|
||||
// Reported by the ResizeObserver, so rendering and downsampling need not
|
||||
// measure layout themselves once it has fired.
|
||||
private _contentWidth?: number;
|
||||
|
||||
// @ts-ignore
|
||||
private _resizeController = new ResizeController(this, {
|
||||
callback: () => {
|
||||
callback: (entries) => {
|
||||
// The controller also fires once with no entries when it starts observing.
|
||||
const contentRect = entries[entries.length - 1]?.contentRect;
|
||||
if (contentRect) {
|
||||
this._contentWidth = contentRect.width;
|
||||
this._hasSize = contentRect.width > 0 && contentRect.height > 0;
|
||||
}
|
||||
if (this.chart) {
|
||||
if (this._suspendResize) {
|
||||
this._shouldResizeChart = true;
|
||||
return;
|
||||
}
|
||||
if (!this.chart.getZr().animation.isFinished()) {
|
||||
} else if (!this.chart.getZr().animation.isFinished()) {
|
||||
this._shouldResizeChart = true;
|
||||
} else {
|
||||
this.chart.resize();
|
||||
}
|
||||
}
|
||||
if (!this._suspendResize) {
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -210,10 +239,24 @@ export class HaChartBase extends LitElement {
|
||||
|
||||
private _pendingSetup = false;
|
||||
|
||||
private _pendingUpdate?: Set<PropertyKey>;
|
||||
|
||||
private _pendingOptions?: HaECOption;
|
||||
|
||||
private _pendingZoom?: [number, number, boolean];
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._legendPointerCancel();
|
||||
this._pendingSetup = false;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
this._pendingZoom = undefined;
|
||||
// The observers are about to be torn down, so nothing would correct a stale
|
||||
// value if this element is reattached inside a hidden container.
|
||||
this._intersecting = false;
|
||||
this._hasSize = false;
|
||||
this._contentWidth = undefined;
|
||||
while (this._listeners.length) {
|
||||
this._listeners.pop()!();
|
||||
}
|
||||
@@ -227,14 +270,31 @@ export class HaChartBase extends LitElement {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._pendingSetup = true;
|
||||
afterNextRender(() => {
|
||||
if (this.isConnected && this._pendingSetup) {
|
||||
this._pendingSetup = false;
|
||||
this._setupChart();
|
||||
}
|
||||
});
|
||||
afterNextRender(() => this._applyDeferredWork());
|
||||
}
|
||||
|
||||
const handleVisibilityChange = () => this._applyDeferredWork();
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
this._listeners.push(() =>
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange)
|
||||
);
|
||||
|
||||
const intersectionObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
this._intersecting = entries[entries.length - 1].isIntersecting;
|
||||
if (!this._suspendResize) {
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
},
|
||||
{
|
||||
rootMargin: this._isMobileSize
|
||||
? VISIBILITY_ROOT_MARGIN_NARROW
|
||||
: VISIBILITY_ROOT_MARGIN,
|
||||
}
|
||||
);
|
||||
intersectionObserver.observe(this);
|
||||
this._listeners.push(() => intersectionObserver.disconnect());
|
||||
|
||||
this._listeners.push(
|
||||
listenMediaQuery("(prefers-reduced-motion)", (matches) => {
|
||||
if (this._reducedMotion !== matches) {
|
||||
@@ -300,6 +360,7 @@ export class HaChartBase extends LitElement {
|
||||
this._suspendResize = this._layoutTransitionActive;
|
||||
if (!this._suspendResize) {
|
||||
this._resizeChartIfNeeded();
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
};
|
||||
window.addEventListener("hass-layout-transition", handleLayoutTransition);
|
||||
@@ -312,24 +373,105 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
if (this.isConnected) {
|
||||
this._setupChart();
|
||||
if (!this.isConnected) {
|
||||
return;
|
||||
}
|
||||
// The only measurement taken here: neither observer has reported yet, and a
|
||||
// chart first rendered inside a hidden container has to defer its setup
|
||||
// rather than build against a guessed width.
|
||||
this._hasSize = this.clientWidth > 0 && this.clientHeight > 0;
|
||||
if (this._isVisible()) {
|
||||
this._setupChart();
|
||||
} else {
|
||||
this._pendingSetup = true;
|
||||
}
|
||||
}
|
||||
|
||||
private _isVisible() {
|
||||
return (
|
||||
document.visibilityState !== "hidden" &&
|
||||
this._hasSize &&
|
||||
this._intersecting
|
||||
);
|
||||
}
|
||||
|
||||
private _deferUpdate(changedProps: PropertyValues) {
|
||||
for (const prop of DEFERRED_PROPS) {
|
||||
if (!changedProps.has(prop)) {
|
||||
continue;
|
||||
}
|
||||
if (!this._pendingUpdate) {
|
||||
this._pendingUpdate = new Set();
|
||||
}
|
||||
if (prop === "options" && !this._pendingUpdate.has(prop)) {
|
||||
// The one previous value a replay reads, and it has to stay the options
|
||||
// the chart currently renders, not those of a later deferred change.
|
||||
this._pendingOptions = changedProps.get(prop) as HaECOption | undefined;
|
||||
}
|
||||
this._pendingUpdate.add(prop);
|
||||
}
|
||||
}
|
||||
|
||||
private async _applyDeferredWork() {
|
||||
if (!this._pendingSetup && !this._pendingUpdate) {
|
||||
return;
|
||||
}
|
||||
if (!this.isConnected || !this._isVisible()) {
|
||||
return;
|
||||
}
|
||||
if (this._pendingSetup) {
|
||||
await this._setupChart();
|
||||
return;
|
||||
}
|
||||
const pending = this._pendingUpdate!;
|
||||
const previousOptions = this._pendingOptions;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
this._applyChartUpdate(pending, previousOptions);
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues): void {
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
if (changedProps.has("_themes") && this.hasUpdated) {
|
||||
this._setupChart();
|
||||
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
|
||||
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
|
||||
return;
|
||||
}
|
||||
const invisible = !this._isVisible();
|
||||
if (themeChanged) {
|
||||
if (invisible) {
|
||||
this._pendingSetup = true;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
} else {
|
||||
this._setupChart();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("options")) {
|
||||
// Separate 'if' from below since this must updated before _getSeries()
|
||||
// Separate 'if' from below since this must be updated before _getSeries().
|
||||
// It stays out of _applyChartUpdate so a replay cannot request another
|
||||
// update and turn one catch-up render into two.
|
||||
this._updateHiddenStatsFromOptions(this.options);
|
||||
}
|
||||
if (invisible) {
|
||||
if (!this._pendingSetup) {
|
||||
this._deferUpdate(changedProps);
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._applyChartUpdate(
|
||||
changedProps,
|
||||
changedProps.get("options") as HaECOption | undefined
|
||||
);
|
||||
}
|
||||
|
||||
private _applyChartUpdate(
|
||||
changedProps: { has: (prop: string) => boolean },
|
||||
previousOptions: HaECOption | undefined
|
||||
) {
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
|
||||
chartOptions.series = this._getSeries();
|
||||
// New data, or a series shown again, may well be convertible where the
|
||||
@@ -347,12 +489,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
if (
|
||||
this._compareCustomLegendOptions(
|
||||
changedProps.get("options"),
|
||||
this.options
|
||||
)
|
||||
) {
|
||||
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
|
||||
// custom legend changes may require a resize to layout properly
|
||||
this._shouldResizeChart = true;
|
||||
this._resizeAnimationDuration = 250;
|
||||
@@ -463,10 +600,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
const overflowLimit = isMobile
|
||||
const overflowLimit = this._isMobileSize
|
||||
? LEGEND_OVERFLOW_LIMIT_MOBILE
|
||||
: LEGEND_OVERFLOW_LIMIT;
|
||||
return html`<div
|
||||
@@ -578,7 +712,11 @@ export class HaChartBase extends LitElement {
|
||||
// costs the user their place in the tab order, so stay programmatically
|
||||
// focusable for as long as we hold focus, however we stop being sonifiable.
|
||||
this._sonificationFocusHeld = true;
|
||||
if (this._sonification || this._sonificationLoading || !this.chart) {
|
||||
if (this._sonification || this._sonificationLoading) {
|
||||
return;
|
||||
}
|
||||
await this._applyDeferredWork();
|
||||
if (!this.chart) {
|
||||
return;
|
||||
}
|
||||
this._sonificationLoading = true;
|
||||
@@ -635,14 +773,22 @@ export class HaChartBase extends LitElement {
|
||||
);
|
||||
|
||||
private async _setupChart() {
|
||||
if (this._loading) return;
|
||||
if (this._loading) {
|
||||
this._pendingSetup = true;
|
||||
return;
|
||||
}
|
||||
this._loading = true;
|
||||
this._pendingSetup = false;
|
||||
this._pendingUpdate = undefined;
|
||||
this._pendingOptions = undefined;
|
||||
try {
|
||||
// The connection holds a reference to the chart instance, so it cannot
|
||||
// outlive it. Focusing the chart again reconnects.
|
||||
this._disposeSonification();
|
||||
if (this.chart) {
|
||||
this.chart.dispose();
|
||||
this.chart = undefined;
|
||||
this._originalZrFlush = undefined;
|
||||
}
|
||||
const echarts = (await import("../../resources/echarts/echarts")).default;
|
||||
|
||||
@@ -780,8 +926,14 @@ export class HaChartBase extends LitElement {
|
||||
series: this._getSeries(),
|
||||
});
|
||||
this._updateSankeyRoam();
|
||||
if (this._pendingZoom) {
|
||||
const [start, end, silent] = this._pendingZoom;
|
||||
this._pendingZoom = undefined;
|
||||
this.chart.dispatchAction({ type: "dataZoom", start, end, silent });
|
||||
}
|
||||
} finally {
|
||||
this._loading = false;
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -927,9 +1079,7 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
|
||||
if (options.tooltip) {
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
const isMobile = this._isMobileSize;
|
||||
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
|
||||
// back into the caller's options.tooltip reference (callers may cache the
|
||||
// options object via memoizeOne, in which case in-place mutation would
|
||||
@@ -1166,8 +1316,8 @@ export class HaChartBase extends LitElement {
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) *
|
||||
window.devicePixelRatio,
|
||||
((this._contentWidth ?? this.clientWidth) ||
|
||||
DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
@@ -1180,7 +1330,7 @@ export class HaChartBase extends LitElement {
|
||||
}
|
||||
|
||||
private _getDefaultHeight() {
|
||||
return Math.max(this.clientWidth / 2, 200);
|
||||
return Math.max((this._contentWidth ?? this.clientWidth) / 2, 200);
|
||||
}
|
||||
|
||||
private _setChartOptions(options: ECOption) {
|
||||
@@ -1247,7 +1397,16 @@ export class HaChartBase extends LitElement {
|
||||
};
|
||||
|
||||
public zoom(start: number, end: number, silent = false) {
|
||||
this.chart?.dispatchAction({
|
||||
if (!this.chart || this._pendingSetup) {
|
||||
// Sibling charts sync their zoom imperatively, so a range that arrives
|
||||
// before a deferred setup or rebuild has to be replayed rather than
|
||||
// dropped. A reset to the full range is what a fresh chart is built with,
|
||||
// so it just clears.
|
||||
this._pendingZoom =
|
||||
start === 0 && end === 100 ? undefined : [start, end, silent];
|
||||
return;
|
||||
}
|
||||
this.chart.dispatchAction({
|
||||
type: "dataZoom",
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -12,7 +12,6 @@ import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { NODE_SIZE } from "../trace/hat-graph-const";
|
||||
import "../ha-alert";
|
||||
|
||||
export interface Node {
|
||||
@@ -43,6 +42,7 @@ const OVERFLOW_MARGIN = 5;
|
||||
const FONT_SIZE = 12;
|
||||
const NODE_GAP = 6;
|
||||
const LABEL_DISTANCE = 5;
|
||||
const NODE_SIZE = 30;
|
||||
const LABEL_MIN_MARGIN = 5;
|
||||
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import { snapFrameSize } from "./down-sample";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
|
||||
export interface StateHistoryChartTimelineDataParams {
|
||||
states: HassEntities;
|
||||
data: TimelineEntity[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
names?: Record<string, string>;
|
||||
showNames: boolean;
|
||||
computedStyles: CSSStyleDeclaration;
|
||||
renderItem: CustomSeriesRenderItem;
|
||||
/** Chart width in device pixels; bounds how many rectangles are emitted. */
|
||||
chartWidth: number;
|
||||
}
|
||||
|
||||
export interface TimelineSegment {
|
||||
state: string;
|
||||
locState: string | null;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Resolves each frame of a run to the state covering most of that frame. */
|
||||
function collapseRun(
|
||||
segments: TimelineSegment[],
|
||||
from: number,
|
||||
to: number,
|
||||
frameMs: number,
|
||||
push: (segment: TimelineSegment) => void
|
||||
) {
|
||||
const runEnd = segments[to - 1].end;
|
||||
const frameStates = new Map<
|
||||
string,
|
||||
{ duration: number; locState: string | null }
|
||||
>();
|
||||
let frameStart = segments[from].start;
|
||||
let index = from;
|
||||
|
||||
while (frameStart < runEnd) {
|
||||
const boundary = (Math.floor(frameStart / frameMs) + 1) * frameMs;
|
||||
// a frame size that rounds back onto frameStart would never advance
|
||||
const next = boundary > frameStart ? boundary : frameStart + frameMs;
|
||||
const frameEnd = next < runEnd ? next : runEnd;
|
||||
|
||||
frameStates.clear();
|
||||
let bestState: string | null = null;
|
||||
let bestLocState: string | null = null;
|
||||
let bestDuration = 0;
|
||||
// Segments are narrower than a frame, so each is visited at most twice:
|
||||
// index stops at the one spilling into the next frame.
|
||||
let cursor = index;
|
||||
while (cursor < to && segments[cursor].start < frameEnd) {
|
||||
const segment = segments[cursor];
|
||||
cursor++;
|
||||
const overlapStart =
|
||||
segment.start > frameStart ? segment.start : frameStart;
|
||||
const overlapEnd = segment.end < frameEnd ? segment.end : frameEnd;
|
||||
if (overlapEnd <= overlapStart) {
|
||||
continue;
|
||||
}
|
||||
let entry = frameStates.get(segment.state);
|
||||
if (entry) {
|
||||
entry.duration += overlapEnd - overlapStart;
|
||||
} else {
|
||||
entry = {
|
||||
duration: overlapEnd - overlapStart,
|
||||
locState: segment.locState,
|
||||
};
|
||||
frameStates.set(segment.state, entry);
|
||||
}
|
||||
if (entry.duration > bestDuration) {
|
||||
bestDuration = entry.duration;
|
||||
bestState = segment.state;
|
||||
bestLocState = entry.locState;
|
||||
}
|
||||
}
|
||||
while (index < to && segments[index].end <= frameEnd) {
|
||||
index++;
|
||||
}
|
||||
if (bestState !== null) {
|
||||
push({
|
||||
state: bestState,
|
||||
locState: bestLocState,
|
||||
start: frameStart,
|
||||
end: frameEnd,
|
||||
});
|
||||
}
|
||||
frameStart = frameEnd;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the rectangle count by the chart's pixel width. Segments at least one
|
||||
* frame wide are kept as they are; narrower ones are resolved per frame, and
|
||||
* neighbours resolving to the same state merge into one rectangle.
|
||||
*/
|
||||
export function downSampleTimelineSegments(
|
||||
segments: TimelineSegment[],
|
||||
frameMs: number
|
||||
): TimelineSegment[] {
|
||||
if (!(frameMs > 0)) {
|
||||
return segments;
|
||||
}
|
||||
const result: TimelineSegment[] = [];
|
||||
const push = (segment: TimelineSegment) => {
|
||||
const last = result[result.length - 1];
|
||||
if (last && last.state === segment.state && last.end === segment.start) {
|
||||
last.end = segment.end;
|
||||
return;
|
||||
}
|
||||
result.push(segment);
|
||||
};
|
||||
|
||||
let index = 0;
|
||||
while (index < segments.length) {
|
||||
if (segments[index].end - segments[index].start >= frameMs) {
|
||||
push({ ...segments[index] });
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const from = index;
|
||||
index++;
|
||||
// A gap of its own frame or more stays a gap; a narrower one is invisible
|
||||
// and is absorbed, so that a row of gap-separated slivers stays bounded.
|
||||
while (
|
||||
index < segments.length &&
|
||||
segments[index].end - segments[index].start < frameMs &&
|
||||
segments[index].start - segments[index - 1].end < frameMs
|
||||
) {
|
||||
index++;
|
||||
}
|
||||
collapseRun(segments, from, index, frameMs, push);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms processed history (`TimelineEntity[]`) into ECharts custom series
|
||||
* for `state-history-chart-timeline`. Pure data processing: all environment
|
||||
* inputs (theme style, entity states, chart width, the render callback) are injected so
|
||||
* the transform is deterministic and benchmarkable.
|
||||
*/
|
||||
export function generateStateHistoryChartTimelineData(
|
||||
params: StateHistoryChartTimelineDataParams
|
||||
): CustomSeriesOption[] {
|
||||
const { states, computedStyles, startTime, endTime, renderItem } = params;
|
||||
const stateHistory = params.data ?? [];
|
||||
const startTimeMs = startTime.getTime();
|
||||
const endTimeMs = endTime.getTime();
|
||||
// Snapped, and placed on absolute time, so a chart following "now" keeps
|
||||
// resolving the same frames instead of reshaping on every refresh.
|
||||
const rawFrameMs = Math.ceil(
|
||||
(endTimeMs - startTimeMs) / Math.floor(params.chartWidth)
|
||||
);
|
||||
const frameMs =
|
||||
Number.isFinite(rawFrameMs) && rawFrameMs > 0
|
||||
? snapFrameSize(rawFrameMs)
|
||||
: 0;
|
||||
const datasets: CustomSeriesOption[] = [];
|
||||
const names = params.names || {};
|
||||
// stateHistory is a list of lists of sorted state objects
|
||||
stateHistory.forEach((stateInfo) => {
|
||||
let prevState: string | null = null;
|
||||
let locState: string | null = null;
|
||||
let prevLastChanged = startTimeMs;
|
||||
const entityDisplay: string = params.showNames
|
||||
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
|
||||
: "";
|
||||
|
||||
const segments: TimelineSegment[] = [];
|
||||
stateInfo.data.forEach((entityState) => {
|
||||
let newState: string | null = entityState.state;
|
||||
const timeStamp = entityState.last_changed;
|
||||
if (!newState) {
|
||||
newState = null;
|
||||
}
|
||||
if (timeStamp > endTimeMs) {
|
||||
// Drop datapoints that are after the requested endTime. This could happen if
|
||||
// endTime is 'now' and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
if (prevState === null) {
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = timeStamp;
|
||||
} else if (newState !== prevState) {
|
||||
segments.push({
|
||||
state: prevState,
|
||||
locState,
|
||||
start: prevLastChanged,
|
||||
end: timeStamp,
|
||||
});
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = timeStamp;
|
||||
}
|
||||
});
|
||||
|
||||
if (prevState !== null) {
|
||||
segments.push({
|
||||
state: prevState,
|
||||
locState,
|
||||
start: prevLastChanged,
|
||||
end: endTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
const stateObj = states[stateInfo.entity_id];
|
||||
const dataRow = downSampleTimelineSegments(segments, frameMs).map(
|
||||
(segment) => {
|
||||
const color = computeTimelineColor(
|
||||
segment.state,
|
||||
computedStyles,
|
||||
stateObj
|
||||
);
|
||||
return {
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
new Date(segment.start),
|
||||
new Date(segment.end),
|
||||
segment.locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
datasets.push({
|
||||
id: stateInfo.entity_id,
|
||||
data: dataRow,
|
||||
name: entityDisplay,
|
||||
dimensions: ["id", "start", "end", "name", "color", "textColor"],
|
||||
type: "custom",
|
||||
encode: {
|
||||
x: [1, 2],
|
||||
y: 0,
|
||||
itemName: 3,
|
||||
},
|
||||
renderItem,
|
||||
progressive: 0,
|
||||
});
|
||||
});
|
||||
|
||||
return datasets;
|
||||
}
|
||||
@@ -11,16 +11,14 @@ import millisecondsToDuration from "../../common/datetime/milliseconds_to_durati
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { generateStateHistoryChartTimelineData } from "./state-history-chart-timeline-data";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
// Taller rows when the name is drawn under the bar instead of in a column.
|
||||
@@ -44,7 +42,8 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
// Render each row's name inside the plot (under its bar) instead of in a
|
||||
// left-hand category-label column. Opt-in; used by the history panel.
|
||||
// left-hand category-label column. Opt-in; used by the history panel and
|
||||
// history-graph card.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@@ -79,7 +78,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${
|
||||
this.data.length *
|
||||
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
|
||||
(this.insideLabels && (this.chunked || this.showNames)
|
||||
? ROW_HEIGHT_INSIDE_LABELS
|
||||
: ROW_HEIGHT) +
|
||||
GRID_BOTTOM
|
||||
}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
@@ -312,109 +313,20 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
private _generateData() {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
let stateHistory = this.data;
|
||||
|
||||
if (!stateHistory) {
|
||||
stateHistory = [];
|
||||
}
|
||||
|
||||
this._chartTime = new Date();
|
||||
const startTime = this.startTime;
|
||||
const endTime = this.endTime;
|
||||
const datasets: CustomSeriesOption[] = [];
|
||||
const names = this.names || {};
|
||||
// stateHistory is a list of lists of sorted state objects
|
||||
stateHistory.forEach((stateInfo) => {
|
||||
let newLastChanged: Date;
|
||||
let prevState: string | null = null;
|
||||
let locState: string | null = null;
|
||||
let prevLastChanged = startTime;
|
||||
const entityDisplay: string = this.showNames
|
||||
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
|
||||
: "";
|
||||
|
||||
const dataRow: unknown[] = [];
|
||||
stateInfo.data.forEach((entityState) => {
|
||||
let newState: string | null = entityState.state;
|
||||
const timeStamp = new Date(entityState.last_changed);
|
||||
if (!newState) {
|
||||
newState = null;
|
||||
}
|
||||
if (timeStamp > endTime) {
|
||||
// Drop datapoints that are after the requested endTime. This could happen if
|
||||
// endTime is 'now' and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
if (prevState === null) {
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = new Date(entityState.last_changed);
|
||||
} else if (newState !== prevState) {
|
||||
newLastChanged = new Date(entityState.last_changed);
|
||||
|
||||
const color = computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
);
|
||||
dataRow.push({
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
prevLastChanged,
|
||||
newLastChanged,
|
||||
locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
});
|
||||
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = newLastChanged;
|
||||
}
|
||||
});
|
||||
|
||||
if (prevState !== null) {
|
||||
const color = computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
);
|
||||
dataRow.push({
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
prevLastChanged,
|
||||
endTime,
|
||||
locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
});
|
||||
}
|
||||
datasets.push({
|
||||
id: stateInfo.entity_id,
|
||||
data: dataRow,
|
||||
name: entityDisplay,
|
||||
dimensions: ["id", "start", "end", "name", "color", "textColor"],
|
||||
type: "custom",
|
||||
encode: {
|
||||
x: [1, 2],
|
||||
y: 0,
|
||||
itemName: 3,
|
||||
},
|
||||
renderItem: this._renderItem,
|
||||
progressive: 0,
|
||||
});
|
||||
this._chartData = generateStateHistoryChartTimelineData({
|
||||
states: this.hass.states,
|
||||
data: this.data,
|
||||
startTime: this.startTime,
|
||||
endTime: this.endTime,
|
||||
names: this.names,
|
||||
showNames: this.showNames,
|
||||
computedStyles: getComputedStyle(this),
|
||||
renderItem: this._renderItem,
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
chartWidth:
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
|
||||
});
|
||||
|
||||
this._chartData = datasets;
|
||||
}
|
||||
|
||||
private _handleChartClick(
|
||||
@@ -431,6 +343,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
|
||||
|
||||
// Render timeline row names inside the plot (under each bar) instead of in a
|
||||
// left-hand column. Opt-in; used by the history panel.
|
||||
// left-hand column. Opt-in; used by the history panel and history-graph card.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@@ -375,7 +375,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
const chartBase =
|
||||
chartComponent.renderRoot?.querySelector("ha-chart-base");
|
||||
|
||||
if (chartBase && chartBase.chart) {
|
||||
if (chartBase) {
|
||||
chartBase.zoom(0, 100);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -53,6 +53,41 @@ export interface StatisticsChartData {
|
||||
yAxisFractionDigits: number;
|
||||
}
|
||||
|
||||
// ECharts stacks a time axis by data index. Merge the sorted point lists so
|
||||
// every stacked line has the same x at every index, padding with null.
|
||||
function alignStackedLines(datasets: LineSeriesOption[]) {
|
||||
const sources = datasets.map((d) => d.data as [number, number | null][]);
|
||||
const cursors = sources.map(() => 0);
|
||||
const counts = sources.map(() => 0);
|
||||
const aligned = sources.map((): [number, number | null][] => []);
|
||||
while (cursors.some((cursor, i) => cursor < sources[i].length)) {
|
||||
let x = Infinity;
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const point = sources[i][cursors[i]];
|
||||
if (point && point[0] < x) x = point[0];
|
||||
}
|
||||
let slots = 1;
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
let count = 0;
|
||||
while (sources[i][cursors[i] + count]?.[0] === x) count++;
|
||||
counts[i] = count;
|
||||
if (count > slots) slots = count;
|
||||
}
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const count = counts[i];
|
||||
for (let slot = 0; slot < slots; slot++) {
|
||||
aligned[i].push(
|
||||
count ? sources[i][cursors[i] + Math.min(slot, count - 1)] : [x, null]
|
||||
);
|
||||
}
|
||||
cursors[i] += count;
|
||||
}
|
||||
}
|
||||
datasets.forEach((d, i) => {
|
||||
d.data = aligned[i];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms raw statistics into ECharts series for `statistics-chart`.
|
||||
* Pure data processing: all environment inputs (current time, theme style,
|
||||
@@ -263,8 +298,10 @@ export function generateStatisticsChartData(
|
||||
),
|
||||
symbol: "none",
|
||||
// minmax sampling operates independently per series, breaking stacking alignment
|
||||
// echarts stacks before it samples, so its lttb keeps stacks aligned
|
||||
// https://github.com/apache/echarts/issues/11879
|
||||
sampling: band && drawBands ? "lttb" : "minmax",
|
||||
sampling:
|
||||
band || (chartStacked && chartType === "line") ? "lttb" : "minmax",
|
||||
animationDurationUpdate: 0,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
@@ -439,6 +476,15 @@ export function generateStatisticsChartData(
|
||||
Array.prototype.push.apply(legendData, statLegendData);
|
||||
});
|
||||
|
||||
if (chartType === "line" && chartStacked) {
|
||||
const stacked = (totalDataSets as LineSeriesOption[]).filter(
|
||||
(d) => d.data?.length
|
||||
);
|
||||
if (stacked.length > 1) {
|
||||
alignStackedLines(stacked);
|
||||
}
|
||||
}
|
||||
|
||||
if (chartType === "bar") {
|
||||
fillDataGapsAndRoundCaps(totalDataSets as BarSeriesOption[], chartStacked);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,23 @@ export class StatisticsChart extends LitElement {
|
||||
private _yAxisFractionDigits = 1;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
return changedProps.size > 1 || !changedProps.has("hass");
|
||||
return (
|
||||
changedProps.size > 1 ||
|
||||
!changedProps.has("hass") ||
|
||||
this._entityNamesChanged(changedProps)
|
||||
);
|
||||
}
|
||||
|
||||
// Series names are resolved once and cached in _chartData, so a hass update
|
||||
// that only replaces the formatters has to regenerate them. The formatters are
|
||||
// swapped as a set whenever the registries change, which is what renames a
|
||||
// series.
|
||||
private _entityNamesChanged(changedProps: PropertyValues): boolean {
|
||||
if (!changedProps.has("hass")) {
|
||||
return false;
|
||||
}
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
return !!oldHass && oldHass.formatEntityName !== this.hass.formatEntityName;
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
@@ -135,7 +151,8 @@ export class StatisticsChart extends LitElement {
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats") ||
|
||||
changedProps.has("names")
|
||||
changedProps.has("names") ||
|
||||
this._entityNamesChanged(changedProps)
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
@@ -253,6 +270,8 @@ export class StatisticsChart extends LitElement {
|
||||
}[] = [];
|
||||
for (const param of params) {
|
||||
if (rendered[param.seriesIndex]) continue;
|
||||
// stacked lines are padded with null where a statistic has no data
|
||||
if (!chartIsBar && param.value[1] === null) continue;
|
||||
rendered[param.seriesIndex] = true;
|
||||
|
||||
const statisticId = this._statisticIds[param.seriesIndex];
|
||||
@@ -530,17 +549,26 @@ export class StatisticsChart extends LitElement {
|
||||
this.unit = data.unit;
|
||||
this._yAxisFractionDigits = data.yAxisFractionDigits;
|
||||
this._chartData = data.datasets;
|
||||
if (data.legendData.length !== this._legendData?.length) {
|
||||
const legendData =
|
||||
data.legendData.length > 1
|
||||
? data.legendData.map(({ id, name, noLabelClick }) => ({
|
||||
id,
|
||||
name,
|
||||
noLabelClick,
|
||||
}))
|
||||
: // if there is only one entity, let the base chart handle the legend
|
||||
undefined;
|
||||
if (
|
||||
legendData?.length !== this._legendData?.length ||
|
||||
legendData?.some(
|
||||
(item, index) =>
|
||||
item.id !== this._legendData?.[index]?.id ||
|
||||
item.name !== this._legendData?.[index]?.name ||
|
||||
item.noLabelClick !== this._legendData?.[index]?.noLabelClick
|
||||
)
|
||||
) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
this._legendData =
|
||||
data.legendData.length > 1
|
||||
? data.legendData.map(({ id, name, noLabelClick }) => ({
|
||||
id,
|
||||
name,
|
||||
noLabelClick,
|
||||
}))
|
||||
: // if there is only one entity, let the base chart handle the legend
|
||||
undefined;
|
||||
this._legendData = legendData;
|
||||
}
|
||||
this._statisticIds = data.statisticIds;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,14 @@ export class HaFilterChip extends FilterChip {
|
||||
`,
|
||||
];
|
||||
|
||||
protected getContainerClasses() {
|
||||
const classes = super.getContainerClasses();
|
||||
if (this.noLeadingIcon) {
|
||||
classes["has-icon"] = false;
|
||||
}
|
||||
return classes;
|
||||
}
|
||||
|
||||
protected renderLeadingIcon() {
|
||||
if (this.noLeadingIcon) {
|
||||
// eslint-disable-next-line lit/prefer-nothing
|
||||
|
||||
@@ -1202,6 +1202,20 @@ export class HaDataTable extends LitElement {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table {
|
||||
width: calc(
|
||||
100% + var(--safe-area-inset-left, 0px) +
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
|
||||
margin-right: calc(-1 * var(--safe-area-inset-right, 0px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__header-row {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
.mdc-data-table__header-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
@@ -1499,6 +1513,16 @@ export class HaDataTable extends LitElement {
|
||||
overflow: overlay !important;
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__row {
|
||||
box-sizing: border-box;
|
||||
padding-left: var(--safe-area-inset-left, 0px);
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
:host([narrow]) .mdc-data-table__row:has(.group-header) {
|
||||
background-color: var(--primary-background-color);
|
||||
}
|
||||
|
||||
.mdc-data-table__table.auto-height .scroller {
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
@@ -136,9 +136,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
${
|
||||
this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
<ha-input-helper-text> ${this.helper} </ha-input-helper-text>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
|
||||
@@ -329,11 +329,7 @@ export class HaStateContentPicker extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
|
||||
: nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,10 @@ import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { type HASSDomEvent, fireEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import {
|
||||
computeEntityPickerDisplay,
|
||||
computeEntitySearchLabels,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
@@ -233,11 +234,6 @@ export class HaStatisticPicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const output: StatisticComboBoxItem[] = [];
|
||||
|
||||
statisticIds.forEach((meta) => {
|
||||
@@ -291,31 +287,17 @@ export class HaStatisticPicker extends LitElement {
|
||||
}
|
||||
const id = meta.statistic_id;
|
||||
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
hass,
|
||||
stateObj
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(
|
||||
stateObj,
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||
output.push({
|
||||
@@ -325,14 +307,12 @@ export class HaStatisticPicker extends LitElement {
|
||||
secondary,
|
||||
stateObj: stateObj,
|
||||
type: "entity",
|
||||
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
sorting_label: [
|
||||
sortingPrefix,
|
||||
searchLabels.deviceName,
|
||||
searchLabels.entityName,
|
||||
].join("_"),
|
||||
search_labels: searchLabels,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -407,35 +387,17 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
this.hass,
|
||||
stateObj
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(
|
||||
stateObj,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || statisticId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||
return {
|
||||
@@ -445,15 +407,12 @@ export class HaStatisticPicker extends LitElement {
|
||||
secondary,
|
||||
stateObj: stateObj,
|
||||
type: "entity",
|
||||
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
},
|
||||
sorting_label: [
|
||||
sortingPrefix,
|
||||
searchLabels.deviceName,
|
||||
searchLabels.entityName,
|
||||
].join("_"),
|
||||
search_labels: { ...searchLabels, statisticId },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
|
||||
@@ -20,13 +21,15 @@ declare global {
|
||||
|
||||
@customElement("ha-analytics")
|
||||
export class HaAnalytics extends LitElement {
|
||||
@property({ attribute: false }) public localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public analytics?: Analytics;
|
||||
|
||||
@property({ attribute: "translation_key_panel" }) public translationKeyPanel:
|
||||
"page-onboarding" | "config" = "config";
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
const loading = this.analytics === undefined;
|
||||
const baseEnabled = !loading && this.analytics!.preferences.base;
|
||||
@@ -34,12 +37,12 @@ export class HaAnalytics extends LitElement {
|
||||
return html`
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
>${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.localize(
|
||||
>${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.description`
|
||||
)}</span
|
||||
>
|
||||
@@ -51,7 +54,7 @@ export class HaAnalytics extends LitElement {
|
||||
.disabled=${loading}
|
||||
name="base"
|
||||
>
|
||||
${this.localize(
|
||||
${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
@@ -60,12 +63,12 @@ export class HaAnalytics extends LitElement {
|
||||
(preference) => html`
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
>${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.localize(
|
||||
>${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.description`
|
||||
)}</span
|
||||
>
|
||||
@@ -77,7 +80,7 @@ export class HaAnalytics extends LitElement {
|
||||
.preference=${preference}
|
||||
name=${preference}
|
||||
>
|
||||
${this.localize(
|
||||
${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
@@ -88,7 +91,7 @@ export class HaAnalytics extends LitElement {
|
||||
.for="switch-${preference}"
|
||||
placement="right"
|
||||
>
|
||||
${this.localize(
|
||||
${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
@@ -98,12 +101,12 @@ export class HaAnalytics extends LitElement {
|
||||
)}
|
||||
<ha-row-item>
|
||||
<span slot="headline"
|
||||
>${this.localize(
|
||||
>${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
|
||||
)}</span
|
||||
>
|
||||
<span slot="supporting-text"
|
||||
>${this.localize(
|
||||
>${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.description`
|
||||
)}</span
|
||||
>
|
||||
@@ -115,7 +118,7 @@ export class HaAnalytics extends LitElement {
|
||||
.disabled=${loading}
|
||||
name="diagnostics"
|
||||
>
|
||||
${this.localize(
|
||||
${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
|
||||
@@ -3,8 +3,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import Fuse from "fuse.js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import { computeEntityPickerDisplay } from "../common/entity/compute_entity_name_display";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
multiTermSortedSearch,
|
||||
@@ -183,11 +182,6 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
const allEntityIds = Object.values(controlEntities).flat();
|
||||
const uniqueEntityIds = Array.from(new Set(allEntityIds));
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
uniqueEntityIds.forEach((entityId) => {
|
||||
if (isSelected(entityId)) {
|
||||
return;
|
||||
@@ -197,29 +191,10 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
this.hass!,
|
||||
stateObj
|
||||
);
|
||||
|
||||
entityItems.push({
|
||||
type: "entity",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type ConversationChatLogToolResultDelta,
|
||||
type PipelineRunEvent,
|
||||
} from "../data/assist_pipeline";
|
||||
import type { ChatLogToolResult } from "../data/chat_log";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
@@ -58,7 +59,7 @@ interface AssistMessage {
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: any;
|
||||
result?: ChatLogToolResult;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
@@ -834,7 +835,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
} else if (isToolResult(delta)) {
|
||||
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
|
||||
progress.hassMessage.tool_calls[delta.tool_call_id].result =
|
||||
delta.tool_result;
|
||||
delta.result;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,9 +312,7 @@ export class HaBaseTimeInput extends LitElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -42,8 +42,8 @@ export class HaCard extends LitElement {
|
||||
font-family: var(--ha-card-header-font-family, inherit);
|
||||
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
|
||||
letter-spacing: -0.012em;
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
padding: var(--ha-space-5) var(--ha-space-4) var(--ha-space-6);
|
||||
display: block;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
|
||||
@@ -198,11 +198,7 @@ export class HaClockDateFormatPicker extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
|
||||
: nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,69 +103,87 @@ export class HaDurationInput extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
// Fold units we do not render into the smallest unit we do, so a value the
|
||||
// user cannot see is not silently dropped on the next edit.
|
||||
private get _data(): HaDurationData | undefined {
|
||||
if (!this.data) {
|
||||
return this.data;
|
||||
}
|
||||
const data = { ...this.data };
|
||||
if (!this.enableDay && data.days) {
|
||||
data.hours = (data.hours || 0) + data.days * 24;
|
||||
delete data.days;
|
||||
}
|
||||
if (!this.enableMillisecond && data.milliseconds) {
|
||||
data.seconds = (data.seconds || 0) + data.milliseconds / 1000;
|
||||
delete data.milliseconds;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private get _negative() {
|
||||
return (
|
||||
this._toggleNegative ||
|
||||
(this.data?.days
|
||||
? this.data.days < 0
|
||||
: this.data?.hours
|
||||
? this.data.hours < 0
|
||||
: this.data?.minutes
|
||||
? this.data.minutes < 0
|
||||
: this.data?.seconds
|
||||
? this.data.seconds < 0
|
||||
: this.data?.milliseconds
|
||||
? this.data.milliseconds < 0
|
||||
(this._data?.days
|
||||
? this._data.days < 0
|
||||
: this._data?.hours
|
||||
? this._data.hours < 0
|
||||
: this._data?.minutes
|
||||
? this._data.minutes < 0
|
||||
: this._data?.seconds
|
||||
? this._data.seconds < 0
|
||||
: this._data?.milliseconds
|
||||
? this._data.milliseconds < 0
|
||||
: false)
|
||||
);
|
||||
}
|
||||
|
||||
private get _days() {
|
||||
return this.data?.days
|
||||
return this._data?.days
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.days))
|
||||
: Number(this.data.days)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.days))
|
||||
: Number(this._data.days)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _hours() {
|
||||
return this.data?.hours
|
||||
return this._data?.hours
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.hours))
|
||||
: Number(this.data.hours)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.hours))
|
||||
: Number(this._data.hours)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _minutes() {
|
||||
return this.data?.minutes
|
||||
return this._data?.minutes
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.minutes))
|
||||
: Number(this.data.minutes)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.minutes))
|
||||
: Number(this._data.minutes)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _seconds() {
|
||||
return this.data?.seconds
|
||||
return this._data?.seconds
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.seconds))
|
||||
: Number(this.data.seconds)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.seconds))
|
||||
: Number(this._data.seconds)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
|
||||
private get _milliseconds() {
|
||||
return this.data?.milliseconds
|
||||
return this._data?.milliseconds
|
||||
? this.allowNegative
|
||||
? Math.abs(Number(this.data.milliseconds))
|
||||
: Number(this.data.milliseconds)
|
||||
: this.required || this.data
|
||||
? Math.abs(Number(this._data.milliseconds))
|
||||
: Number(this._data.milliseconds)
|
||||
: this.required || this._data
|
||||
? 0
|
||||
: NaN;
|
||||
}
|
||||
@@ -236,8 +254,8 @@ export class HaDurationInput extends LitElement {
|
||||
ev.stopPropagation();
|
||||
const negative = (ev.detail?.value || ev.target.value) === "-";
|
||||
this._toggleNegative = negative;
|
||||
if (this.data) {
|
||||
const value = { ...this.data };
|
||||
if (this._data) {
|
||||
const value = { ...this._data };
|
||||
FIELDS.forEach((t) => {
|
||||
if (value[t]) {
|
||||
value[t] = negative ? -Math.abs(value[t]) : Math.abs(value[t]);
|
||||
@@ -254,6 +272,11 @@ export class HaDurationInput extends LitElement {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
/* Full width: bigger touch targets, no ragged right edge in a form. */
|
||||
ha-base-time-input {
|
||||
flex: 1;
|
||||
--time-input-flex: 1;
|
||||
}
|
||||
ha-button-toggle-group {
|
||||
margin: var(--ha-space-2);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,9 @@ declare global {
|
||||
|
||||
@customElement("ha-file-upload")
|
||||
export class HaFileUpload extends LitElement {
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
@state() @consumeLocalize() private _localize?: LocalizeFunc;
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@@ -92,7 +92,6 @@ export class HaFileUpload extends LitElement {
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const localize = this.localize || this._localize!;
|
||||
return html`
|
||||
${
|
||||
this.uploading
|
||||
@@ -102,10 +101,13 @@ export class HaFileUpload extends LitElement {
|
||||
>${
|
||||
this.uploadingLabel ||
|
||||
(this.value
|
||||
? localize("ui.components.file-upload.uploading_name", {
|
||||
name: this._name,
|
||||
})
|
||||
: localize("ui.components.file-upload.uploading"))
|
||||
? this._localize(
|
||||
"ui.components.file-upload.uploading_name",
|
||||
{
|
||||
name: this._name,
|
||||
}
|
||||
)
|
||||
: this._localize("ui.components.file-upload.uploading"))
|
||||
}</span
|
||||
>
|
||||
${
|
||||
@@ -147,12 +149,12 @@ export class HaFileUpload extends LitElement {
|
||||
slot="start"
|
||||
.path=${this.icon || mdiFileUpload}
|
||||
></ha-svg-icon>
|
||||
${this.label || localize("ui.components.file-upload.label")}
|
||||
${this.label || this._localize("ui.components.file-upload.label")}
|
||||
</ha-button>
|
||||
<span class="secondary"
|
||||
>${
|
||||
this.secondary ||
|
||||
localize("ui.components.file-upload.secondary")
|
||||
this._localize("ui.components.file-upload.secondary")
|
||||
}</span
|
||||
>
|
||||
<span class="supports">${this.supports}</span>`
|
||||
@@ -166,7 +168,7 @@ export class HaFileUpload extends LitElement {
|
||||
</div>
|
||||
<ha-icon-button
|
||||
@click=${this._clearValue}
|
||||
.label=${this.deleteLabel || localize("ui.common.delete")}
|
||||
.label=${this.deleteLabel || this._localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
@@ -185,7 +187,8 @@ export class HaFileUpload extends LitElement {
|
||||
<ha-icon-button
|
||||
@click=${this._clearValue}
|
||||
.label=${
|
||||
this.deleteLabel || localize("ui.common.delete")
|
||||
this.deleteLabel ||
|
||||
this._localize("ui.common.delete")
|
||||
}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
|
||||
@@ -60,6 +60,9 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
@property() public type?: keyof RelatedResult;
|
||||
|
||||
@property({ type: Boolean, attribute: "include-disabled-entities" })
|
||||
public includeDisabledEntities = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
@@ -227,7 +230,14 @@ export class HaFilterDevices extends LitElement {
|
||||
for (const deviceId of this.value) {
|
||||
value.push(deviceId);
|
||||
if (this.type) {
|
||||
relatedPromises.push(findRelated(this._api, "device", deviceId));
|
||||
relatedPromises.push(
|
||||
findRelated(
|
||||
this._api,
|
||||
"device",
|
||||
deviceId,
|
||||
this.includeDisabledEntities
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(relatedPromises);
|
||||
|
||||
@@ -65,6 +65,9 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
@property() public type?: keyof RelatedResult;
|
||||
|
||||
@property({ type: Boolean, attribute: "include-disabled-entities" })
|
||||
public includeDisabledEntities = false;
|
||||
|
||||
@property({ type: Boolean }) public narrow = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public expanded = false;
|
||||
@@ -295,7 +298,9 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
if (this.value.areas) {
|
||||
for (const areaId of this.value.areas) {
|
||||
if (this.type) {
|
||||
relatedPromises.push(findRelated(this._api, "area", areaId));
|
||||
relatedPromises.push(
|
||||
findRelated(this._api, "area", areaId, this.includeDisabledEntities)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -303,7 +308,14 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
if (this.value.floors) {
|
||||
for (const floorId of this.value.floors) {
|
||||
if (this.type) {
|
||||
relatedPromises.push(findRelated(this._api, "floor", floorId));
|
||||
relatedPromises.push(
|
||||
findRelated(
|
||||
this._api,
|
||||
"floor",
|
||||
floorId,
|
||||
this.includeDisabledEntities
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import "./ha-list";
|
||||
import "./ha-list-item";
|
||||
import "./voice-assistant-brand-icon";
|
||||
import { voiceAssistants } from "../data/expose";
|
||||
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
|
||||
|
||||
@customElement("ha-filter-voice-assistants")
|
||||
export class HaFilterVoiceAssistants extends LitElement {
|
||||
|
||||
@@ -91,7 +91,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
? html`<ha-input-helper-text
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
|
||||
@@ -3,7 +3,6 @@ import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomTargetEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-check-list-item";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-dropdown";
|
||||
|
||||
@@ -57,6 +57,7 @@ export class HaFormString extends LitElement implements HaFormElement {
|
||||
.name=${this.schema.name}
|
||||
.autofocus=${!!this.schema.autofocus}
|
||||
.autocomplete=${this.schema.autocomplete}
|
||||
.inputId=${this.schema.name}
|
||||
.validationMessage=${
|
||||
this.schema.required
|
||||
? this.localize?.("ui.common.error_required")
|
||||
|
||||
@@ -329,7 +329,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||
return html`<ha-input-helper-text>
|
||||
${
|
||||
showError
|
||||
? html`<span class="error">${this.errorMessage}</span> ${
|
||||
@@ -360,7 +360,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
|
||||
this._initialFieldValue = undefined;
|
||||
}
|
||||
if (
|
||||
this._hassConfig?.auth.external &&
|
||||
this._hassConfig?.auth?.external &&
|
||||
isIosApp(this._hassConfig.auth.external)
|
||||
) {
|
||||
this._hassConfig.auth.external.fireMessage({
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { HaSvgIcon } from "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-icon-arrow-next")
|
||||
export class HaIconArrowNext extends HaSvgIcon {
|
||||
@property() public override path =
|
||||
mainWindow.document.dir === "rtl" ? mdiArrowLeft : mdiArrowRight;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-arrow-next": HaIconArrowNext;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { mdiArrowLeft, mdiArrowRight } from "@mdi/js";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import { HaSvgIcon } from "./ha-svg-icon";
|
||||
|
||||
@customElement("ha-icon-arrow-prev")
|
||||
export class HaIconArrowPrev extends HaSvgIcon {
|
||||
@property() public override path =
|
||||
mainWindow.document.dir === "rtl" ? mdiArrowRight : mdiArrowLeft;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-arrow-prev": HaIconArrowPrev;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import "./ha-icon-button";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
|
||||
@customElement("ha-icon-button-first")
|
||||
export class HaIconButtonFirst extends LitElement {
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
@state() private _icon =
|
||||
mainWindow.document.dir === "rtl"
|
||||
? mdiChevronDoubleRight
|
||||
: mdiChevronDoubleLeft;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || this._localize("ui.common.first") || "First"}
|
||||
.path=${this._icon}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-button-first": HaIconButtonFirst;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mdiChevronDoubleLeft, mdiChevronDoubleRight } from "@mdi/js";
|
||||
import type { TemplateResult } from "lit";
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mainWindow } from "../common/dom/get_main_window";
|
||||
import "./ha-icon-button";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
|
||||
@customElement("ha-icon-button-last")
|
||||
export class HaIconButtonLast extends LitElement {
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() href?: string;
|
||||
|
||||
@property() target?: "_blank" | "_parent" | "_self" | "_top";
|
||||
|
||||
@property() rel?: string;
|
||||
|
||||
@property() download?: string;
|
||||
|
||||
@state() private _icon =
|
||||
mainWindow.document.dir === "rtl"
|
||||
? mdiChevronDoubleLeft
|
||||
: mdiChevronDoubleRight;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || this._localize("ui.common.last") || "Last"}
|
||||
.path=${this._icon}
|
||||
.href=${this.href}
|
||||
.target=${this.target}
|
||||
.rel=${this.rel}
|
||||
.download=${this.download}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-icon-button-last": HaIconButtonLast;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-input-helper-text")
|
||||
class InputHelperText extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) disabled = false;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
@@ -25,9 +23,6 @@ class InputHelperText extends LitElement {
|
||||
);
|
||||
line-height: normal;
|
||||
}
|
||||
:host([disabled]) {
|
||||
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,9 +50,7 @@ class HaLabeledSlider extends LitElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text> ${this.helper} </ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -28,6 +28,11 @@ const LAWN_MOWER_ACTIONS: Partial<
|
||||
service: "start_mowing",
|
||||
feature: LawnMowerEntityFeature.START_MOWING,
|
||||
},
|
||||
idle: {
|
||||
action: "start_mowing",
|
||||
service: "start_mowing",
|
||||
feature: LawnMowerEntityFeature.START_MOWING,
|
||||
},
|
||||
returning: {
|
||||
action: "pause",
|
||||
service: "pause",
|
||||
|
||||
@@ -239,9 +239,7 @@ export class HaMediaItemPicker extends LitElement {
|
||||
}
|
||||
${
|
||||
hideEntityPicker && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
narrowViewportContext,
|
||||
uiContext,
|
||||
@@ -32,6 +33,10 @@ class HaMenuButton extends LitElement {
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui?: ContextType<typeof uiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _hasNotifications = false;
|
||||
|
||||
@state() private _show = false;
|
||||
@@ -82,7 +87,8 @@ class HaMenuButton extends LitElement {
|
||||
if (
|
||||
!changedProps.has("_narrow") &&
|
||||
!changedProps.has("_ui") &&
|
||||
!changedProps.has("_connection")
|
||||
!changedProps.has("_connection") &&
|
||||
!changedProps.has("_config")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -94,6 +100,7 @@ class HaMenuButton extends LitElement {
|
||||
|
||||
const showButton =
|
||||
this._ui?.kioskMode === false &&
|
||||
this._config?.auth.external?.config.hasSidebar !== true &&
|
||||
(this._narrow || this._ui.dockedSidebar === "always_hidden");
|
||||
|
||||
this._show = showButton || this._alwaysVisible;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { OutlinedButton } from "@material/web/button/internal/outlined-button";
|
||||
import { styles as sharedStyles } from "@material/web/button/internal/shared-styles.cssresult.js";
|
||||
import { styles } from "@material/web/button/internal/outlined-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-outlined-button")
|
||||
export class HaOutlinedButton extends OutlinedButton {
|
||||
static override styles = [
|
||||
sharedStyles,
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
--ha-icon-display: block;
|
||||
--md-sys-color-primary: var(--primary-text-color);
|
||||
--md-sys-color-outline: var(--outline-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-outlined-button": HaOutlinedButton;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { OutlinedField } from "@material/web/field/internal/outlined-field";
|
||||
import { styles } from "@material/web/field/internal/outlined-styles.cssresult.js";
|
||||
import { styles as sharedStyles } from "@material/web/field/internal/shared-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { literal } from "lit/static-html";
|
||||
|
||||
@customElement("ha-outlined-field")
|
||||
export class HaOutlinedField extends OutlinedField {
|
||||
protected readonly fieldTag = literal`ha-outlined-field`;
|
||||
|
||||
static override styles = [
|
||||
sharedStyles,
|
||||
styles,
|
||||
css`
|
||||
.container::before {
|
||||
display: block;
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--ha-outlined-field-container-color, transparent);
|
||||
opacity: var(--ha-outlined-field-container-opacity, 1);
|
||||
border-start-start-radius: var(--_container-shape-start-start);
|
||||
border-start-end-radius: var(--_container-shape-start-end);
|
||||
border-end-start-radius: var(--_container-shape-end-start);
|
||||
border-end-end-radius: var(--_container-shape-end-end);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-outlined-field": HaOutlinedField;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
|
||||
@@ -11,39 +11,80 @@ import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
* the sheet by dragging the handle at the top. It supports both mouse and touch
|
||||
* interactions and automatically closes when dragged below a 20% of screen height.
|
||||
*
|
||||
* A persistent sheet never closes by dragging; it stops at its minimum height
|
||||
* instead, so a host can keep a collapsed strip of content reachable.
|
||||
*
|
||||
* @fires bottom-sheet-closed - Fired when the bottom sheet is closed
|
||||
* @fires bottom-sheet-resized - Fired with the sheet's height in pixels once
|
||||
* it has opened and whenever a drag ends
|
||||
*
|
||||
* @cssprop --ha-bottom-sheet-border-width - Border width for the sheet
|
||||
* @cssprop --ha-bottom-sheet-border-style - Border style for the sheet
|
||||
* @cssprop --ha-bottom-sheet-border-color - Border color for the sheet
|
||||
* @cssprop --ha-bottom-sheet-handle-padding - How far below the handle the
|
||||
* grab area reaches; shrink it when content sits right under the handle
|
||||
*/
|
||||
@customElement("ha-resizable-bottom-sheet")
|
||||
export class HaResizableBottomSheet extends LitElement {
|
||||
@query("dialog") private _dialog!: HTMLDialogElement;
|
||||
|
||||
/** Dragging down stops at the minimum height instead of closing the sheet */
|
||||
@property({ type: Boolean }) public persistent = false;
|
||||
|
||||
/**
|
||||
* The height in pixels the sheet cannot be dragged below, e.g. what a host
|
||||
* measures for the strip it wants to keep visible. Without it the sheet
|
||||
* stops at 20% of the viewport.
|
||||
*/
|
||||
@property({ type: Number, attribute: "min-height" })
|
||||
public minHeight?: number;
|
||||
|
||||
/**
|
||||
* The largest share of the viewport the sheet opens at, in percent. It
|
||||
* opens at its content height up to this, and can be dragged to 90 after.
|
||||
*/
|
||||
@property({ type: Number, attribute: "open-max-viewport-height" })
|
||||
public openMaxViewportHeight = 70;
|
||||
|
||||
/**
|
||||
* Whether the sheet may open smaller than 55% of the viewport, down to its
|
||||
* content height and the minimum it can be dragged to.
|
||||
*/
|
||||
@property({ type: Boolean, attribute: "open-at-content-height" })
|
||||
public openAtContentHeight = false;
|
||||
|
||||
private _dragging = false;
|
||||
|
||||
private _dragStartY = 0;
|
||||
|
||||
private _initialSize = 0;
|
||||
|
||||
@state() private _dialogMaxViewpointHeight = 70;
|
||||
private _opened = false;
|
||||
|
||||
@state() private _dialogMinViewpointHeight = 55;
|
||||
@state() private _dialogMaxViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogMinViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogViewportHeight?: number;
|
||||
|
||||
render() {
|
||||
// Until it has opened, the sheet sizes to its content within the opening
|
||||
// bounds; afterwards it keeps the height it settled on or was dragged to
|
||||
const maxHeight =
|
||||
this._dialogMaxViewpointHeight ?? this.openMaxViewportHeight;
|
||||
const minHeight =
|
||||
this._dialogMinViewpointHeight ??
|
||||
(this.openAtContentHeight ? this._minViewportHeight() : 55);
|
||||
return html`<dialog
|
||||
open
|
||||
@transitionend=${this._handleTransitionEnd}
|
||||
style=${`
|
||||
--height: ${this._dialogViewportHeight}vh;
|
||||
--height: ${this._dialogViewportHeight}dvh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}vh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}dvh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}vh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}dvh;
|
||||
--max-height: ${maxHeight}vh;
|
||||
--max-height: ${maxHeight}dvh;
|
||||
--min-height: ${minHeight}vh;
|
||||
--min-height: ${minHeight}dvh;
|
||||
`}
|
||||
>
|
||||
<div class="handle-wrapper">
|
||||
@@ -83,7 +124,9 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight =
|
||||
(this._dialog.offsetHeight / window.innerHeight) * 100;
|
||||
this._dialogMaxViewpointHeight = 90;
|
||||
this._dialogMinViewpointHeight = 20;
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this._opened = true;
|
||||
this._fireResized();
|
||||
} else {
|
||||
// after close animation is done close dialog element and fire closed event
|
||||
this._dialog.close();
|
||||
@@ -102,6 +145,7 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
});
|
||||
document.addEventListener("touchend", this._handleTouchEnd);
|
||||
document.addEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.addEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -113,8 +157,16 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
document.removeEventListener("touchmove", this._handleTouchMove);
|
||||
document.removeEventListener("touchend", this._handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.removeEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
// minHeight is a pixel value, so its viewport-relative minimum depends on the
|
||||
// window height; recompute it when the viewport changes (for example on
|
||||
// rotation) so the cached percentage cannot clip the persistent peek content.
|
||||
private _handleViewportResize = () => {
|
||||
this._applyMinViewportHeight();
|
||||
};
|
||||
|
||||
private _handleMouseDown = (ev: MouseEvent) => {
|
||||
this._startDrag(ev.clientY);
|
||||
};
|
||||
@@ -156,8 +208,10 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
let newSize = this._initialSize + deltaVh;
|
||||
newSize = Math.max(10, Math.min(90, newSize));
|
||||
|
||||
// on drag down and below 20vh
|
||||
if (newSize < 20 && deltaY < 0) {
|
||||
if (this.persistent) {
|
||||
newSize = Math.max(this._minViewportHeight(), newSize);
|
||||
} else if (newSize < 20 && deltaY < 0) {
|
||||
// on drag down and below 20vh
|
||||
this._endDrag();
|
||||
this.closeSheet();
|
||||
return;
|
||||
@@ -166,6 +220,45 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight = newSize;
|
||||
}
|
||||
|
||||
private _minViewportHeight(): number {
|
||||
return this.minHeight === undefined
|
||||
? 20
|
||||
: (this.minHeight / window.innerHeight) * 100;
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues<this>) {
|
||||
super.updated(changedProperties);
|
||||
// A minimum set after opening applies right away
|
||||
if (changedProperties.has("minHeight") && this._opened) {
|
||||
this._applyMinViewportHeight();
|
||||
}
|
||||
}
|
||||
|
||||
// Applies the current pixel minimum as a viewport percentage and, once it has
|
||||
// rendered, reports the size if it changed: raising the minimum can grow a
|
||||
// sheet resting at the old one, and the host needs the new height to keep its
|
||||
// overlay padding in step.
|
||||
private _applyMinViewportHeight() {
|
||||
if (!this._opened) {
|
||||
return;
|
||||
}
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this.updateComplete.then(() => {
|
||||
if (this._dialog.offsetHeight !== this._lastReportedHeight) {
|
||||
this._fireResized();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _lastReportedHeight?: number;
|
||||
|
||||
private _fireResized() {
|
||||
this._lastReportedHeight = this._dialog.offsetHeight;
|
||||
fireEvent(this, "bottom-sheet-resized", {
|
||||
height: this._lastReportedHeight,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleMouseUp = () => {
|
||||
this._endDrag();
|
||||
};
|
||||
@@ -180,6 +273,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
}
|
||||
this._dragging = false;
|
||||
document.body.style.removeProperty("cursor");
|
||||
// Hosts lay out around the sheet once, not on every move
|
||||
this.updateComplete.then(() => this._fireResized());
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
@@ -201,7 +296,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 7;
|
||||
padding-bottom: 76px;
|
||||
/* Extends the grab area over the content below the handle */
|
||||
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
|
||||
}
|
||||
.handle-wrapper .handle::after {
|
||||
content: "";
|
||||
@@ -234,8 +330,20 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
top: 0;
|
||||
inset-inline-start: 0;
|
||||
position: fixed;
|
||||
--sheet-inset-left: var(
|
||||
--ha-bottom-sheet-inset-left,
|
||||
var(--safe-area-inset-left)
|
||||
);
|
||||
--sheet-inset-right: var(
|
||||
--ha-bottom-sheet-inset-right,
|
||||
var(--safe-area-inset-right)
|
||||
);
|
||||
--sheet-border-width: var(--ha-bottom-sheet-border-width, 0px);
|
||||
--sheet-side-borders: calc(2 * var(--sheet-border-width));
|
||||
width: calc(
|
||||
100% - 4px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
100% - var(--sheet-side-borders) - var(--sheet-inset-left) - var(
|
||||
--sheet-inset-right
|
||||
)
|
||||
);
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
@@ -257,14 +365,14 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
);
|
||||
transform: translateY(100%);
|
||||
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
|
||||
border-top-width: var(--ha-bottom-sheet-border-width);
|
||||
border-right-width: var(--ha-bottom-sheet-border-width);
|
||||
border-left-width: var(--ha-bottom-sheet-border-width);
|
||||
border-top-width: var(--sheet-border-width);
|
||||
border-right-width: var(--sheet-border-width);
|
||||
border-left-width: var(--sheet-border-width);
|
||||
border-bottom-width: 0;
|
||||
border-style: var(--ha-bottom-sheet-border-style);
|
||||
border-color: var(--ha-bottom-sheet-border-color);
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
margin-left: var(--sheet-inset-left);
|
||||
margin-right: var(--sheet-inset-right);
|
||||
}
|
||||
|
||||
dialog.show {
|
||||
@@ -280,5 +388,6 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"bottom-sheet-closed": undefined;
|
||||
"bottom-sheet-resized": { height: number };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,9 +164,7 @@ export class HaSelect extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,9 +72,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
></ha-select-box>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -45,8 +45,11 @@ export class HaChooseSelector extends LitElement {
|
||||
}
|
||||
if (
|
||||
changedProperties.has("value") &&
|
||||
changedProperties.get("value")?.active_choice &&
|
||||
changedProperties.get("value")?.active_choice !== this._activeChoice
|
||||
typeof this.value === "object" &&
|
||||
this.value !== null &&
|
||||
"active_choice" in this.value &&
|
||||
this.value.active_choice in this.selector.choose.choices &&
|
||||
this.value.active_choice !== this._activeChoice
|
||||
) {
|
||||
this._setActiveChoice();
|
||||
}
|
||||
@@ -150,14 +153,14 @@ export class HaChooseSelector extends LitElement {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof this.value === "object"
|
||||
return typeof this.value === "object" && "active_choice" in this.value
|
||||
? this.value[choice || this.value.active_choice]
|
||||
: this.value;
|
||||
}
|
||||
|
||||
private _setActiveChoice() {
|
||||
if (this.value) {
|
||||
if (typeof this.value === "object") {
|
||||
if (typeof this.value === "object" && "active_choice" in this.value) {
|
||||
if (this.value.active_choice in this.selector.choose.choices) {
|
||||
this._activeChoice = this.value.active_choice;
|
||||
return;
|
||||
@@ -199,6 +202,22 @@ export class HaChooseSelector extends LitElement {
|
||||
];
|
||||
return;
|
||||
}
|
||||
if (
|
||||
typeofValue === "object" &&
|
||||
!Array.isArray(this.value) &&
|
||||
Object.keys(this.value).length > 0 &&
|
||||
Object.keys(this.value).every((key) =>
|
||||
["days", "hours", "minutes", "seconds", "milliseconds"].includes(
|
||||
key
|
||||
)
|
||||
) &&
|
||||
selectorTypes.includes("duration")
|
||||
) {
|
||||
this._activeChoice = Object.keys(this.selector.choose.choices)[
|
||||
selectorTypes.indexOf("duration")
|
||||
];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
this._activeChoice = Object.keys(this.selector.choose.choices)[0];
|
||||
|
||||
@@ -68,9 +68,7 @@ export class HaDateTimeSelector extends LitElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -4,6 +4,7 @@ import memoizeOne from "memoize-one";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import { createDurationData } from "../../common/datetime/create_duration_data";
|
||||
|
||||
@customElement("ha-selector-duration")
|
||||
export class HaTimeDuration extends LitElement {
|
||||
@@ -27,33 +28,8 @@ export class HaTimeDuration extends LitElement {
|
||||
}
|
||||
|
||||
private _data = memoizeOne(
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined => {
|
||||
if (typeof value === "number") {
|
||||
return { seconds: value };
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const negative = value.trim()[0] === "-";
|
||||
const parts = value
|
||||
.split(":")
|
||||
.map((p) => (negative && p ? -Math.abs(Number(p)) : Number(p)));
|
||||
|
||||
if (parts.length === 1) {
|
||||
return { seconds: parts[0] };
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
return { hours: parts[0], minutes: parts[1] };
|
||||
}
|
||||
if (parts.length === 3) {
|
||||
return {
|
||||
hours: parts[0],
|
||||
minutes: parts[1],
|
||||
seconds: parts[2],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined =>
|
||||
createDurationData(value)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -127,11 +127,12 @@ export class HaLocationSelector extends LitElement {
|
||||
? this.hass.config.longitude
|
||||
: value.longitude,
|
||||
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
|
||||
radius_color: zoneRadiusColor,
|
||||
radius_color: selector.location?.color || zoneRadiusColor,
|
||||
name: selector.location?.name,
|
||||
icon:
|
||||
selector.location?.icon || selector.location?.radius
|
||||
? "mdi:map-marker-radius"
|
||||
: "mdi:map-marker",
|
||||
selector.location?.icon ||
|
||||
// No icon: show the name's initials, else a default marker
|
||||
(selector.location?.name ? undefined : "mdi:map-marker"),
|
||||
location_editable: true,
|
||||
radius_editable:
|
||||
!!selector.location?.radius && !selector.location?.radius_readonly,
|
||||
|
||||
@@ -143,9 +143,7 @@ export class HaNumberSelector extends LitElement {
|
||||
</div>
|
||||
${
|
||||
!isBox && this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -204,9 +204,7 @@ export class HaObjectSelector extends LitElement {
|
||||
></ha-yaml-editor>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: ""
|
||||
} `;
|
||||
}
|
||||
|
||||
@@ -268,9 +268,7 @@ export class HaSelectSelector extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: "";
|
||||
}
|
||||
|
||||
|
||||
@@ -132,9 +132,7 @@ ${
|
||||
}
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
|
||||
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
|
||||
import { css } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
@customElement("ha-sub-menu")
|
||||
export class HaSubMenu extends SubMenu {
|
||||
async show() {
|
||||
super.show();
|
||||
this.menu.hasOverflow = false;
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
--ha-icon-display: block;
|
||||
--md-sys-color-primary: var(--primary-text-color);
|
||||
--md-sys-color-on-primary: var(--primary-text-color);
|
||||
--md-sys-color-secondary: var(--secondary-text-color);
|
||||
--md-sys-color-surface: var(--card-background-color);
|
||||
--md-sys-color-on-surface: var(--primary-text-color);
|
||||
--md-sys-color-on-surface-variant: var(--secondary-text-color);
|
||||
--md-sys-color-secondary-container: rgba(
|
||||
var(--rgb-primary-color),
|
||||
0.15
|
||||
);
|
||||
--md-sys-color-on-secondary-container: var(--text-primary-color);
|
||||
--mdc-icon-size: 16px;
|
||||
|
||||
--md-sys-color-on-primary-container: var(--primary-text-color);
|
||||
--md-sys-color-on-secondary-container: var(--primary-text-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-sub-menu": HaSubMenu;
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,23 @@ export const haTopAppBarFixedStyles = css`
|
||||
.row {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
border-bottom: var(--app-header-border-bottom);
|
||||
}
|
||||
|
||||
:host([narrow]) .row,
|
||||
:host([narrow]) .sub-row {
|
||||
width: calc(
|
||||
100% + var(--safe-area-inset-left, 0px) +
|
||||
var(--safe-area-inset-right, 0px)
|
||||
);
|
||||
margin-left: calc(-1 * var(--safe-area-inset-left, 0px));
|
||||
padding-left: var(--safe-area-inset-left, 0px);
|
||||
}
|
||||
|
||||
.top-app-bar.has-sub-row .row {
|
||||
border-bottom: 0;
|
||||
}
|
||||
@@ -65,7 +76,8 @@ export const haTopAppBarFixedStyles = css`
|
||||
.sub-row {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
width: 100%;
|
||||
width: calc(100% + var(--safe-area-inset-right, 0px));
|
||||
padding-right: var(--safe-area-inset-right, 0px);
|
||||
overflow: hidden;
|
||||
border-bottom: var(--app-header-border-bottom);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { mdiArrowRightThin } from "@mdi/js";
|
||||
import { mdiArrowRightThin, mdiDelete } from "@mdi/js";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { HASSDomCurrentTargetEvent } from "../common/dom/fire_event";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import type { Segment } from "../data/vacuum";
|
||||
import { getVacuumSegments } from "../data/vacuum";
|
||||
import { haStyle } from "../resources/styles";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-alert";
|
||||
import "./ha-area-picker";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
type AreaSegmentMapping = Record<string, string[]>; // area ID -> segment IDs
|
||||
@@ -76,6 +79,8 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
// Group segments by group (if available)
|
||||
const groupedSegments = this._groupSegments(this._segments);
|
||||
|
||||
const orphanedAreas = this._getOrphanedAreas();
|
||||
|
||||
return html`
|
||||
${Object.entries(groupedSegments).map(
|
||||
([groupName, segments]) => html`
|
||||
@@ -83,9 +88,71 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
${segments.map((segment) => this._renderSegment(segment))}
|
||||
`
|
||||
)}
|
||||
${orphanedAreas.length ? this._renderOrphanedAreas(orphanedAreas) : nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _getOrphanedAreas(): string[] {
|
||||
if (!this.value || !this._segments || this._segments.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const liveIds = new Set(this._segments.map((segment) => segment.id));
|
||||
return Object.entries(this.value)
|
||||
.filter(([, segmentIds]) => segmentIds.some((id) => !liveIds.has(id)))
|
||||
.map(([areaId]) => areaId);
|
||||
}
|
||||
|
||||
private _renderOrphanedAreas(areaIds: string[]) {
|
||||
return html`
|
||||
<h2>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_header"
|
||||
)}
|
||||
</h2>
|
||||
<p class="orphaned-description">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_description"
|
||||
)}
|
||||
</p>
|
||||
${areaIds.map((areaId) => {
|
||||
const area = this.hass.areas[areaId];
|
||||
const name = (area ? computeAreaName(area) : undefined) || areaId;
|
||||
return html`
|
||||
<div class="orphaned-row">
|
||||
<span class="orphaned-name">${name}</span>
|
||||
<ha-icon-button
|
||||
.path=${mdiDelete}
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.vacuum_segment_mapping.orphaned_remove",
|
||||
{ name }
|
||||
)}
|
||||
data-area-id=${areaId}
|
||||
@click=${this._removeOrphanedArea}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
`;
|
||||
}
|
||||
|
||||
private _removeOrphanedArea = (
|
||||
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
|
||||
) => {
|
||||
const areaId = ev.currentTarget.dataset.areaId;
|
||||
if (!areaId || !this.value || !this._segments) {
|
||||
return;
|
||||
}
|
||||
const liveIds = new Set(this._segments.map((segment) => segment.id));
|
||||
const newMapping: AreaSegmentMapping = { ...this.value };
|
||||
const kept = (newMapping[areaId] ?? []).filter((id) => liveIds.has(id));
|
||||
if (kept.length) {
|
||||
newMapping[areaId] = kept;
|
||||
} else {
|
||||
delete newMapping[areaId];
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: newMapping });
|
||||
};
|
||||
|
||||
private _groupSegments(segments: Segment[]): Record<string, Segment[]> {
|
||||
const grouped: Record<string, Segment[]> = {};
|
||||
|
||||
@@ -209,8 +276,33 @@ export class HaVacuumSegmentAreaMapper extends LitElement {
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
margin-inline-start: var(--ha-space-4);
|
||||
margin: var(--ha-space-4) var(--ha-space-4) var(--ha-space-2);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.orphaned-description {
|
||||
margin: var(--ha-space-1) var(--ha-space-4) var(--ha-space-2);
|
||||
color: var(--secondary-text-color);
|
||||
font: var(--ha-font-body-s);
|
||||
}
|
||||
|
||||
.orphaned-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-4);
|
||||
padding-block: var(--ha-space-2);
|
||||
padding-inline: var(--ha-space-4) var(--ha-space-2);
|
||||
}
|
||||
|
||||
.orphaned-name {
|
||||
flex: 1;
|
||||
font: var(--ha-font-body-l);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
|
||||
@@ -208,9 +208,7 @@ class HaInputMulti extends LitElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -77,6 +77,7 @@ export type InputType =
|
||||
* @attr {boolean} invalid - Marks the input as invalid.
|
||||
* @attr {boolean} inset-label - Uses an inset label style where the label stays inside the input.
|
||||
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
|
||||
* @attr {string} input-id - Sets the native input's `id` (and associated label `for`) on the inner `wa-input`.
|
||||
*/
|
||||
@customElement("ha-input")
|
||||
export class HaInput extends WaInputMixin(LitElement) {
|
||||
@@ -124,6 +125,10 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
@property({ type: Boolean, attribute: "inset-label" })
|
||||
public insetLabel = false;
|
||||
|
||||
/** Sets the native input id (and label `for`) on the inner `wa-input`. */
|
||||
@property({ attribute: "input-id" })
|
||||
public inputId?: string;
|
||||
|
||||
@query("wa-input")
|
||||
private _input?: WaInput;
|
||||
|
||||
@@ -160,6 +165,39 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
this._input?.stepDown();
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a native input value that was written without `input`/`change` events
|
||||
* (password-manager autofill) into the component so labels and form data update.
|
||||
*/
|
||||
public syncFromNativeInput(): boolean {
|
||||
const native = this._getNativeInput();
|
||||
if (!native) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nativeValue = native.value;
|
||||
if ((this.value ?? "") === nativeValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._input) {
|
||||
this._input.value = nativeValue;
|
||||
}
|
||||
this._handleInput();
|
||||
this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
|
||||
return true;
|
||||
}
|
||||
|
||||
public override reportValidity(): boolean {
|
||||
this.syncFromNativeInput();
|
||||
return super.reportValidity();
|
||||
}
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("focusin", this._syncFromNativeInput);
|
||||
}
|
||||
|
||||
protected override async firstUpdated(
|
||||
changedProperties: PropertyValues<this>
|
||||
): Promise<void> {
|
||||
@@ -175,6 +213,7 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("focusin", this._syncFromNativeInput);
|
||||
this._startSlotResizeObserver?.disconnect();
|
||||
}
|
||||
|
||||
@@ -215,6 +254,7 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
.inputmode=${this.inputmode || undefined}
|
||||
.name=${this.name}
|
||||
.disabled=${this.disabled}
|
||||
.inputId=${this.inputId || "input"}
|
||||
class=${classMap({
|
||||
input: true,
|
||||
invalid: this.invalid || this._invalid,
|
||||
@@ -346,6 +386,14 @@ export class HaInput extends WaInputMixin(LitElement) {
|
||||
this.passwordVisible = !this.passwordVisible;
|
||||
}
|
||||
|
||||
private _getNativeInput(): HTMLInputElement | undefined {
|
||||
return this._input?.input ?? undefined;
|
||||
}
|
||||
|
||||
private _syncFromNativeInput = (): void => {
|
||||
this.syncFromNativeInput();
|
||||
};
|
||||
|
||||
static styles = [
|
||||
waInputStyles,
|
||||
css`
|
||||
|
||||
@@ -24,11 +24,13 @@ class HaEntityMarker extends LitElement {
|
||||
|
||||
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public selected = false;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<div
|
||||
class="marker ${this.entityPicture ? "picture" : ""}"
|
||||
style=${styleMap({ "border-color": this.entityColor })}
|
||||
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
|
||||
@click=${this._badgeTap}
|
||||
>
|
||||
${
|
||||
@@ -94,13 +96,22 @@ class HaEntityMarker extends LitElement {
|
||||
height: var(--ha-marker-size, 48px);
|
||||
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
|
||||
border-radius: var(--ha-marker-border-radius, 50%);
|
||||
border: 1px solid var(--ha-marker-color, var(--primary-color));
|
||||
border: var(--ha-marker-border-width, 3px) solid
|
||||
var(--ha-marker-color, var(--card-background-color, #fff));
|
||||
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
|
||||
color: var(--primary-text-color);
|
||||
background-color: var(--card-background-color);
|
||||
background-color: var(
|
||||
--ha-marker-background,
|
||||
var(--card-background-color)
|
||||
);
|
||||
}
|
||||
.marker.picture {
|
||||
overflow: hidden;
|
||||
}
|
||||
/* A ring in the entity color outside the frame marks the selected marker */
|
||||
:host([selected]) .marker {
|
||||
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
|
||||
}
|
||||
.entity-picture {
|
||||
background-size: cover;
|
||||
height: 100%;
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { ContextType } from "@lit/context";
|
||||
import { consume } from "@lit/context";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { transform } from "../../common/decorators/transform";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
|
||||
import type { MapLatLng } from "../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import { internationalizationContext, uiContext } from "../../data/context";
|
||||
import type { Themes } from "../../data/ws-themes";
|
||||
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
|
||||
import "../ha-alert";
|
||||
import "../ha-input-helper-text";
|
||||
@@ -16,6 +19,10 @@ import "./ha-map";
|
||||
import type { HaMap, HaMapEditableLocation } from "./ha-map";
|
||||
import type { HaIcon } from "../ha-icon";
|
||||
import type { HaSvgIcon } from "../ha-svg-icon";
|
||||
import {
|
||||
createZoneMarkerElement,
|
||||
ZONE_CIRCLE_SIZE,
|
||||
} from "../../common/map/zone-marker";
|
||||
|
||||
declare global {
|
||||
// for fire event
|
||||
@@ -68,6 +75,15 @@ export class HaLocationsEditor extends LitElement {
|
||||
|
||||
@state() private _editingAvailable = true;
|
||||
|
||||
// Marker elements bake in theme colors, so they are rebuilt on a theme
|
||||
// change; narrow the UI context to themes to avoid unrelated rerenders
|
||||
@state()
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
@transform<ContextType<typeof uiContext>, Themes>({
|
||||
transformer: ({ themes }) => themes,
|
||||
})
|
||||
private _themes?: Themes;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
private _i18n?: HomeAssistantInternationalization;
|
||||
@@ -108,7 +124,10 @@ export class HaLocationsEditor extends LitElement {
|
||||
return html`
|
||||
<div class="map">
|
||||
<ha-map
|
||||
.editableLocations=${this._editableLocations(this.locations)}
|
||||
.editableLocations=${this._editableLocations(
|
||||
this.locations,
|
||||
this._themes
|
||||
)}
|
||||
.zoom=${this.zoom}
|
||||
.autoFit=${this.autoFit}
|
||||
.themeMode=${this.themeMode}
|
||||
@@ -140,7 +159,14 @@ export class HaLocationsEditor extends LitElement {
|
||||
}
|
||||
|
||||
private _editableLocations = memoizeOne(
|
||||
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
|
||||
(
|
||||
locations: MarkerLocation[] | undefined,
|
||||
themes: Themes | undefined
|
||||
): HaMapEditableLocation[] => {
|
||||
if (themes !== this._elementsThemes) {
|
||||
this._elementsThemes = themes;
|
||||
this._elements.clear();
|
||||
}
|
||||
const ids = new Set((locations ?? []).map((location) => location.id));
|
||||
for (const id of this._elements.keys()) {
|
||||
if (!ids.has(id)) {
|
||||
@@ -152,7 +178,9 @@ export class HaLocationsEditor extends LitElement {
|
||||
location: [location.latitude, location.longitude],
|
||||
radius: location.radius,
|
||||
element: this._elementFor(location),
|
||||
elementSize: [ICON_SIZE, ICON_SIZE],
|
||||
elementSize: location.radius
|
||||
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
|
||||
: [ICON_SIZE, ICON_SIZE],
|
||||
title: location.name,
|
||||
color: location.radius_color,
|
||||
locationEditable: location.location_editable,
|
||||
@@ -165,30 +193,46 @@ export class HaLocationsEditor extends LitElement {
|
||||
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
|
||||
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
|
||||
|
||||
private _elementsThemes?: Themes;
|
||||
|
||||
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
|
||||
const isZone = !!location.radius;
|
||||
const key = JSON.stringify([
|
||||
isZone,
|
||||
location.icon,
|
||||
location.iconPath,
|
||||
location.name,
|
||||
location.location_editable,
|
||||
location.radius_color,
|
||||
]);
|
||||
const cached = this._elements.get(location.id);
|
||||
if (cached?.key === key) {
|
||||
return cached.element;
|
||||
}
|
||||
const element = this._createIcon(location);
|
||||
// The zone marker doubles as the circle's draggable center
|
||||
const element = isZone
|
||||
? this._createZoneMarker(location)
|
||||
: this._createIcon(location);
|
||||
this._elements.set(location.id, { key, element });
|
||||
return element;
|
||||
}
|
||||
|
||||
private _createZoneMarker(location: MarkerLocation): HTMLElement {
|
||||
return createZoneMarkerElement({
|
||||
color:
|
||||
location.radius_color ||
|
||||
getComputedStyle(this).getPropertyValue("--accent-color"),
|
||||
icon: location.icon,
|
||||
iconPath: location.iconPath,
|
||||
name: location.name ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
|
||||
if (!location.icon && !location.iconPath) {
|
||||
return undefined;
|
||||
}
|
||||
const el = document.createElement("div");
|
||||
el.className = `named-icon ${
|
||||
location.location_editable ? "draggable" : ""
|
||||
}`;
|
||||
el.className = "named-icon";
|
||||
if (location.name !== undefined) {
|
||||
el.innerText = location.name;
|
||||
}
|
||||
@@ -296,12 +340,13 @@ export class HaLocationsEditor extends LitElement {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
|
||||
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
|
||||
The control sits at the physical top-left in either direction. */
|
||||
ha-alert {
|
||||
position: absolute;
|
||||
top: var(--ha-space-2);
|
||||
inset-inline-start: 56px;
|
||||
inset-inline-end: var(--ha-space-2);
|
||||
left: 56px;
|
||||
right: var(--ha-space-2);
|
||||
z-index: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
+364
-71
@@ -1,4 +1,4 @@
|
||||
import { consume } from "@lit/context";
|
||||
import { consume, ContextConsumer } from "@lit/context";
|
||||
import { isToday } from "date-fns";
|
||||
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -18,7 +18,9 @@ import { getEntityLocation } from "../../common/entity/get_entity_location";
|
||||
import { supportsWebGL2 } from "../../common/map/base-layer";
|
||||
import type {
|
||||
MapClusterIcon,
|
||||
MapControlPosition,
|
||||
MapEngine,
|
||||
MapFitPadding,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
@@ -31,15 +33,24 @@ import type {
|
||||
} from "../../common/map/map-engine";
|
||||
import { circleBoundsPoints } from "../../common/map/map-engine";
|
||||
import { editableCircleStyles } from "../../common/map/editable-circle";
|
||||
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
|
||||
import {
|
||||
createZoneMarkerElement,
|
||||
ZONE_CIRCLE_SIZE,
|
||||
zoneMarkerStyles,
|
||||
} from "../../common/map/zone-marker";
|
||||
import { deepEqual } from "../../common/util/deep-equal";
|
||||
import { filterXSS } from "../../common/util/xss";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
formattersContext,
|
||||
fullEntitiesContext,
|
||||
internationalizationContext,
|
||||
statesContext,
|
||||
uiContext,
|
||||
} from "../../data/context";
|
||||
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
|
||||
import { ensureMapTilesToken } from "../../data/map_tiles";
|
||||
import type {
|
||||
HomeAssistantConfig,
|
||||
@@ -211,9 +222,37 @@ export interface HaMapEntity {
|
||||
unit?: string;
|
||||
name?: string;
|
||||
focus?: boolean;
|
||||
hide_accuracy?: boolean;
|
||||
hide_radius?: boolean;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
// Data carried by entity markers for rendering cluster bubbles
|
||||
interface ClusterData {
|
||||
entityId: string;
|
||||
picture?: string;
|
||||
label: string;
|
||||
showIcon: boolean;
|
||||
unit: string;
|
||||
color?: string;
|
||||
selected: boolean;
|
||||
zoneId?: string;
|
||||
}
|
||||
|
||||
const CLUSTER_AVATAR_SIZE = 32;
|
||||
const CLUSTER_BUBBLE_PADDING = 6;
|
||||
const CLUSTER_BUBBLE_GAP = 4;
|
||||
const CLUSTER_MAX_AVATARS = 3;
|
||||
const CLUSTER_MORE_WIDTH = 28;
|
||||
const CLUSTER_MORE_MAX = 99;
|
||||
const CLUSTER_TAIL_SIZE = 10;
|
||||
// The tail is a rotated square on the bubble's bottom edge, reaching half its diagonal below
|
||||
const CLUSTER_TAIL_HEIGHT = Math.round((CLUSTER_TAIL_SIZE * Math.SQRT2) / 2);
|
||||
const CLUSTER_ZONE_SPACING = 2;
|
||||
const CLUSTER_RADIUS = 40;
|
||||
// Same-zone markers share the zone's bubble while they span at most this many
|
||||
// pixels; further apart they show their actual positions
|
||||
const ZONE_GROUP_RADIUS = 160;
|
||||
|
||||
@customElement("ha-map")
|
||||
export class HaMap extends ReactiveElement {
|
||||
@@ -264,6 +303,14 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
|
||||
|
||||
/** Part of the map an overlay covers; automatic fits keep clear of it */
|
||||
@property({ attribute: false }) public fitPadding?: MapFitPadding;
|
||||
|
||||
@property({ attribute: "zoom-position" })
|
||||
public zoomPosition: MapControlPosition = "topleft";
|
||||
|
||||
private _zonePositions: Record<string, MapLatLng> = {};
|
||||
|
||||
@property({ attribute: "theme-mode", type: String })
|
||||
public themeMode: ThemeMode = "auto";
|
||||
|
||||
@@ -296,6 +343,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
private _resizeObserver?: ResizeObserver;
|
||||
|
||||
// Registry creation order decides the palette colors
|
||||
@state() private _entityReg: EntityRegistryEntry[] = [];
|
||||
|
||||
private _registryConsumer?: ContextConsumer<typeof fullEntitiesContext, this>;
|
||||
|
||||
private _entityHandles: MapMarkerHandle[] = [];
|
||||
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
@@ -323,6 +375,21 @@ export class HaMap extends ReactiveElement {
|
||||
this._attachObserver();
|
||||
}
|
||||
|
||||
// Only maps that draw entities ask for the registry; an editor's map, as in
|
||||
// onboarding, never does. The context provider shares one subscription.
|
||||
private _watchRegistry(): void {
|
||||
if (this._registryConsumer || !this.entities?.length) {
|
||||
return;
|
||||
}
|
||||
this._registryConsumer = new ContextConsumer(this, {
|
||||
context: fullEntitiesContext,
|
||||
subscribe: true,
|
||||
callback: (entries) => {
|
||||
this._entityReg = entries;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _handleVisibilityChange = async () => {
|
||||
if (!document.hidden) {
|
||||
setTimeout(() => {
|
||||
@@ -385,10 +452,22 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("clusterMarkers")) {
|
||||
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
|
||||
this._drawEntities();
|
||||
}
|
||||
|
||||
// An overlay that grew or shrank may cover the fitted markers
|
||||
if (
|
||||
changedProps.has("fitPadding") &&
|
||||
!deepEqual(changedProps.get("fitPadding"), this.fitPadding)
|
||||
) {
|
||||
autoFitRequired = !this._pauseAutoFit;
|
||||
}
|
||||
|
||||
if (changedProps.has("zoomPosition")) {
|
||||
this._engine?.setZoomControlPosition(this.zoomPosition);
|
||||
}
|
||||
|
||||
const oldConfig = changedProps.get("_config") as HassConfig | undefined;
|
||||
if (
|
||||
changedProps.has("_loaded") ||
|
||||
@@ -401,6 +480,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("paths")) {
|
||||
this._drawPaths();
|
||||
// Cluster bubbles show entity colors only while trails are visible
|
||||
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
|
||||
if (!!oldPaths?.length !== !!this.paths?.length) {
|
||||
this._engine?.refreshClusters();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
|
||||
@@ -433,13 +517,19 @@ export class HaMap extends ReactiveElement {
|
||||
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
|
||||
if (
|
||||
!changedProps.has("themeMode") &&
|
||||
(!changedProps.has("_ui") ||
|
||||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
|
||||
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._updateMapStyle();
|
||||
// Marker, trail and circle colors were resolved from the theme when drawn
|
||||
this._drawEntities();
|
||||
this._drawPaths();
|
||||
if (this._editableHandles.size) {
|
||||
this._removeEditableLocations();
|
||||
this._drawEditableLocations();
|
||||
}
|
||||
}
|
||||
|
||||
private get _darkMode() {
|
||||
@@ -528,7 +618,7 @@ export class HaMap extends ReactiveElement {
|
||||
darkMode: this._darkMode,
|
||||
token,
|
||||
rasterOnly: this._forceLeaflet,
|
||||
zoomControlPosition: "topleft",
|
||||
zoomControlPosition: this.zoomPosition,
|
||||
events: {
|
||||
click: (location) => this._handleEngineClick(location),
|
||||
zoomStart: () => {
|
||||
@@ -623,6 +713,7 @@ export class HaMap extends ReactiveElement {
|
||||
public fitMap(options?: {
|
||||
zoom?: number;
|
||||
pad?: number;
|
||||
padding?: MapFitPadding;
|
||||
unpause_autofit?: boolean;
|
||||
}): void {
|
||||
if (options?.unpause_autofit) {
|
||||
@@ -666,6 +757,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._engine!.fitBounds(points, {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
padding: options?.padding ?? this.fitPadding,
|
||||
animate: this._hasFitted,
|
||||
});
|
||||
});
|
||||
@@ -713,8 +805,11 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
public fitBounds(
|
||||
boundingbox: MapLatLng[],
|
||||
options?: { zoom?: number; pad?: number }
|
||||
options?: { zoom?: number; pad?: number; padding?: MapFitPadding }
|
||||
) {
|
||||
// An explicit fit is user intent, even while it waits for the engine or
|
||||
// a size; an auto-fit must not take its place in the meantime
|
||||
this._pauseAutoFit = true;
|
||||
if (!this._engine) {
|
||||
// Engine still loading (see _loadMap); runs once it is
|
||||
this._pendingFit = () => this.fitBounds(boundingbox, options);
|
||||
@@ -728,6 +823,7 @@ export class HaMap extends ReactiveElement {
|
||||
maxZoom: options?.zoom || this.zoom,
|
||||
pad: options?.pad ?? 0.5,
|
||||
animate: this._hasFitted,
|
||||
padding: options?.padding,
|
||||
});
|
||||
});
|
||||
this._hasFitted = true;
|
||||
@@ -1031,16 +1127,36 @@ export class HaMap extends ReactiveElement {
|
||||
engine.setClustering(null);
|
||||
return;
|
||||
}
|
||||
this._watchRegistry();
|
||||
|
||||
const computedStyles = getComputedStyle(this);
|
||||
const zoneColor = computedStyles.getPropertyValue("--accent-color");
|
||||
const passiveZoneColor = computedStyles.getPropertyValue(
|
||||
"--secondary-text-color"
|
||||
);
|
||||
|
||||
const darkPrimaryColor = computedStyles.getPropertyValue(
|
||||
"--dark-primary-color"
|
||||
);
|
||||
// A person's state is "home" for the home zone, the zone name otherwise
|
||||
const zoneByState: Record<string, string> = {};
|
||||
this._zonePositions = {};
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
// A zone that is not drawn cannot anchor a bubble either
|
||||
if (
|
||||
stateObj &&
|
||||
computeStateDomain(stateObj) === "zone" &&
|
||||
(this.renderPassive || !stateObj.attributes.passive)
|
||||
) {
|
||||
zoneByState[
|
||||
stateObj.entity_id === "zone.home"
|
||||
? "home"
|
||||
: computeStateName(stateObj)
|
||||
] = stateObj.entity_id;
|
||||
if (
|
||||
typeof stateObj.attributes.latitude === "number" &&
|
||||
typeof stateObj.attributes.longitude === "number"
|
||||
) {
|
||||
this._zonePositions[stateObj.entity_id] = [
|
||||
stateObj.attributes.latitude,
|
||||
stateObj.attributes.longitude,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const entity of this.entities) {
|
||||
const stateObj = states[getEntityId(entity)];
|
||||
@@ -1069,26 +1185,29 @@ export class HaMap extends ReactiveElement {
|
||||
continue;
|
||||
}
|
||||
|
||||
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
|
||||
const hideRadius = typeof entity !== "string" && entity.hide_radius;
|
||||
// A host-set color wins, except passive zones are always muted
|
||||
const markerColor =
|
||||
!passive && typeof entity !== "string" && entity.color
|
||||
? entity.color
|
||||
: zoneColor(
|
||||
stateObj.entity_id,
|
||||
!!passive,
|
||||
this._entityReg,
|
||||
computedStyles
|
||||
);
|
||||
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._zoneHandles.push(
|
||||
engine.addCircle(position, { radius, color: zoneMarkerColor })
|
||||
engine.addCircle(position, { radius, color: markerColor })
|
||||
);
|
||||
}
|
||||
|
||||
// create icon
|
||||
const iconEl = document.createElement("div");
|
||||
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
|
||||
if (icon) {
|
||||
const el = document.createElement("ha-icon");
|
||||
el.setAttribute("icon", icon);
|
||||
iconEl.appendChild(el);
|
||||
} else {
|
||||
const el = document.createElement("span");
|
||||
el.textContent = title;
|
||||
iconEl.appendChild(el);
|
||||
}
|
||||
const circleEl = createZoneMarkerElement({
|
||||
color: markerColor,
|
||||
icon,
|
||||
name: title,
|
||||
});
|
||||
|
||||
if (this.interactiveZones) {
|
||||
const openMoreInfo = (ev: Event) => {
|
||||
@@ -1097,8 +1216,8 @@ export class HaMap extends ReactiveElement {
|
||||
entityId: stateObj.entity_id,
|
||||
});
|
||||
};
|
||||
iconEl.addEventListener("click", openMoreInfo);
|
||||
iconEl.addEventListener("keydown", (ev) => {
|
||||
circleEl.addEventListener("click", openMoreInfo);
|
||||
circleEl.addEventListener("keydown", (ev) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
openMoreInfo(ev);
|
||||
@@ -1106,10 +1225,9 @@ export class HaMap extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
|
||||
this._zoneHandles.push(
|
||||
engine.addMarker(iconEl, position, {
|
||||
size: [zoneIconSize, zoneIconSize],
|
||||
engine.addMarker(circleEl, position, {
|
||||
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
|
||||
interactive: this.interactiveZones,
|
||||
title,
|
||||
})
|
||||
@@ -1119,7 +1237,7 @@ export class HaMap extends ReactiveElement {
|
||||
this.fitZones &&
|
||||
(typeof entity === "string" || entity.focus !== false)
|
||||
) {
|
||||
if (radius) {
|
||||
if (!hideRadius && radius) {
|
||||
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
|
||||
} else {
|
||||
this._focusZonePoints.push(position);
|
||||
@@ -1163,19 +1281,42 @@ export class HaMap extends ReactiveElement {
|
||||
entityPicture && (typeof entity === "string" || !entity.label_mode)
|
||||
? this._connection.hassUrl(entityPicture)
|
||||
: "";
|
||||
// A host may leave the color to the map
|
||||
const entityColor =
|
||||
(typeof entity !== "string" ? entity.color : undefined) ||
|
||||
entityMapColor(getEntityId(entity), this._entityReg, computedStyles);
|
||||
entityMarker.entityColor = entityColor;
|
||||
if (typeof entity !== "string") {
|
||||
entityMarker.entityColor = entity.color;
|
||||
entityMarker.selected = entity.selected ?? false;
|
||||
}
|
||||
|
||||
const clusterData: ClusterData = {
|
||||
entityId: getEntityId(entity),
|
||||
picture: entityMarker.entityPicture || undefined,
|
||||
label: entityName,
|
||||
showIcon: entityMarker.showIcon,
|
||||
unit: entityMarker.entityUnit ?? "",
|
||||
color: entityColor,
|
||||
selected: typeof entity !== "string" && (entity.selected ?? false),
|
||||
zoneId: ["person", "device_tracker"].includes(
|
||||
computeStateDomain(stateObj)
|
||||
)
|
||||
? zoneByState[stateObj.state]
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const showAccuracy =
|
||||
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
|
||||
|
||||
const markerSize = this._getMarkerSize(computedStyles);
|
||||
this._entityHandles.push(
|
||||
engine.addMarker(entityMarker, position, {
|
||||
size: [markerSize, markerSize],
|
||||
title,
|
||||
cluster: true,
|
||||
// create circle around if entity has accuracy
|
||||
decoration: gpsAccuracy
|
||||
? { radius: gpsAccuracy, color: darkPrimaryColor }
|
||||
clusterData,
|
||||
decoration: showAccuracy
|
||||
? { radius: gpsAccuracy!, color: entityColor }
|
||||
: undefined,
|
||||
})
|
||||
);
|
||||
@@ -1187,22 +1328,97 @@ export class HaMap extends ReactiveElement {
|
||||
|
||||
engine.setClustering(
|
||||
this.clusterMarkers
|
||||
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
|
||||
? {
|
||||
radius: CLUSTER_RADIUS,
|
||||
iconBuilder: this._createClusterBubble,
|
||||
// Everyone in a zone shares its bubble until zooming spreads them
|
||||
groupKey: (marker) => (marker.clusterData as ClusterData)?.zoneId,
|
||||
groupRadius: ZONE_GROUP_RADIUS,
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// Renders a marker cluster as a circle with the member count
|
||||
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
|
||||
const size = Math.round(
|
||||
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
|
||||
);
|
||||
const element = document.createElement("div");
|
||||
element.className = "marker-cluster";
|
||||
const count = document.createElement("span");
|
||||
count.textContent = String(members.length);
|
||||
element.appendChild(count);
|
||||
return { element, size: [size, size] };
|
||||
// Renders a marker cluster as a bubble of its members' avatars
|
||||
private _createClusterBubble = (
|
||||
members: MapMarkerHandle[]
|
||||
): MapClusterIcon => {
|
||||
const data = members.map((member) => member.clusterData as ClusterData);
|
||||
const shown = data.slice(0, CLUSTER_MAX_AVATARS);
|
||||
const hidden = data.length - shown.length;
|
||||
|
||||
// With history trails shown, colored borders match avatars to trails
|
||||
const showColors = !!this.paths?.length;
|
||||
|
||||
const bubble = document.createElement("div");
|
||||
bubble.className = "cluster-bubble";
|
||||
for (const member of shown) {
|
||||
const avatar = document.createElement("ha-entity-marker");
|
||||
avatar.entityId = member?.entityId;
|
||||
avatar.entityName = member?.label ?? "";
|
||||
avatar.entityUnit = member?.unit ?? "";
|
||||
avatar.showIcon = member?.showIcon ?? false;
|
||||
avatar.entityPicture = member?.picture ?? "";
|
||||
avatar.entityColor = member?.color;
|
||||
if (showColors) {
|
||||
avatar.style.setProperty(
|
||||
"--ha-marker-color",
|
||||
member?.color ?? "var(--primary-color)"
|
||||
);
|
||||
avatar.style.setProperty("--ha-marker-border-width", "2px");
|
||||
}
|
||||
if (member?.selected) {
|
||||
avatar.selected = true;
|
||||
}
|
||||
bubble.appendChild(avatar);
|
||||
}
|
||||
|
||||
let width =
|
||||
shown.length * CLUSTER_AVATAR_SIZE +
|
||||
(shown.length - 1) * CLUSTER_BUBBLE_GAP +
|
||||
2 * CLUSTER_BUBBLE_PADDING;
|
||||
if (hidden > 0) {
|
||||
const more = document.createElement("span");
|
||||
more.className = "more";
|
||||
more.textContent =
|
||||
hidden > CLUSTER_MORE_MAX ? `${CLUSTER_MORE_MAX}+` : `+${hidden}`;
|
||||
bubble.appendChild(more);
|
||||
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
|
||||
}
|
||||
|
||||
// A cluster of one zone's occupants attaches to that zone's marker
|
||||
const zoneId = data[0]?.zoneId;
|
||||
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
|
||||
const atZone =
|
||||
!!zoneId &&
|
||||
!!zonePosition &&
|
||||
data.every((member) => member?.zoneId === zoneId);
|
||||
|
||||
let height = CLUSTER_AVATAR_SIZE + 2 * CLUSTER_BUBBLE_PADDING;
|
||||
let root: HTMLElement = bubble;
|
||||
if (atZone) {
|
||||
root = document.createElement("div");
|
||||
root.className = "cluster-marker";
|
||||
const tail = document.createElement("div");
|
||||
tail.className = "cluster-bubble-tail";
|
||||
root.append(bubble, tail);
|
||||
height += CLUSTER_TAIL_HEIGHT;
|
||||
}
|
||||
|
||||
return {
|
||||
element: root,
|
||||
size: [width, height],
|
||||
// Float above the zone circle, tail pointing at it
|
||||
...(atZone && zonePosition
|
||||
? {
|
||||
location: zonePosition,
|
||||
anchor: [
|
||||
width / 2,
|
||||
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
|
||||
] as [number, number],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
|
||||
private _drawScaleRuler(): void {
|
||||
@@ -1237,12 +1453,19 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
#map {
|
||||
height: 100%;
|
||||
/* A cluster bubble and its tail cast a single shadow around their
|
||||
combined silhouette (drop-shadow on the wrapper), so no shadow seam
|
||||
appears between the bubble and its tail. */
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12));
|
||||
}
|
||||
#map.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
#map.dark {
|
||||
background: #090909;
|
||||
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))
|
||||
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
|
||||
}
|
||||
#map.forced-dark {
|
||||
color: #ffffff;
|
||||
@@ -1263,6 +1486,8 @@ export class HaMap extends ReactiveElement {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
}
|
||||
.cluster-open-members {
|
||||
display: flex;
|
||||
@@ -1274,7 +1499,13 @@ export class HaMap extends ReactiveElement {
|
||||
max-width: calc(6 * var(--ha-marker-size, 48px) + 5 * 4px + 12px);
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
/* Both tails are a rotated square whose upper half sits under the bubble;
|
||||
drawn behind it, so it never covers a member's frame or selected ring */
|
||||
.cluster-open-tail,
|
||||
.cluster-bubble-tail {
|
||||
position: relative;
|
||||
z-index: -1;
|
||||
}
|
||||
.cluster-open-tail {
|
||||
width: 10px;
|
||||
@@ -1301,6 +1532,19 @@ export class HaMap extends ReactiveElement {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
.maplibregl-ctrl-bottom-left,
|
||||
.maplibregl-ctrl-bottom-right {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.maplibregl-ctrl-top-left,
|
||||
.maplibregl-ctrl-bottom-left {
|
||||
margin-left: var(--ha-map-left-inset, 0);
|
||||
}
|
||||
.maplibregl-ctrl-top-right,
|
||||
.maplibregl-ctrl-bottom-right {
|
||||
margin-right: var(--ha-map-right-inset, 0);
|
||||
}
|
||||
.dark .maplibregl-ctrl.maplibregl-ctrl-group {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
@@ -1327,6 +1571,9 @@ export class HaMap extends ReactiveElement {
|
||||
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
|
||||
border-bottom-color: rgba(80, 80, 80, 0.9) !important;
|
||||
}
|
||||
.maplibregl-ctrl-bottom-left {
|
||||
direction: ltr;
|
||||
}
|
||||
.dark .leaflet-bar a {
|
||||
background-color: #1c1c1c;
|
||||
color: #ffffff;
|
||||
@@ -1346,16 +1593,74 @@ export class HaMap extends ReactiveElement {
|
||||
.leaflet-pane {
|
||||
z-index: 0 !important;
|
||||
}
|
||||
/* Zone icons, sized like the Leaflet divIcon they replaced */
|
||||
.zone-icon {
|
||||
.cluster-marker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
isolation: isolate;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
}
|
||||
/* The wrapper carries the shadow around the bubble-plus-tail outline, so
|
||||
the bubble itself drops its own to avoid a seam at the tail. */
|
||||
.cluster-marker .cluster-bubble {
|
||||
filter: none;
|
||||
}
|
||||
.cluster-bubble-tail {
|
||||
width: ${CLUSTER_TAIL_SIZE}px;
|
||||
height: ${CLUSTER_TAIL_SIZE}px;
|
||||
margin-top: ${-CLUSTER_TAIL_SIZE / 2}px;
|
||||
border-radius: 2px;
|
||||
background: var(--card-background-color, #fff);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.cluster-bubble {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${CLUSTER_BUBBLE_GAP}px;
|
||||
padding: ${CLUSTER_BUBBLE_PADDING}px;
|
||||
box-sizing: border-box;
|
||||
background: var(--card-background-color, #fff);
|
||||
border-radius: 14px;
|
||||
filter: var(--ha-cluster-shadow);
|
||||
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
|
||||
--ha-marker-color: transparent;
|
||||
--ha-marker-border-width: 1px;
|
||||
--ha-marker-shadow: none;
|
||||
--ha-marker-font-size: var(--ha-font-size-s);
|
||||
/* distinguish letter tiles from the bubble background */
|
||||
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
|
||||
}
|
||||
.cluster-bubble .more {
|
||||
flex: none;
|
||||
width: ${CLUSTER_MORE_WIDTH}px;
|
||||
height: ${CLUSTER_AVATAR_SIZE}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--primary-text-color);
|
||||
border-radius: 10px;
|
||||
background: var(--ha-color-fill-neutral-quiet-resting, #f0f0f0);
|
||||
color: var(--primary-text-color, #212121);
|
||||
font-size: var(--ha-font-size-s);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
.zone-icon.dark {
|
||||
color: #ffffff;
|
||||
/* Markers are rounded squares to match the cluster bubble avatars */
|
||||
ha-entity-marker {
|
||||
--ha-marker-border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
.cluster-bubble ha-entity-marker {
|
||||
flex: none;
|
||||
--ha-marker-border-radius: 10px;
|
||||
}
|
||||
${unsafeCSS(zoneMarkerStyles)}
|
||||
.leaflet-bottom {
|
||||
/* Lets a card keep the attribution and scale clear of an overlay */
|
||||
margin-bottom: var(--ha-map-bottom-inset, 0);
|
||||
}
|
||||
.leaflet-left {
|
||||
margin-left: var(--ha-map-left-inset, 0);
|
||||
}
|
||||
.leaflet-right {
|
||||
margin-right: var(--ha-map-right-inset, 0);
|
||||
}
|
||||
.leaflet-control,
|
||||
.leaflet-top,
|
||||
@@ -1377,6 +1682,7 @@ export class HaMap extends ReactiveElement {
|
||||
transparent
|
||||
) !important;
|
||||
text-shadow: none !important;
|
||||
direction: ltr;
|
||||
}
|
||||
/* the theme tokens follow the page, so forced modes need the opposite values */
|
||||
#map.forced-light .leaflet-control-scale-line {
|
||||
@@ -1406,19 +1712,6 @@ export class HaMap extends ReactiveElement {
|
||||
ha-icon {
|
||||
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
|
||||
}
|
||||
|
||||
.marker-cluster {
|
||||
box-sizing: border-box;
|
||||
background-clip: padding-box;
|
||||
background-color: var(--primary-color);
|
||||
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user