mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-26 15:00:42 +00:00
Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7d3a5c2b7 | ||
|
|
cdb68d0b56 | ||
|
|
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 |
@@ -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) => {
|
||||
|
||||
@@ -358,6 +358,12 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
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";
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -319,8 +384,14 @@ const SCHEMAS: {
|
||||
selector: { config_entry: {} },
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
offset: { name: "Offset", selector: { offset: { enable_day: true } } },
|
||||
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: {
|
||||
@@ -505,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: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -606,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) {
|
||||
@@ -777,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,31 +0,0 @@
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
|
||||
export const durationValueToData = (
|
||||
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;
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -90,6 +90,7 @@ export class HaAutomationConditionSummary extends LitElement {
|
||||
></ha-automation-row-options>`
|
||||
: nothing
|
||||
}
|
||||
<slot name="references"></slot>
|
||||
${
|
||||
note
|
||||
? html`
|
||||
|
||||
@@ -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 {
|
||||
@@ -58,6 +59,16 @@ const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
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,
|
||||
|
||||
@@ -44,7 +44,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 +80,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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,7 +42,6 @@ const SELECTOR_FALLBACK_VALUES = {
|
||||
number: (selector) => selector.number?.min ?? 0,
|
||||
numeric_threshold: undefined,
|
||||
object: undefined,
|
||||
offset: undefined,
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: undefined,
|
||||
|
||||
@@ -74,7 +74,6 @@ const SELECTOR_INITIAL_VALUES = {
|
||||
};
|
||||
},
|
||||
object: (selector) => (selector.object?.multiple ? [] : ""),
|
||||
offset: () => ({ type: "none" }),
|
||||
period: undefined,
|
||||
qr_code: undefined,
|
||||
select: (selector) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -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>`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
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,7 +27,10 @@ export class HaTimeDuration extends LitElement {
|
||||
return this._input?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private _data = memoizeOne(durationValueToData);
|
||||
private _data = memoizeOne(
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined =>
|
||||
createDurationData(value)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
|
||||
@@ -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>`
|
||||
: ""
|
||||
} `;
|
||||
}
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
import {
|
||||
mdiClockMinusOutline,
|
||||
mdiClockOutline,
|
||||
mdiClockPlusOutline,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { durationValueToData } from "../../common/datetime/duration_value_to_data";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type {
|
||||
OffsetSelector,
|
||||
OffsetSelectorValue,
|
||||
OffsetType,
|
||||
} from "../../data/selector";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import "../ha-input-helper-text";
|
||||
import "../ha-select";
|
||||
|
||||
const OFFSET_TYPES: { value: OffsetType; iconPath: string }[] = [
|
||||
{ value: "none", iconPath: mdiClockOutline },
|
||||
{ value: "before", iconPath: mdiClockMinusOutline },
|
||||
{ value: "after", iconPath: mdiClockPlusOutline },
|
||||
];
|
||||
|
||||
const DEFAULT_DURATION: HaDurationData = { hours: 0, minutes: 0, seconds: 0 };
|
||||
|
||||
const isOffsetValue = (value: unknown): value is OffsetSelectorValue =>
|
||||
typeof value === "object" && value !== null && "type" in value;
|
||||
|
||||
@customElement("ha-selector-offset")
|
||||
export class HaOffsetSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: OffsetSelector;
|
||||
|
||||
@property({ attribute: false }) public value?: OffsetSelectorValue;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@query("ha-duration-input", true) private _durationInput?: HaDurationInput;
|
||||
|
||||
public reportValidity(): boolean {
|
||||
return this._durationInput?.reportValidity() ?? true;
|
||||
}
|
||||
|
||||
private get _value(): OffsetSelectorValue | undefined {
|
||||
return isOffsetValue(this.value) ? this.value : undefined;
|
||||
}
|
||||
|
||||
private _duration = memoizeOne(durationValueToData);
|
||||
|
||||
private _typeOptions = memoizeOne((localize: LocalizeFunc) =>
|
||||
OFFSET_TYPES.map(({ value, iconPath }) => ({
|
||||
value,
|
||||
iconPath,
|
||||
label: localize(`ui.components.selectors.offset.${value}`),
|
||||
}))
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const type = this._value?.type ?? "none";
|
||||
return html`
|
||||
<div class="container">
|
||||
${
|
||||
this.label
|
||||
? html`<label>${this.label}${this.required ? "*" : ""}</label>`
|
||||
: nothing
|
||||
}
|
||||
<div class="inputs">
|
||||
<ha-select
|
||||
.value=${type}
|
||||
.options=${this._typeOptions(this.hass.localize)}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._typeChanged}
|
||||
></ha-select>
|
||||
${
|
||||
type !== "none"
|
||||
? html`<div class="value-row">
|
||||
<span class="value-label"
|
||||
>${this.hass.localize(
|
||||
"ui.components.selectors.offset.duration"
|
||||
)}${this.required ? "*" : ""}</span
|
||||
>
|
||||
<ha-duration-input
|
||||
.data=${this._duration(this._value?.duration)}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.enableDay=${this.selector.offset?.enable_day}
|
||||
.enableMillisecond=${
|
||||
this.selector.offset?.enable_millisecond
|
||||
}
|
||||
@value-changed=${this._durationChanged}
|
||||
></ha-duration-input>
|
||||
</div>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _durationChanged(ev: CustomEvent<{ value?: HaDurationData }>) {
|
||||
ev.stopPropagation();
|
||||
const type = this._value?.type;
|
||||
if (!type || type === "none") {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value: { type, duration: ev.detail.value ?? DEFAULT_DURATION },
|
||||
});
|
||||
}
|
||||
|
||||
private _typeChanged(ev: CustomEvent<{ value?: string }>) {
|
||||
ev.stopPropagation();
|
||||
const type = ev.detail.value as OffsetType | undefined;
|
||||
if (!type || type === this._value?.type) {
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", {
|
||||
value:
|
||||
type === "none"
|
||||
? { type }
|
||||
: {
|
||||
type,
|
||||
duration:
|
||||
this._duration(this._value?.duration) ?? DEFAULT_DURATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
margin-bottom: var(--ha-space-1);
|
||||
}
|
||||
|
||||
.inputs,
|
||||
.value-row {
|
||||
--ha-input-padding-bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
.value-label {
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
ha-select {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-offset": HaOffsetSelector;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -43,7 +43,6 @@ const LOAD_ELEMENTS = {
|
||||
number: () => import("./ha-selector-number"),
|
||||
numeric_threshold: () => import("./ha-selector-numeric-threshold"),
|
||||
object: () => import("./ha-selector-object"),
|
||||
offset: () => import("./ha-selector-offset"),
|
||||
period: () => import("./ha-selector-period"),
|
||||
qr_code: () => import("./ha-selector-qr-code"),
|
||||
select: () => import("./ha-selector-select"),
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -6,6 +6,10 @@ import { consumeEntityState } from "../../common/decorators/consume-context-entr
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-state-icon";
|
||||
|
||||
/**
|
||||
* @csspart marker - The framed avatar.
|
||||
* @csspart picture - The entity picture inside the frame.
|
||||
*/
|
||||
@customElement("ha-entity-marker")
|
||||
class HaEntityMarker extends LitElement {
|
||||
@property({ attribute: "entity-id", reflect: true }) public entityId?: string;
|
||||
@@ -24,16 +28,20 @@ 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
|
||||
part="marker"
|
||||
class="marker ${this.entityPicture ? "picture" : ""}"
|
||||
style=${styleMap({ "border-color": this.entityColor })}
|
||||
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
|
||||
@click=${this._badgeTap}
|
||||
>
|
||||
${
|
||||
this.entityPicture
|
||||
? html`<div
|
||||
part="picture"
|
||||
class="entity-picture"
|
||||
style=${styleMap({
|
||||
"background-image": `url(${this.entityPicture})`,
|
||||
@@ -94,13 +102,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;
|
||||
}
|
||||
`;
|
||||
|
||||
+391
-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,8 +343,18 @@ 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 _clusterAvatars = new Map<
|
||||
string,
|
||||
HTMLElementTagNameMap["ha-entity-marker"]
|
||||
>();
|
||||
|
||||
private _zoneHandles: MapItemHandle[] = [];
|
||||
|
||||
private _pathHandles: MapItemHandle[] = [];
|
||||
@@ -323,6 +380,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(() => {
|
||||
@@ -345,6 +417,7 @@ export class HaMap extends ReactiveElement {
|
||||
this._startingEngine = undefined;
|
||||
this._loading = false;
|
||||
this._entityHandles = [];
|
||||
this._clusterAvatars.clear();
|
||||
this._zoneHandles = [];
|
||||
this._pathHandles = [];
|
||||
this._removeEditableLocations();
|
||||
@@ -385,10 +458,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 +486,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 +523,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 +624,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 +719,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 +763,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 +811,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 +829,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;
|
||||
@@ -1028,19 +1130,40 @@ export class HaMap extends ReactiveElement {
|
||||
this._focusZonePoints = [];
|
||||
|
||||
if (!this.entities) {
|
||||
this._clusterAvatars.clear();
|
||||
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 +1192,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 +1223,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 +1232,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 +1244,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 +1288,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,
|
||||
})
|
||||
);
|
||||
@@ -1185,24 +1333,119 @@ export class HaMap extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
const shownIds = new Set(this.entities.map(getEntityId));
|
||||
for (const entityId of this._clusterAvatars.keys()) {
|
||||
if (!shownIds.has(entityId)) {
|
||||
this._clusterAvatars.delete(entityId);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
let avatar = member?.entityId
|
||||
? this._clusterAvatars.get(member.entityId)
|
||||
: undefined;
|
||||
// Leaflet keeps the outgoing bubble on screen during its zoom animation
|
||||
if (avatar && (avatar.isConnected || avatar.parentElement === bubble)) {
|
||||
avatar = undefined;
|
||||
}
|
||||
if (!avatar) {
|
||||
avatar = document.createElement("ha-entity-marker");
|
||||
if (member?.entityId) {
|
||||
this._clusterAvatars.set(member.entityId, avatar);
|
||||
}
|
||||
}
|
||||
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");
|
||||
} else {
|
||||
avatar.style.removeProperty("--ha-marker-color");
|
||||
avatar.style.removeProperty("--ha-marker-border-width");
|
||||
}
|
||||
avatar.selected = member?.selected ?? false;
|
||||
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 +1480,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 +1513,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 +1526,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 +1559,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 +1598,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 +1620,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 +1709,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 +1739,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);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,6 @@ import "../ha-list";
|
||||
import "../ha-spinner";
|
||||
import "../ha-svg-icon";
|
||||
import "../ha-tip";
|
||||
import "./ha-media-player-browse";
|
||||
import "./ha-media-upload-button";
|
||||
import type { MediaManageDialogParams } from "./show-media-manage-dialog";
|
||||
|
||||
|
||||
@@ -7,9 +7,11 @@ import { customElement, property, query } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
computeEntityPickerDisplay,
|
||||
computeEntitySearchLabels,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import { computeStateDomain } from "../../common/entity/compute_state_domain";
|
||||
import { computeStateName } from "../../common/entity/compute_state_name";
|
||||
import { supportsFeature } from "../../common/entity/supports-feature";
|
||||
import {
|
||||
areasContext,
|
||||
@@ -107,9 +109,6 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
const webBrowserLabel = this._i18n.localize(
|
||||
"ui.components.media-browser.web-browser"
|
||||
);
|
||||
const lang = this._i18n.language || "en";
|
||||
const isRTL =
|
||||
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -128,34 +127,22 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
...Object.values(this._states)
|
||||
.filter(this._filterPlayerEntities)
|
||||
.map<MediaPlayerComboBoxItem>((stateObj) => {
|
||||
const friendlyName = computeStateName(stateObj);
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
);
|
||||
const entityId = stateObj.entity_id;
|
||||
const domainName = domainToName(
|
||||
this._i18n.localize,
|
||||
computeDomain(entityId)
|
||||
);
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
{
|
||||
entities: this._entities,
|
||||
devices: this._devices,
|
||||
areas: this._areas,
|
||||
floors: this._floors,
|
||||
language: this._i18n.language,
|
||||
translationMetadata: this._i18n.translationMetadata,
|
||||
},
|
||||
stateObj
|
||||
);
|
||||
|
||||
return {
|
||||
id: entityId,
|
||||
@@ -165,12 +152,14 @@ export class HaMediaPlayerPicker extends LitElement {
|
||||
domain_name: domainName,
|
||||
sorting_label: [primary, secondary].filter(Boolean).join("_"),
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
parentDeviceName: parentDeviceName || null,
|
||||
areaName: areaName || null,
|
||||
...computeEntitySearchLabels(
|
||||
stateObj,
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors
|
||||
),
|
||||
domainName: domainName || null,
|
||||
friendlyName: friendlyName || null,
|
||||
entityId,
|
||||
},
|
||||
stateObj,
|
||||
|
||||
@@ -7,8 +7,7 @@ import memoizeOne from "memoize-one";
|
||||
|
||||
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
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 {
|
||||
areasContext,
|
||||
devicesContext,
|
||||
@@ -54,40 +53,24 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
|
||||
private _computeDisplayData = memoizeOne(
|
||||
(
|
||||
entityId: string,
|
||||
entities: ContextType<typeof entitiesContext>,
|
||||
devices: ContextType<typeof devicesContext>,
|
||||
areas: ContextType<typeof areasContext>,
|
||||
floors: ContextType<typeof floorsContext>,
|
||||
isRTL: boolean,
|
||||
i18n: ContextType<typeof internationalizationContext>,
|
||||
stateObj: HassEntity
|
||||
) => {
|
||||
const [entityName, deviceName, parentDeviceName, areaName] =
|
||||
computeEntityNameList(
|
||||
stateObj,
|
||||
[
|
||||
{ type: "entity" },
|
||||
{ type: "device" },
|
||||
{ type: "parent_device" },
|
||||
{ type: "area" },
|
||||
],
|
||||
) =>
|
||||
computeEntityPickerDisplay(
|
||||
{
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
return { primary, secondary };
|
||||
}
|
||||
floors,
|
||||
language: i18n.language,
|
||||
translationMetadata: i18n.translationMetadata,
|
||||
},
|
||||
stateObj
|
||||
)
|
||||
);
|
||||
|
||||
protected render() {
|
||||
@@ -104,18 +87,12 @@ class HaMediaPlayerToggle extends LitElement {
|
||||
icon = mdiSpeakerPause;
|
||||
}
|
||||
|
||||
const isRTL = computeRTL(
|
||||
this._i18n.language,
|
||||
this._i18n.translationMetadata.translations
|
||||
);
|
||||
|
||||
const { primary, secondary } = this._computeDisplayData(
|
||||
this.entityId,
|
||||
this._entities,
|
||||
this._devices,
|
||||
this._areas,
|
||||
this._floors,
|
||||
isRTL,
|
||||
this._i18n,
|
||||
stateObj
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { getDeviceAreaId } from "../../common/entity/context/get_device_context";
|
||||
import type {
|
||||
ExtractFromTargetResultReferenced,
|
||||
TargetType,
|
||||
} from "../../data/target";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
export interface TargetSubRows {
|
||||
nextType: TargetType;
|
||||
rows: string[];
|
||||
rowEntries?: ExtractFromTargetResultReferenced[];
|
||||
deviceRows: string[];
|
||||
deviceRowEntries?: ExtractFromTargetResultReferenced[];
|
||||
entityRows: string[];
|
||||
}
|
||||
|
||||
const emptyEntries = (): ExtractFromTargetResultReferenced => ({
|
||||
referenced_areas: [],
|
||||
referenced_devices: [],
|
||||
referenced_entities: [],
|
||||
});
|
||||
|
||||
export const computeTargetSubRows = (
|
||||
type: TargetType,
|
||||
itemId: string,
|
||||
entries: ExtractFromTargetResultReferenced,
|
||||
entityRegistry: HomeAssistant["entities"],
|
||||
devices: HomeAssistant["devices"]
|
||||
): TargetSubRows => {
|
||||
let nextType: TargetType =
|
||||
type === "floor" ? "area" : type === "area" ? "device" : "entity";
|
||||
|
||||
if (type === "label") {
|
||||
if (entries.referenced_areas.length) {
|
||||
nextType = "area";
|
||||
} else if (entries.referenced_devices.length) {
|
||||
nextType = "device";
|
||||
}
|
||||
}
|
||||
|
||||
const deviceOf = (entityId: string) => entityRegistry[entityId]?.device_id;
|
||||
const parentOf = (deviceId: string) => devices[deviceId]?.parent_device_id;
|
||||
// An entity belongs to a device row when it is on that device or on one of
|
||||
// its child devices, so the row can nest the children below it.
|
||||
const belongsTo = (entityId: string, deviceId: string) => {
|
||||
const entityDevice = deviceOf(entityId);
|
||||
return (
|
||||
entityDevice === deviceId ||
|
||||
(!!entityDevice && parentOf(entityDevice) === deviceId)
|
||||
);
|
||||
};
|
||||
const entitiesOf = (deviceId: string) =>
|
||||
entries.referenced_entities.filter((entityId) =>
|
||||
belongsTo(entityId, deviceId)
|
||||
);
|
||||
const rootsOf = (deviceIds: string[]) =>
|
||||
deviceIds.filter((deviceId) => {
|
||||
const parentId = parentOf(deviceId);
|
||||
return !parentId || !deviceIds.includes(parentId);
|
||||
});
|
||||
|
||||
const childDevices =
|
||||
type === "device"
|
||||
? [
|
||||
...new Set(
|
||||
entries.referenced_entities
|
||||
.map(deviceOf)
|
||||
.filter(
|
||||
(deviceId): deviceId is string =>
|
||||
!!deviceId &&
|
||||
deviceId !== itemId &&
|
||||
parentOf(deviceId) === itemId
|
||||
)
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
const rows =
|
||||
nextType === "area"
|
||||
? entries.referenced_areas
|
||||
: nextType === "device" && type !== "label"
|
||||
? rootsOf(entries.referenced_devices)
|
||||
: type === "device"
|
||||
? entries.referenced_entities.filter(
|
||||
(entityId) => !childDevices.includes(deviceOf(entityId) || "")
|
||||
)
|
||||
: type !== "label"
|
||||
? entries.referenced_entities
|
||||
: [];
|
||||
|
||||
const devicesInAreas: string[] = [];
|
||||
|
||||
const rowEntries =
|
||||
nextType === "entity"
|
||||
? undefined
|
||||
: rows.map((rowItem) => {
|
||||
const nextEntries = emptyEntries();
|
||||
|
||||
if (nextType === "area") {
|
||||
const areaDevices = entries.referenced_devices.filter(
|
||||
(deviceId) => {
|
||||
const device = devices[deviceId];
|
||||
return (
|
||||
!!device &&
|
||||
getDeviceAreaId(device, devices) === rowItem &&
|
||||
entries.referenced_entities.some((entityId) =>
|
||||
belongsTo(entityId, deviceId)
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
devicesInAreas.push(...areaDevices);
|
||||
|
||||
nextEntries.referenced_devices = rootsOf(areaDevices);
|
||||
nextEntries.referenced_entities =
|
||||
entries.referenced_entities.filter((entityId) => {
|
||||
const entity = entityRegistry[entityId];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
areaDevices.includes(entity.device_id)
|
||||
);
|
||||
});
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
nextEntries.referenced_entities = entitiesOf(rowItem);
|
||||
|
||||
return nextEntries;
|
||||
});
|
||||
|
||||
const entityRows =
|
||||
type === "label"
|
||||
? entries.referenced_entities.filter((entityId) => {
|
||||
const entity = entityRegistry[entityId];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.labels.includes(itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
);
|
||||
})
|
||||
: nextType === "device"
|
||||
? entries.referenced_entities.filter(
|
||||
(entityId) => entityRegistry[entityId]?.area_id === itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRows =
|
||||
type === "label"
|
||||
? rootsOf(
|
||||
entries.referenced_devices.filter(
|
||||
(deviceId) =>
|
||||
!devicesInAreas.includes(deviceId) &&
|
||||
devices[deviceId]?.labels.includes(itemId)
|
||||
)
|
||||
)
|
||||
: childDevices;
|
||||
|
||||
const deviceRowEntries = deviceRows.length
|
||||
? deviceRows.map((deviceId) => ({
|
||||
...emptyEntries(),
|
||||
referenced_entities: entitiesOf(deviceId),
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
nextType,
|
||||
rows,
|
||||
rowEntries,
|
||||
deviceRows,
|
||||
deviceRowEntries,
|
||||
entityRows,
|
||||
};
|
||||
};
|
||||
@@ -19,7 +19,6 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
|
||||
import "../../ha-adaptive-dialog";
|
||||
import "../../ha-dialog-header";
|
||||
import "../../ha-icon-button";
|
||||
import "../../ha-icon-next";
|
||||
import "../../ha-svg-icon";
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { consume } from "@lit/context";
|
||||
import {
|
||||
mdiChevronLeft,
|
||||
mdiChevronRight,
|
||||
mdiClose,
|
||||
mdiDevices,
|
||||
mdiHome,
|
||||
@@ -28,9 +26,11 @@ import {
|
||||
computeDeviceNameDisplay,
|
||||
} from "../../common/entity/compute_device_name";
|
||||
import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { computeEntityName } from "../../common/entity/compute_entity_name";
|
||||
import { getDeviceArea } from "../../common/entity/context/get_device_context";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import {
|
||||
getDeviceArea,
|
||||
getDeviceAreaId,
|
||||
} from "../../common/entity/context/get_device_context";
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { AreaRegistryEntry } from "../../data/area/area_registry";
|
||||
import { getConfigEntry } from "../../data/config_entries";
|
||||
@@ -59,10 +59,12 @@ import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"
|
||||
import { floorDefaultIconPath } from "../ha-floor-icon";
|
||||
import "../ha-button";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-icon-next";
|
||||
import "../ha-state-icon";
|
||||
import "../ha-svg-icon";
|
||||
import "../item/ha-list-item-base";
|
||||
import "../item/ha-list-item-button";
|
||||
import { computeTargetSubRows } from "./compute-target-sub-rows";
|
||||
import { showTargetDetailsDialog } from "./dialog/show-dialog-target-details";
|
||||
|
||||
@customElement("ha-target-picker-item-row")
|
||||
@@ -313,19 +315,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
></ha-icon-button>
|
||||
`
|
||||
: this.subEntry && this.type === "entity"
|
||||
? html`
|
||||
<ha-svg-icon
|
||||
.path=${
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? mdiChevronLeft
|
||||
: mdiChevronRight
|
||||
}
|
||||
slot="end"
|
||||
></ha-svg-icon>
|
||||
`
|
||||
? html` <ha-icon-next slot="end"></ha-icon-next> `
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
@@ -401,125 +391,25 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
return this._renderEmptyEntries();
|
||||
}
|
||||
|
||||
let nextType: TargetType =
|
||||
this.type === "floor"
|
||||
? "area"
|
||||
: this.type === "area"
|
||||
? "device"
|
||||
: "entity";
|
||||
|
||||
if (this.type === "label") {
|
||||
if (entries?.referenced_areas.length) {
|
||||
nextType = "area";
|
||||
} else if (entries?.referenced_devices.length) {
|
||||
nextType = "device";
|
||||
}
|
||||
}
|
||||
|
||||
const rows1 =
|
||||
(nextType === "area"
|
||||
? entries?.referenced_areas
|
||||
: nextType === "device" && this.type !== "label"
|
||||
? entries?.referenced_devices
|
||||
: this.type !== "label"
|
||||
? entries?.referenced_entities
|
||||
: []) || [];
|
||||
|
||||
const devicesInAreas = [] as string[];
|
||||
|
||||
const rows1Entries =
|
||||
nextType === "entity"
|
||||
? undefined
|
||||
: rows1.map((rowItem) => {
|
||||
const nextEntries = {
|
||||
referenced_areas: [] as string[],
|
||||
referenced_devices: [] as string[],
|
||||
referenced_entities: [] as string[],
|
||||
};
|
||||
|
||||
if (nextType === "area") {
|
||||
nextEntries.referenced_devices =
|
||||
entries?.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
this.hass.devices?.[device_id]?.area_id === rowItem &&
|
||||
entries?.referenced_entities.some(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === device_id
|
||||
)
|
||||
) || ([] as string[]);
|
||||
|
||||
devicesInAreas.push(...nextEntries.referenced_devices);
|
||||
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.area_id === rowItem ||
|
||||
!entity.device_id ||
|
||||
nextEntries.referenced_devices.includes(entity.device_id)
|
||||
);
|
||||
}) || ([] as string[]);
|
||||
|
||||
return nextEntries;
|
||||
}
|
||||
|
||||
nextEntries.referenced_entities =
|
||||
entries?.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === rowItem
|
||||
) || ([] as string[]);
|
||||
|
||||
return nextEntries;
|
||||
});
|
||||
|
||||
const entityRows =
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_entities.filter((entity_id) => {
|
||||
const entity = this.hass.entities[entity_id];
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
entity.labels.includes(this.itemId) &&
|
||||
!entries.referenced_devices.includes(entity.device_id || "")
|
||||
);
|
||||
})
|
||||
: nextType === "device" && entries
|
||||
? entries.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities[entity_id]?.area_id === this.itemId
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRows =
|
||||
this.type === "label" && entries
|
||||
? entries.referenced_devices.filter(
|
||||
(device_id) =>
|
||||
!devicesInAreas.includes(device_id) &&
|
||||
this.hass.devices[device_id]?.labels.includes(this.itemId)
|
||||
)
|
||||
: [];
|
||||
|
||||
const deviceRowsEntries =
|
||||
deviceRows.length === 0
|
||||
? undefined
|
||||
: deviceRows.map((device_id) => ({
|
||||
referenced_areas: [] as string[],
|
||||
referenced_devices: [] as string[],
|
||||
referenced_entities:
|
||||
entries?.referenced_entities.filter(
|
||||
(entity_id) =>
|
||||
this.hass.entities?.[entity_id]?.device_id === device_id
|
||||
) || ([] as string[]),
|
||||
}));
|
||||
const {
|
||||
nextType,
|
||||
rows,
|
||||
rowEntries,
|
||||
deviceRows,
|
||||
deviceRowEntries,
|
||||
entityRows,
|
||||
} = computeTargetSubRows(
|
||||
this.type,
|
||||
this.itemId,
|
||||
entries,
|
||||
this.hass.entities,
|
||||
this.hass.devices
|
||||
);
|
||||
|
||||
const nextSubLevel = this.subLevel + 1;
|
||||
|
||||
return html`
|
||||
${rows1.map(
|
||||
${rows.map(
|
||||
(itemId, index) => html`
|
||||
<ha-target-picker-item-row
|
||||
sub-entry
|
||||
@@ -528,7 +418,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.type=${nextType}
|
||||
.itemId=${itemId}
|
||||
.parentEntries=${rows1Entries?.[index]}
|
||||
.parentEntries=${rowEntries?.[index]}
|
||||
.hideContext=${this.hideContext || this.type !== "label"}
|
||||
expand
|
||||
></ha-target-picker-item-row>
|
||||
@@ -543,7 +433,7 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
.hass=${this.hass}
|
||||
type="device"
|
||||
.itemId=${itemId}
|
||||
.parentEntries=${deviceRowsEntries?.[index]}
|
||||
.parentEntries=${deviceRowEntries?.[index]}
|
||||
.hideContext=${this.hideContext || this.type !== "label"}
|
||||
expand
|
||||
></ha-target-picker-item-row>
|
||||
@@ -624,32 +514,53 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
|
||||
let referencedDevices = entries.referenced_devices;
|
||||
const hiddenDeviceIds: string[] = [];
|
||||
// Parents kept only so a matching child device can nest under them;
|
||||
// their own entities stay filtered out like a hidden device's.
|
||||
const groupOnlyDeviceIds = new Set<string>();
|
||||
if (
|
||||
this.type === "floor" ||
|
||||
this.type === "area" ||
|
||||
this.type === "label"
|
||||
) {
|
||||
const matchingDeviceIds = new Set(
|
||||
referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
return (
|
||||
!!device &&
|
||||
!hiddenAreaIds.includes(
|
||||
getDeviceAreaId(device, this.hass.devices) || ""
|
||||
) &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
const parentsOfMatching = new Set(
|
||||
[...matchingDeviceIds].map(
|
||||
(device_id) => this.hass.devices[device_id].parent_device_id
|
||||
)
|
||||
);
|
||||
referencedDevices = referencedDevices.filter((device_id) => {
|
||||
const device = this.hass.devices[device_id];
|
||||
if (!device) {
|
||||
// Absent from the registry is not a filter decision: drop the id
|
||||
// without marking it hidden, like the area filtering above.
|
||||
if (!this.hass.devices[device_id]) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
!hiddenAreaIds.includes(device.area_id || "") &&
|
||||
deviceMeetsFilter(
|
||||
device,
|
||||
this.hass.entities,
|
||||
this.deviceFilter,
|
||||
this.includeDomains,
|
||||
this.includeDeviceClasses,
|
||||
this.hass.states,
|
||||
this.entityFilter,
|
||||
!this.primaryEntitiesOnly
|
||||
)
|
||||
) {
|
||||
if (matchingDeviceIds.has(device_id)) {
|
||||
return true;
|
||||
}
|
||||
if (parentsOfMatching.has(device_id)) {
|
||||
groupOnlyDeviceIds.add(device_id);
|
||||
return true;
|
||||
}
|
||||
|
||||
hiddenDeviceIds.push(device_id);
|
||||
return false;
|
||||
});
|
||||
@@ -663,7 +574,10 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
if (!entity) {
|
||||
return false;
|
||||
}
|
||||
if (hiddenDeviceIds.includes(entity.device_id || "")) {
|
||||
if (
|
||||
hiddenDeviceIds.includes(entity.device_id || "") ||
|
||||
groupOnlyDeviceIds.has(entity.device_id || "")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
@@ -757,40 +671,12 @@ export class HaTargetPickerItemRow extends LitElement {
|
||||
}
|
||||
if (type === "entity") {
|
||||
const stateObject: HassEntity | undefined = this.hass.states[item];
|
||||
const entityName = stateObject
|
||||
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
|
||||
: item;
|
||||
const { area, device, parentDevice } = stateObject
|
||||
? getEntityContext(
|
||||
stateObject,
|
||||
this.hass.entities,
|
||||
this.hass.devices,
|
||||
this.hass.areas,
|
||||
this.hass.floors
|
||||
)
|
||||
: { area: undefined, device: undefined, parentDevice: undefined };
|
||||
const deviceName = device ? computeDeviceName(device) : undefined;
|
||||
const parentDeviceName = parentDevice
|
||||
? computeDeviceName(parentDevice)
|
||||
: undefined;
|
||||
const areaName = area ? computeAreaName(area) : undefined;
|
||||
const context = [
|
||||
areaName,
|
||||
parentDeviceName,
|
||||
entityName ? deviceName : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(
|
||||
computeRTL(
|
||||
this.hass.language,
|
||||
this.hass.translationMetadata.translations
|
||||
)
|
||||
? " ◂ "
|
||||
: " ▸ "
|
||||
);
|
||||
const { primary, secondary } = stateObject
|
||||
? computeEntityPickerDisplay(this.hass, stateObject)
|
||||
: { primary: item, secondary: undefined };
|
||||
return {
|
||||
name: entityName || deviceName || item,
|
||||
context,
|
||||
name: primary,
|
||||
context: secondary,
|
||||
stateObject,
|
||||
notFound: !stateObject && item !== "all" && item !== "none",
|
||||
};
|
||||
|
||||
@@ -69,7 +69,10 @@ export class HaTracePathDetails extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public renderedNodes: Record<string, any> = {};
|
||||
|
||||
@property({ attribute: false }) public trackedNodes!: Record<string, any>;
|
||||
@property({ attribute: false }) public trackedNodes!: Record<
|
||||
string,
|
||||
NodeInfo
|
||||
>;
|
||||
|
||||
@state() private _view: (typeof TRACE_PATH_TABS)[number] = "step_config";
|
||||
|
||||
@@ -192,11 +195,16 @@ export class HaTracePathDetails extends LitElement {
|
||||
const nestPath = curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
.split("/");
|
||||
let currentDetail = this.selected.config;
|
||||
let currentDetail: unknown = this.selected.config;
|
||||
for (const part of nestPath) {
|
||||
if (!["undefined", "string"].includes(typeof currentDetail[part])) {
|
||||
currentDetail = currentDetail[part];
|
||||
if (typeof currentDetail !== "object" || currentDetail === null) {
|
||||
break;
|
||||
}
|
||||
const child = (currentDetail as Record<string, unknown>)[part];
|
||||
if (child === undefined || typeof child === "string") {
|
||||
break;
|
||||
}
|
||||
currentDetail = child;
|
||||
}
|
||||
|
||||
parts.push(
|
||||
@@ -282,6 +290,9 @@ export class HaTracePathDetails extends LitElement {
|
||||
: html`<pre>${dump(rest)}</pre>`
|
||||
}
|
||||
${
|
||||
typeof currentDetail === "object" &&
|
||||
currentDetail !== null &&
|
||||
"entity_id" in currentDetail &&
|
||||
currentDetail.entity_id &&
|
||||
curPath
|
||||
.substring(this.selected.path.length + 1)
|
||||
@@ -326,7 +337,7 @@ export class HaTracePathDetails extends LitElement {
|
||||
this._entityReg,
|
||||
currentDetail,
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
this._manifests
|
||||
)
|
||||
: selectedType === "chooseOption"
|
||||
@@ -486,7 +497,9 @@ export class HaTracePathDetails extends LitElement {
|
||||
const trackedPaths = Object.keys(this.trackedNodes);
|
||||
const index = trackedPaths.indexOf(this.selected.path);
|
||||
|
||||
if (index === -1) {
|
||||
// Synthetic choose-option nodes have no direct trace records, so there is
|
||||
// no start timestamp to slice the logbook with.
|
||||
if (index === -1 || !startTrace) {
|
||||
return html`<div class="padded-box">
|
||||
${this.hass!.localize(
|
||||
"ui.panel.config.automation.trace.path.step_not_executed"
|
||||
@@ -496,7 +509,11 @@ export class HaTracePathDetails extends LitElement {
|
||||
|
||||
let entries: LogbookEntry[];
|
||||
|
||||
if (index === trackedPaths.length - 1) {
|
||||
const nextTrace =
|
||||
index < trackedPaths.length - 1
|
||||
? paths[trackedPaths[index + 1]]
|
||||
: undefined;
|
||||
if (!nextTrace) {
|
||||
// it's the last entry. Find all logbook entries after start.
|
||||
const startTime = new Date(startTrace[0].timestamp);
|
||||
const idx = this.logbookEntries.findIndex(
|
||||
@@ -508,8 +525,6 @@ export class HaTracePathDetails extends LitElement {
|
||||
entries = this.logbookEntries.slice(idx);
|
||||
}
|
||||
} else {
|
||||
const nextTrace = paths[trackedPaths[index + 1]];
|
||||
|
||||
const startTime = new Date(startTrace[0].timestamp);
|
||||
const endTime = new Date(nextTrace[0].timestamp);
|
||||
|
||||
|
||||
@@ -18,32 +18,32 @@ import {
|
||||
mdiRoomService,
|
||||
mdiShuffleDisabled,
|
||||
} from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import type { PropertyValues } from "lit";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../common/translations/localize";
|
||||
import type { Condition, Trigger } from "../../data/automation";
|
||||
import { flattenTriggers } from "../../data/automation";
|
||||
import type {
|
||||
Action,
|
||||
ChooseAction,
|
||||
IfAction,
|
||||
ManualScriptConfig,
|
||||
ParallelAction,
|
||||
RepeatAction,
|
||||
SequenceAction,
|
||||
ServiceAction,
|
||||
WaitAction,
|
||||
WaitForTriggerAction,
|
||||
import {
|
||||
getActionType,
|
||||
type ChooseAction,
|
||||
type IfAction,
|
||||
type ParallelAction,
|
||||
type RepeatAction,
|
||||
type SequenceAction,
|
||||
type ServiceAction,
|
||||
type WaitAction,
|
||||
type WaitForTriggerAction,
|
||||
} from "../../data/script";
|
||||
import { getActionType } from "../../data/script";
|
||||
import type { TraceExtended } from "../../data/trace";
|
||||
import { TraceTree } from "../../data/trace-tree";
|
||||
import type {
|
||||
ChooseActionTraceStep,
|
||||
ConditionTraceStep,
|
||||
IfActionTraceStep,
|
||||
TraceExtended,
|
||||
} from "../../data/trace";
|
||||
NodeInfo,
|
||||
TraceActionNode,
|
||||
TraceNode,
|
||||
} from "../../data/trace-tree";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-service-icon";
|
||||
import "./hat-graph-branch";
|
||||
@@ -52,13 +52,7 @@ import "./hat-graph-node";
|
||||
import "./hat-graph-spacer";
|
||||
import { ACTION_ICONS } from "../../data/action";
|
||||
|
||||
type NodeType = "trigger" | "condition" | "action" | "chooseOption" | undefined;
|
||||
|
||||
export interface NodeInfo {
|
||||
path: string;
|
||||
config: any;
|
||||
type?: NodeType;
|
||||
}
|
||||
export type { NodeInfo };
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
@@ -68,6 +62,10 @@ declare global {
|
||||
|
||||
@customElement("hat-script-graph")
|
||||
export class HatScriptGraph extends LitElement {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
|
||||
@property({ attribute: false }) public trace!: TraceExtended;
|
||||
|
||||
@property({ attribute: false }) public selected?: string;
|
||||
@@ -75,57 +73,43 @@ export class HatScriptGraph extends LitElement {
|
||||
@query("hat-graph-node[active], hat-graph-branch[active]")
|
||||
private _activeNode?: HTMLElement;
|
||||
|
||||
public renderedNodes: Record<string, NodeInfo> = {};
|
||||
private _buildTree = memoizeOne(
|
||||
(trace: TraceExtended) => new TraceTree(trace)
|
||||
);
|
||||
|
||||
public trackedNodes: Record<string, NodeInfo> = {};
|
||||
public get renderedNodes(): Record<string, NodeInfo> {
|
||||
return this._buildTree(this.trace).renderedNodes;
|
||||
}
|
||||
|
||||
private _selectNode(config, path, type?) {
|
||||
public get trackedNodes(): Record<string, NodeInfo> {
|
||||
return this._buildTree(this.trace).trackedNodes;
|
||||
}
|
||||
|
||||
private _selectNode(config: unknown, path: string, type?: NodeInfo["type"]) {
|
||||
return () => {
|
||||
fireEvent(this, "graph-node-selected", { config, path, type });
|
||||
};
|
||||
}
|
||||
|
||||
private _renderTrigger(config: Trigger, i: number) {
|
||||
const path = `trigger/${i}`;
|
||||
const tracked = this.trace && path in this.trace.trace;
|
||||
// A not-triggered trace records the trigger that evaluated a change but
|
||||
// decided not to fire. It is still selectable (to view the reason), but
|
||||
// must not be shown as the path that ran.
|
||||
const notTriggered = !!(tracked && this.trace.not_triggered);
|
||||
const track = tracked && !notTriggered;
|
||||
this.renderedNodes[path] = { config, path, type: "trigger" };
|
||||
if (tracked) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
private _renderTrigger(node: TraceNode<Trigger>) {
|
||||
const { config, path, track, hasTrace } = node;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
graph-start
|
||||
?track=${track}
|
||||
?not-triggered=${notTriggered}
|
||||
?not-triggered=${node.notTriggered}
|
||||
@focus=${this._selectNode(config, path, "trigger")}
|
||||
?active=${this.selected === path}
|
||||
.iconPath=${mdiAsterisk}
|
||||
.notEnabled=${"enabled" in config && config.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${tracked ? "0" : "-1"}
|
||||
.notEnabled=${node.disabled}
|
||||
.error=${node.error}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderCondition(config: Condition, i: number) {
|
||||
const path = `condition/${i}`;
|
||||
this.renderedNodes[path] = { config, path, type: "condition" };
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
return this._renderConditionNode(config, path);
|
||||
}
|
||||
|
||||
private _typeRenderers = {
|
||||
condition: this._renderConditionNode,
|
||||
and: this._renderConditionNode,
|
||||
or: this._renderConditionNode,
|
||||
not: this._renderConditionNode,
|
||||
service: this._renderServiceNode,
|
||||
wait_template: this._renderWaitNode,
|
||||
wait_for_trigger: this._renderWaitNode,
|
||||
@@ -137,240 +121,142 @@ export class HatScriptGraph extends LitElement {
|
||||
other: this._renderOtherNode,
|
||||
};
|
||||
|
||||
private _renderActionNode(
|
||||
node: Action,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
private _renderActionNode(node: TraceActionNode, graphStart = false) {
|
||||
// The modern `action:` key has no dedicated renderer. The old
|
||||
// `key in node` lookup fell through to the generic node for it, so keep
|
||||
// that here for visual parity. The generic node still picks the service
|
||||
// icon through the node's action type.
|
||||
const type =
|
||||
Object.keys(this._typeRenderers).find((key) => key in node) || "other";
|
||||
this.renderedNodes[path] = { config: node, path, type: "action" };
|
||||
if (this.trace && path in this.trace.trace) {
|
||||
this.trackedNodes[path] = this.renderedNodes[path];
|
||||
}
|
||||
return this._typeRenderers[type].bind(this)(
|
||||
"action" in node.config ? "other" : (node.actionType ?? "other");
|
||||
return (this._typeRenderers[type] ?? this._renderOtherNode).bind(this)(
|
||||
node,
|
||||
path,
|
||||
graphStart,
|
||||
disabled
|
||||
graphStart
|
||||
);
|
||||
}
|
||||
|
||||
private _renderChooseNode(
|
||||
config: ChooseAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
node: TraceActionNode<ChooseAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as ChooseActionTraceStep[] | undefined;
|
||||
const tracePath = trace
|
||||
? trace.map((trc) =>
|
||||
trc.result === undefined || trc.result.choice === "default"
|
||||
? "default"
|
||||
: trc.result.choice
|
||||
)
|
||||
: [];
|
||||
const trackDefault = tracePath.includes("default");
|
||||
const { config, path, track } = node;
|
||||
const defaultBranch = node.branches[node.branches.length - 1];
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${trace !== undefined}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
.notEnabled=${node.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiArrowDecision}
|
||||
?track=${trace !== undefined}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
.notEnabled=${node.disabled}
|
||||
.error=${node.error}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
|
||||
${
|
||||
config.choose
|
||||
? ensureArray(config.choose)?.map((branch, i) => {
|
||||
const branchPath = `${path}/choose/${i}`;
|
||||
const trackThis = tracePath.includes(i);
|
||||
this.renderedNodes[branchPath] = {
|
||||
config: branch,
|
||||
path: branchPath,
|
||||
type: "chooseOption",
|
||||
};
|
||||
if (trackThis) {
|
||||
this.trackedNodes[branchPath] =
|
||||
this.renderedNodes[branchPath];
|
||||
${node.branches.slice(0, -1).map(
|
||||
(branch) => html`
|
||||
<div class="graph-container" ?track=${branch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${
|
||||
!track || branch.hasTrace
|
||||
? mdiCheckboxMarkedOutline
|
||||
: mdiCheckboxBlankOutline
|
||||
}
|
||||
return html`
|
||||
<div class="graph-container" ?track=${trackThis}>
|
||||
<hat-graph-node
|
||||
.iconPath=${
|
||||
!trace || trackThis
|
||||
? mdiCheckboxMarkedOutline
|
||||
: mdiCheckboxBlankOutline
|
||||
}
|
||||
@focus=${this._selectNode(
|
||||
branch,
|
||||
branchPath,
|
||||
"chooseOption"
|
||||
)}
|
||||
?track=${trackThis}
|
||||
?active=${this.selected === branchPath}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
></hat-graph-node>
|
||||
${
|
||||
branch.sequence !== null
|
||||
? ensureArray<Action>(branch.sequence).map(
|
||||
(action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${branchPath}/sequence/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
)
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
: ""
|
||||
}
|
||||
<div ?track=${trackDefault}>
|
||||
<hat-graph-spacer ?track=${trackDefault}></hat-graph-spacer>
|
||||
${
|
||||
config.default !== null
|
||||
? ensureArray<Action | undefined>(config.default)?.map(
|
||||
(action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/default/${i}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
)
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIfNode(
|
||||
config: IfAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as IfActionTraceStep[] | undefined;
|
||||
let trackThen = false;
|
||||
let trackElse = false;
|
||||
for (const trc of trace || []) {
|
||||
if (!trackThen && trc.result?.choice === "then") {
|
||||
trackThen = true;
|
||||
}
|
||||
if ((!trackElse && trc.result?.choice === "else") || !trc.result) {
|
||||
trackElse = true;
|
||||
}
|
||||
if (trackElse && trackThen) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${trace !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCallSplit}
|
||||
?track=${trace !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${
|
||||
config.else
|
||||
? html`<div class="graph-container" ?track=${trackElse}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallMissed}
|
||||
?track=${trackElse}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
nofocus
|
||||
></hat-graph-node
|
||||
>${ensureArray<Action>(config.else).map((action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/else/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
@focus=${this._selectNode(
|
||||
branch.option,
|
||||
branch.path,
|
||||
"chooseOption"
|
||||
)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer ?track=${trackElse}></hat-graph-spacer>`
|
||||
}
|
||||
<div class="graph-container" ?track=${trackThen}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${trackThen}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || config.enabled === false}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray<Action>(config.then ?? []).map((action, j) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/then/${j}`,
|
||||
false,
|
||||
disabled || config.enabled === false
|
||||
)
|
||||
?track=${branch.hasTrace}
|
||||
?active=${this.selected === branch.path}
|
||||
.notEnabled=${branch.disabled}
|
||||
></hat-graph-node>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
<div ?track=${defaultBranch.hasTrace}>
|
||||
<hat-graph-spacer ?track=${defaultBranch.hasTrace}></hat-graph-spacer>
|
||||
${defaultBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderIfNode(node: TraceActionNode<IfAction>, graphStart = false) {
|
||||
const { config, path, track } = node;
|
||||
const [thenBranch, elseBranch] = node.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${node.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(config, path, "action")}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCallSplit}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${node.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${
|
||||
config.else
|
||||
? html`<div class="graph-container" ?track=${elseBranch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallMissed}
|
||||
?track=${elseBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${elseBranch.disabled}
|
||||
nofocus
|
||||
></hat-graph-node
|
||||
>${elseBranch.children.map((action) =>
|
||||
this._renderActionNode(action)
|
||||
)}
|
||||
</div>`
|
||||
: html`<hat-graph-spacer
|
||||
?track=${elseBranch.hasTrace}
|
||||
></hat-graph-spacer>`
|
||||
}
|
||||
<div class="graph-container" ?track=${thenBranch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiCallReceived}
|
||||
?track=${thenBranch.hasTrace}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${thenBranch.disabled}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${thenBranch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderConditionNode(
|
||||
node: Condition,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceNode<Condition>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace = this.trace.trace[path] as ConditionTraceStep[] | undefined;
|
||||
let track = false;
|
||||
let trackPass = false;
|
||||
let trackFailed = false;
|
||||
if (trace) {
|
||||
for (const trc of trace) {
|
||||
if (trc.result) {
|
||||
track = true;
|
||||
if (trc.result.result) {
|
||||
trackPass = true;
|
||||
} else {
|
||||
trackFailed = true;
|
||||
}
|
||||
}
|
||||
if (trackPass && trackFailed) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const { config: node, path, track, hasTrace } = model;
|
||||
const passed = model.condition?.passed ?? false;
|
||||
const failed = model.condition?.failed ?? false;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
@focus=${this._selectNode(node, path, "condition")}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
.notEnabled=${model.disabled}
|
||||
tabindex=${hasTrace ? "0" : "-1"}
|
||||
short
|
||||
>
|
||||
<hat-graph-node
|
||||
@@ -378,7 +264,7 @@ export class HatScriptGraph extends LitElement {
|
||||
slot="head"
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
.iconPath=${mdiAbTesting}
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
@@ -387,81 +273,71 @@ export class HatScriptGraph extends LitElement {
|
||||
graph-start
|
||||
graph-end
|
||||
></div>
|
||||
<div ?track=${trackPass}></div>
|
||||
<div ?track=${passed}></div>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiClose}
|
||||
nofocus
|
||||
?track=${trackFailed}
|
||||
?track=${failed}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
></hat-graph-node>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderRepeatNode(
|
||||
node: RepeatAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<RepeatAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const repeats = this.trace?.trace[`${path}/repeat/sequence/0`]?.length;
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiRefresh}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
<hat-graph-node
|
||||
.iconPath=${mdiArrowUp}
|
||||
?track=${repeats > 1}
|
||||
?track=${model.badge !== undefined}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
nofocus
|
||||
.badge=${repeats > 1 ? repeats : undefined}
|
||||
.badge=${model.badge}
|
||||
></hat-graph-node>
|
||||
<div ?track=${trace}>
|
||||
${ensureArray<Action>(node.repeat.sequence).map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/repeat/sequence/${i}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
<div ?track=${model.hasTrace}>
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderServiceNode(
|
||||
node: ServiceAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<ServiceAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${node.action ? undefined : mdiRoomService}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
>
|
||||
${
|
||||
node.action
|
||||
@@ -476,145 +352,110 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
|
||||
private _renderWaitNode(
|
||||
node: WaitAction | WaitForTriggerAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<WaitAction | WaitForTriggerAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiCodeBraces}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
tabindex=${this.trace && path in this.trace.trace ? "0" : "-1"}
|
||||
.notEnabled=${model.disabled}
|
||||
.error=${model.error}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderSequenceNode(
|
||||
node: SequenceAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<SequenceAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const { config: node, path, track } = model;
|
||||
const [branch] = model.branches;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<div class="graph-container" ?track=${path in this.trace.trace}>
|
||||
<div class="graph-container" ?track=${branch.hasTrace}>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiFormatListNumbered}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray(node.sequence ?? []).map((action, i) =>
|
||||
this._renderActionNode(
|
||||
action,
|
||||
`${path}/sequence/${i}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
${branch.children.map((action) => this._renderActionNode(action))}
|
||||
</div>
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderParallelNode(
|
||||
node: ParallelAction,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
model: TraceActionNode<ParallelAction>,
|
||||
graphStart = false
|
||||
) {
|
||||
const trace: any = this.trace.trace[path];
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-branch
|
||||
tabindex=${trace === undefined ? "-1" : "0"}
|
||||
tabindex=${model.hasTrace ? "0" : "-1"}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
>
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${mdiShuffleDisabled}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.notEnabled=${model.disabled}
|
||||
slot="head"
|
||||
nofocus
|
||||
></hat-graph-node>
|
||||
${ensureArray<Action>(node.parallel).map((action, i) =>
|
||||
"sequence" in action
|
||||
? html`<div ?track=${path in this.trace.trace}>
|
||||
${ensureArray<Action>(
|
||||
(action as ManualScriptConfig).sequence
|
||||
).map((sAction, j) =>
|
||||
this._renderActionNode(
|
||||
sAction,
|
||||
`${path}/parallel/${i}/sequence/${j}`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
)}
|
||||
</div>`
|
||||
: this._renderActionNode(
|
||||
action,
|
||||
`${path}/parallel/${i}/sequence/0`,
|
||||
false,
|
||||
disabled || node.enabled === false
|
||||
)
|
||||
${model.branches.map(
|
||||
(branch) =>
|
||||
html`<div ?track=${branch.hasTrace}>
|
||||
${branch.children.map((sAction) =>
|
||||
this._renderActionNode(sAction)
|
||||
)}
|
||||
</div>`
|
||||
)}
|
||||
</hat-graph-branch>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderOtherNode(
|
||||
node: Action,
|
||||
path: string,
|
||||
graphStart = false,
|
||||
disabled = false
|
||||
) {
|
||||
private _renderOtherNode(model: TraceActionNode, graphStart = false) {
|
||||
const { config: node, path, track } = model;
|
||||
return html`
|
||||
<hat-graph-node
|
||||
.graphStart=${graphStart}
|
||||
.iconPath=${ACTION_ICONS[getActionType(node)] || mdiCodeBrackets}
|
||||
@focus=${this._selectNode(node, path, "action")}
|
||||
?track=${path in this.trace.trace}
|
||||
?track=${track}
|
||||
?active=${this.selected === path}
|
||||
.error=${this.trace.trace[path]?.some((tr) => tr.error)}
|
||||
.notEnabled=${disabled || node.enabled === false}
|
||||
.error=${model.error}
|
||||
.notEnabled=${model.disabled}
|
||||
></hat-graph-node>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const triggerKey = "triggers" in this.trace.config ? "triggers" : "trigger";
|
||||
const conditionKey =
|
||||
"conditions" in this.trace.config ? "conditions" : "condition";
|
||||
const actionKey = "actions" in this.trace.config ? "actions" : "action";
|
||||
|
||||
const paths = Object.keys(this.trackedNodes);
|
||||
const triggerNodes =
|
||||
triggerKey in this.trace.config
|
||||
? flattenTriggers(ensureArray(this.trace.config[triggerKey])).map(
|
||||
(trigger, i) => this._renderTrigger(trigger, i)
|
||||
)
|
||||
: undefined;
|
||||
try {
|
||||
const tree = this._buildTree(this.trace);
|
||||
const paths = tree.trackedPaths;
|
||||
const triggerNodes = tree.triggers?.map((node) =>
|
||||
this._renderTrigger(node)
|
||||
);
|
||||
return html`
|
||||
<div class="graph-scroll ha-scrollbar">
|
||||
<div class="parent graph-container">
|
||||
@@ -628,37 +469,26 @@ export class HatScriptGraph extends LitElement {
|
||||
</hat-graph-branch>`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
conditionKey in this.trace.config
|
||||
? html`${ensureArray(this.trace.config[conditionKey])?.map(
|
||||
(condition, i) => this._renderCondition(condition, i)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
actionKey in this.trace.config
|
||||
? html`${ensureArray(this.trace.config[actionKey]).map(
|
||||
(action, i) => this._renderActionNode(action, `action/${i}`)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${
|
||||
"sequence" in this.trace.config
|
||||
? html`${ensureArray<Action>(this.trace.config.sequence).map(
|
||||
(action, i) =>
|
||||
this._renderActionNode(action, `sequence/${i}`, i === 0)
|
||||
)}`
|
||||
: ""
|
||||
}
|
||||
${tree.conditions.map((node) => this._renderConditionNode(node))}
|
||||
${tree.actions.map((node) => this._renderActionNode(node))}
|
||||
${tree.sequence.map((node, i) =>
|
||||
this._renderActionNode(node, i === 0)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<ha-icon-button
|
||||
label=${this._localize(
|
||||
"ui.panel.config.automation.trace.previous_tracked_node"
|
||||
)}
|
||||
.disabled=${paths.length === 0 || paths[0] === this.selected}
|
||||
@click=${this._previousTrackedNode}
|
||||
.path=${mdiChevronUp}
|
||||
></ha-icon-button>
|
||||
<ha-icon-button
|
||||
label=${this._localize(
|
||||
"ui.panel.config.automation.trace.next_tracked_node"
|
||||
)}
|
||||
.disabled=${
|
||||
paths.length === 0 || paths[paths.length - 1] === this.selected
|
||||
}
|
||||
@@ -681,14 +511,6 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues<this>) {
|
||||
super.willUpdate(changedProps);
|
||||
if (changedProps.has("trace")) {
|
||||
this.renderedNodes = {};
|
||||
this.trackedNodes = {};
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProps: PropertyValues<this>) {
|
||||
super.updated(changedProps);
|
||||
|
||||
@@ -709,61 +531,35 @@ export class HatScriptGraph extends LitElement {
|
||||
}
|
||||
|
||||
// If trace changed and we have no or an invalid selection, select first option.
|
||||
if (!this.selected || !(this.selected in this.trackedNodes)) {
|
||||
const firstNode = this.trackedNodes[Object.keys(this.trackedNodes)[0]];
|
||||
const tree = this._buildTree(this.trace);
|
||||
if (!this.selected || !tree.trackedNodes[this.selected]) {
|
||||
const firstNode = tree.firstTracked;
|
||||
if (firstNode) {
|
||||
fireEvent(this, "graph-node-selected", firstNode);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.trace) {
|
||||
const sortKeys = Object.keys(this.trace.trace);
|
||||
const keys = Object.keys(this.renderedNodes).sort(
|
||||
(a, b) => sortKeys.indexOf(a) - sortKeys.indexOf(b)
|
||||
);
|
||||
const sortedTrackedNodes = {};
|
||||
const sortedRenderedNodes = {};
|
||||
for (const key of keys) {
|
||||
sortedRenderedNodes[key] = this.renderedNodes[key];
|
||||
if (key in this.trackedNodes) {
|
||||
sortedTrackedNodes[key] = this.trackedNodes[key];
|
||||
}
|
||||
}
|
||||
this.renderedNodes = sortedRenderedNodes;
|
||||
this.trackedNodes = sortedTrackedNodes;
|
||||
}
|
||||
}
|
||||
|
||||
private _previousTrackedNode() {
|
||||
const nodes = Object.keys(this.trackedNodes);
|
||||
const prevIndex = nodes.indexOf(this.selected!) - 1;
|
||||
if (prevIndex >= 0) {
|
||||
fireEvent(
|
||||
this,
|
||||
"graph-node-selected",
|
||||
this.trackedNodes[nodes[prevIndex]]
|
||||
);
|
||||
const prev = this._buildTree(this.trace).previousTracked(this.selected!);
|
||||
if (prev) {
|
||||
fireEvent(this, "graph-node-selected", prev);
|
||||
}
|
||||
}
|
||||
|
||||
private _nextTrackedNode() {
|
||||
const nodes = Object.keys(this.trackedNodes);
|
||||
const nextIndex = nodes.indexOf(this.selected!) + 1;
|
||||
if (nextIndex < nodes.length) {
|
||||
fireEvent(
|
||||
this,
|
||||
"graph-node-selected",
|
||||
this.trackedNodes[nodes[nextIndex]]
|
||||
);
|
||||
const next = this._buildTree(this.trace).nextTracked(this.selected!);
|
||||
if (next) {
|
||||
fireEvent(this, "graph-node-selected", next);
|
||||
}
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
return css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
--stroke-clr: var(--stroke-color, var(--secondary-text-color));
|
||||
--active-clr: var(--active-color, var(--primary-color));
|
||||
--track-clr: var(--track-color, var(--accent-color));
|
||||
@@ -782,9 +578,10 @@ export class HatScriptGraph extends LitElement {
|
||||
--hat-graph-branch-height: ${BRANCH_HEIGHT}px;
|
||||
}
|
||||
.graph-scroll {
|
||||
flex: 1;
|
||||
grid-area: 1 / 1;
|
||||
overflow: auto;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
.graph-container {
|
||||
display: flex;
|
||||
@@ -795,6 +592,19 @@ export class HatScriptGraph extends LitElement {
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-area: 1 / 1;
|
||||
justify-self: end;
|
||||
align-self: start;
|
||||
margin-top: var(--ha-space-2);
|
||||
margin-inline-end: var(--ha-space-5);
|
||||
z-index: 1;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--card-background-color) 70%,
|
||||
transparent
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
border-radius: var(--ha-border-radius-pill);
|
||||
}
|
||||
.parent {
|
||||
margin-left: 8px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user