mirror of
https://github.com/home-assistant/frontend.git
synced 2026-09-25 22:40:24 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab41942cd1 |
@@ -118,8 +118,7 @@ For user-facing changes, establish the existing design context as part of fronte
|
||||
- Before reviewing a pull request, read its existing comments, reviews, and threads, including their status, resolver, and Copilot resolution reason when available.
|
||||
- Prioritise substantive human feedback, especially from authors marked `MEMBER`, and validate agent-generated feedback against the code and repository guidance.
|
||||
- Do not duplicate unresolved findings as new inline comments; reference any that still need action in the review summary. Treat resolved feedback as closed only when the resolution reason or surrounding discussion supports that outcome; otherwise validate it against the current code before suppressing it. Respect **Won't fix** and **Incorrect** reasons.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests that `ha-frontend-testing` calls for first.
|
||||
- Do not ask for new tests on visual components. If the visuals clearly changed and the PR has no screenshots or videos, suggest adding them instead.
|
||||
- Identify behavioral regressions, bugs, accessibility issues, and missing tests first.
|
||||
- Record the applicable UI/UX evidence for user-facing changes, whether or not further input is needed.
|
||||
- Keep style-only comments secondary unless they affect maintainability or user experience.
|
||||
- Prefer small, direct fixes over large refactors during review follow-up.
|
||||
|
||||
@@ -51,10 +51,10 @@ Managed app, demo, gallery, and E2E app workflows share one lifetime lock, so on
|
||||
|
||||
## When To Add Tests
|
||||
|
||||
- Write tests for code that computes something: data processing, utilities, config validation, and strategies.
|
||||
- Do not write rendering tests. This includes views, panels, and components whose text, styles, slots, or option defaults are checked, or that only put context and helper data into a template.
|
||||
- Do not try to cover every scenario, especially for behaviour that changes often.
|
||||
- If you are not sure a test is useful, describe it and what it would catch, and let the user decide.
|
||||
- Write tests for code that computes something: data processing, utility functions, config validation, and what happens when the user interacts with a component.
|
||||
- Do not write tests that check what a component looks like: its text, CSS classes, styles, or slots. Do not write tests that check the default value of an option.
|
||||
- A component that only takes data from contexts and helpers and puts it in a template does not need a test.
|
||||
- If you are not sure a test is useful, describe the test and what it would catch, and let the user decide.
|
||||
- Tests never talk to a real Home Assistant. Replace `callWS`, `callApi`, and the connection with fakes.
|
||||
|
||||
## Dev Servers
|
||||
|
||||
@@ -7,17 +7,13 @@ body:
|
||||
value: |
|
||||
Make sure you are running the [latest version of Home Assistant][releases] before reporting an issue.
|
||||
|
||||
Please read our [issue reporting guide][reporting-issues] before submitting a report.
|
||||
|
||||
If you have a feature or enhancement request for the frontend, please [start a discussion][fr] instead of creating an issue.
|
||||
|
||||
**Please do not report issues for custom cards.**
|
||||
|
||||
[fr]: https://github.com/orgs/home-assistant/discussions
|
||||
[releases]: https://github.com/home-assistant/home-assistant/releases
|
||||
[reporting-issues]: https://www.home-assistant.io/help/reporting_issues/
|
||||
- type: checkboxes
|
||||
id: checklist
|
||||
attributes:
|
||||
label: Checklist
|
||||
description: Please verify that you've followed these steps
|
||||
@@ -34,21 +30,18 @@ body:
|
||||
value: |
|
||||
## The problem
|
||||
- type: textarea
|
||||
id: problem
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe the issue you are experiencing
|
||||
description: Provide a clear and concise description of what the bug is.
|
||||
- type: textarea
|
||||
id: expected_behavior
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Describe the behavior you expected
|
||||
description: Describe what you expected to happen or it should look/behave.
|
||||
- type: textarea
|
||||
id: steps_to_reproduce
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
@@ -66,7 +59,6 @@ body:
|
||||
value: |
|
||||
## Environment
|
||||
- type: input
|
||||
id: core_version
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
@@ -75,21 +67,18 @@ body:
|
||||
description: >
|
||||
Can be found in: [Settings -> About](https://my.home-assistant.io/redirect/info/).
|
||||
- type: input
|
||||
id: last_working_version
|
||||
attributes:
|
||||
label: What was the last working version of Home Assistant Core?
|
||||
placeholder: core-
|
||||
description: >
|
||||
If known, otherwise leave blank.
|
||||
- type: input
|
||||
id: browser
|
||||
attributes:
|
||||
label: In which browser are you experiencing the issue?
|
||||
placeholder: Google Chrome 88.0.4324.150
|
||||
description: >
|
||||
Provide the full name and don't forget to add the version!
|
||||
- type: input
|
||||
id: os
|
||||
attributes:
|
||||
label: Which operating system are you using to run this browser?
|
||||
placeholder: macOS Big Sur (1.11)
|
||||
@@ -101,7 +90,6 @@ body:
|
||||
# Details
|
||||
|
||||
- type: textarea
|
||||
id: entity_state
|
||||
attributes:
|
||||
label: State of relevant entities
|
||||
description: >
|
||||
@@ -110,7 +98,6 @@ body:
|
||||
information in the Details view of the More info dialog.
|
||||
render: txt
|
||||
- type: textarea
|
||||
id: configuration
|
||||
attributes:
|
||||
label: Problem-relevant frontend configuration
|
||||
description: >
|
||||
@@ -120,7 +107,6 @@ body:
|
||||
passwords, private URLs and other credentials.
|
||||
render: yaml
|
||||
- type: textarea
|
||||
id: javascript_errors
|
||||
attributes:
|
||||
label: JavaScript errors shown in your browser console/inspector
|
||||
description: >
|
||||
@@ -128,7 +114,6 @@ body:
|
||||
browser console/inspector please provide them.
|
||||
render: txt
|
||||
- type: textarea
|
||||
id: additional_information
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: >
|
||||
|
||||
@@ -32,12 +32,12 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
build-mode: none
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0
|
||||
uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Verify version
|
||||
uses: home-assistant/actions/helpers/verify-version@58bff37c8947f690ace498be413a9b78d6f30f93 # master
|
||||
uses: home-assistant/actions/helpers/verify-version@a7c616ce81ccda50150bf1595786c71b1883fabb # master
|
||||
|
||||
- name: Setup Node and install
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,29 +12,19 @@ const CONNECTIONS: ModbusConnection[] = [
|
||||
{
|
||||
endpoint: ["serial", RS485_PORT],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
|
||||
},
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [1] },
|
||||
},
|
||||
// A second inverter behind a gateway that is not answering
|
||||
{
|
||||
endpoint: ["tcp", "modbus-gateway.local", 502],
|
||||
connected: false,
|
||||
source: "config_entry",
|
||||
units: { [FRONIUS_ENTRY_ID]: [2] },
|
||||
},
|
||||
// A YAML hub keeps a link of its own to a device an integration also talks to
|
||||
{
|
||||
endpoint: ["tcp", "192.168.1.42", 502],
|
||||
connected: true,
|
||||
source: "yaml",
|
||||
units: { modbus_hub: [10, 11] },
|
||||
},
|
||||
];
|
||||
|
||||
export const mockModbus = (hass: MockHomeAssistant) => {
|
||||
|
||||
@@ -13,7 +13,6 @@ const HARDWARE_INFO: HardwareInfo = {
|
||||
hassio_board_id: "green",
|
||||
manufacturer: "homeassistant",
|
||||
model: "green",
|
||||
revision: null,
|
||||
},
|
||||
dongle: null,
|
||||
config_entries: [],
|
||||
|
||||
@@ -32,11 +32,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
|
||||
detached: false,
|
||||
homeassistant: "2025.7.0",
|
||||
icon: false,
|
||||
installed: true,
|
||||
logo: false,
|
||||
repository: "d5369777",
|
||||
stage: "stable",
|
||||
state: "started",
|
||||
system_managed: false,
|
||||
update_available: false,
|
||||
url: "https://github.com/music-assistant/home-assistant-addon",
|
||||
version: "2.6.3",
|
||||
@@ -53,11 +53,11 @@ const DEMO_ADDONS: HassioAddonInfo[] = [
|
||||
detached: false,
|
||||
homeassistant: "2025.7.0",
|
||||
icon: false,
|
||||
installed: true,
|
||||
logo: false,
|
||||
repository: "5c53de3b",
|
||||
stage: "stable",
|
||||
state: "started",
|
||||
system_managed: false,
|
||||
update_available: false,
|
||||
url: "https://esphome.io/",
|
||||
version: "2025.7.3",
|
||||
@@ -114,14 +114,13 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
audio_output: null,
|
||||
audio: false,
|
||||
auth_api: false,
|
||||
auto_uart: false,
|
||||
auto_update: false,
|
||||
boot: "auto",
|
||||
boot_config: "auto",
|
||||
changelog: false,
|
||||
devices: [],
|
||||
devicetree: false,
|
||||
discovery: [],
|
||||
dns: [],
|
||||
docker_api: false,
|
||||
documentation: false,
|
||||
full_access: false,
|
||||
@@ -134,10 +133,8 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
host_ipc: false,
|
||||
host_network: false,
|
||||
host_pid: false,
|
||||
host_uts: false,
|
||||
ingress_entry: null,
|
||||
ingress_panel: false,
|
||||
ingress_port: null,
|
||||
ingress_url: null,
|
||||
ingress: false,
|
||||
ip_address: "172.30.33.2",
|
||||
@@ -151,16 +148,13 @@ const addonDetails = (addon: HassioAddonInfo): HassioAddonDetails => ({
|
||||
protected: true,
|
||||
rating: 6,
|
||||
schema: CONFIG_SCHEMAS[addon.slug] ?? null,
|
||||
services: [],
|
||||
services_role: [],
|
||||
signed: false,
|
||||
startup: "application",
|
||||
stdin: false,
|
||||
system_managed: false,
|
||||
system_managed_config_entry: null,
|
||||
translations: {},
|
||||
uart: false,
|
||||
udev: false,
|
||||
usb: false,
|
||||
video: false,
|
||||
watchdog: true,
|
||||
webui: null,
|
||||
});
|
||||
@@ -187,14 +181,11 @@ const ADDON_STATS: HassioStats = {
|
||||
blk_read: 12300000,
|
||||
blk_write: 4500000,
|
||||
cpu_percent: 1.4,
|
||||
cpu_system_usage: 1420000000000,
|
||||
cpu_usage: 19800000000,
|
||||
memory_limit: 3900000000,
|
||||
memory_percent: 4.2,
|
||||
memory_usage: 163000000,
|
||||
network_rx: 8900000,
|
||||
network_tx: 2300000,
|
||||
online_cpus: 4,
|
||||
};
|
||||
|
||||
export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
@@ -218,13 +209,9 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
wait_boot: 5,
|
||||
timezone: "Europe/Amsterdam",
|
||||
logging: "info",
|
||||
auto_update: true,
|
||||
country: "NL",
|
||||
debug: false,
|
||||
debug_block: false,
|
||||
detect_blocking_io: false,
|
||||
diagnostics: true,
|
||||
feature_flags: {},
|
||||
addons: DEMO_ADDONS as any,
|
||||
addons_repositories: [
|
||||
"https://github.com/music-assistant/home-assistant-addon",
|
||||
@@ -237,6 +224,22 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
if (msg.endpoint === "/addons") {
|
||||
const data: HassioAddonsInfo = {
|
||||
addons: DEMO_ADDONS,
|
||||
repositories: [
|
||||
{
|
||||
slug: "d5369777",
|
||||
name: "Music Assistant",
|
||||
source: "https://github.com/music-assistant/home-assistant-addon",
|
||||
url: "https://github.com/music-assistant/home-assistant-addon",
|
||||
maintainer: "Music Assistant",
|
||||
},
|
||||
{
|
||||
slug: "5c53de3b",
|
||||
name: "ESPHome",
|
||||
source: "https://github.com/esphome/home-assistant-addon",
|
||||
url: "https://esphome.io/",
|
||||
maintainer: "ESPHome",
|
||||
},
|
||||
],
|
||||
};
|
||||
return data;
|
||||
}
|
||||
@@ -261,7 +264,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
hostname: "homeassistant",
|
||||
logging: "info",
|
||||
machine: "green",
|
||||
machine_id: "0123456789abcdef0123456789abcdef",
|
||||
state: "running",
|
||||
operating_system: "Home Assistant OS 18.2",
|
||||
supervisor: "2026.07.1",
|
||||
@@ -299,7 +301,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
update_available: false,
|
||||
version: "18.2",
|
||||
version_latest: "18.2",
|
||||
version_pending: null,
|
||||
data_disk: "Home Assistant Green (mmcblk0)",
|
||||
};
|
||||
return data;
|
||||
@@ -333,9 +334,8 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
const data: NetworkInfo = {
|
||||
interfaces: [
|
||||
{
|
||||
connected: true,
|
||||
mac: "aa:bb:cc:dd:ee:ff",
|
||||
primary: true,
|
||||
privacy: false,
|
||||
interface: "eth0",
|
||||
enabled: true,
|
||||
type: "ethernet",
|
||||
@@ -354,18 +354,10 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
|
||||
gateway: "172.30.32.1",
|
||||
interface: "hassio",
|
||||
},
|
||||
host_internet: true,
|
||||
supervisor_internet: true,
|
||||
};
|
||||
return data;
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/ingress/panels") {
|
||||
// The navigation picker waits for this collection before it enables
|
||||
// itself, so it has to answer even when no add-on exposes a panel.
|
||||
return { panels: {} };
|
||||
}
|
||||
|
||||
if (msg.endpoint === "/store/reload") {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { customElement, property } from "lit/decorators";
|
||||
import "../../../src/components/ha-card";
|
||||
import "../../../src/dialogs/more-info/more-info-content";
|
||||
import "../../../src/state-summary/state-card-content";
|
||||
import "../ha-demo-options";
|
||||
import type { HomeAssistant } from "../../../src/types";
|
||||
import { computeShowNewMoreInfo } from "../../../src/dialogs/more-info/const";
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -170,6 +171,7 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -187,6 +189,7 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -204,6 +207,7 @@ export const basicTrace: DemoTrace = {
|
||||
},
|
||||
},
|
||||
running_script: false,
|
||||
limit: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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,7 +9,6 @@ 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";
|
||||
@@ -683,7 +682,10 @@ class HaGallery extends LitElement {
|
||||
formatEntityAttributeName: (_stateObj, attribute) => attribute,
|
||||
formatEntityAttributeValue: (stateObj, attribute, value) =>
|
||||
value != null ? value : (stateObj.attributes[attribute] ?? ""),
|
||||
formatEntityName: computeEntityNameDisplayWithoutContext,
|
||||
formatEntityName: (stateObj, type) =>
|
||||
typeof type === "string"
|
||||
? type
|
||||
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
|
||||
} as unknown as HomeAssistant;
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ 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,8 +21,6 @@ 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,
|
||||
@@ -295,72 +293,9 @@ 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;
|
||||
context?: Record<string, unknown>;
|
||||
})
|
||||
| null
|
||||
>;
|
||||
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
|
||||
}[] = [
|
||||
{
|
||||
name: "One of each",
|
||||
@@ -385,13 +320,6 @@ const SCHEMAS: {
|
||||
},
|
||||
duration: { name: "Duration", selector: { duration: {} } },
|
||||
app: { name: "App", selector: { app: {} } },
|
||||
serial_port: {
|
||||
name: "Serial port",
|
||||
selector: { serial_port: {} },
|
||||
// A config flow passes its own domain as context, which is what the
|
||||
// picker groups recommended and not recommended ports by
|
||||
context: { domain: "zha" },
|
||||
},
|
||||
number_box: {
|
||||
name: "Number Box",
|
||||
selector: {
|
||||
@@ -576,99 +504,6 @@ 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: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -770,98 +605,8 @@ 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) {
|
||||
@@ -1031,7 +776,6 @@ 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,8 +21,7 @@ const ENTITIES = [
|
||||
state: "on",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_color_modes: ["brightness"],
|
||||
color_mode: "brightness",
|
||||
supported_features: 1,
|
||||
brightness: 100,
|
||||
},
|
||||
},
|
||||
@@ -31,7 +30,7 @@ const ENTITIES = [
|
||||
state: "off",
|
||||
attributes: {
|
||||
friendly_name: "Dining Room",
|
||||
supported_color_modes: ["brightness"],
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -39,7 +38,7 @@ const ENTITIES = [
|
||||
state: "unavailable",
|
||||
attributes: {
|
||||
friendly_name: "Lost Light",
|
||||
supported_color_modes: ["brightness"],
|
||||
supported_features: 1,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -12,10 +12,7 @@ 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 {
|
||||
GridCardConfig,
|
||||
TileCardConfig,
|
||||
} from "../../../../src/panels/lovelace/cards/types";
|
||||
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
@@ -166,39 +163,6 @@ 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 = [
|
||||
@@ -368,50 +332,6 @@ 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: {
|
||||
@@ -498,7 +418,7 @@ const CONFIGS = [
|
||||
],
|
||||
},
|
||||
},
|
||||
] satisfies DemoCardConfig<TileCardConfig | GridCardConfig>[];
|
||||
] satisfies DemoCardConfig<TileCardConfig>[];
|
||||
|
||||
@customElement("demo-lovelace-tile-card")
|
||||
class DemoTile extends LitElement {
|
||||
|
||||
@@ -21,61 +21,6 @@ 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,7 +11,6 @@ import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
|
||||
const ALL_FEATURES =
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.STOP +
|
||||
LawnMowerEntityFeature.DOCK;
|
||||
|
||||
const ENTITIES = [
|
||||
@@ -50,14 +49,6 @@ 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,9 +1,8 @@
|
||||
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, query, state } from "lit/decorators";
|
||||
import { customElement, property, 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,
|
||||
@@ -35,6 +34,9 @@ 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;
|
||||
@@ -42,10 +44,6 @@ class LandingPageLogs extends LitElement {
|
||||
@query("#scroll-bottom-marker")
|
||||
private _scrollBottomMarkerElement?: HTMLElement;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc<LandingPageKeys>;
|
||||
|
||||
@state() private _show = false;
|
||||
|
||||
@state() private _scrolledToBottomController =
|
||||
@@ -65,12 +63,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>`
|
||||
@@ -82,14 +80,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>
|
||||
`
|
||||
@@ -117,7 +115,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,37 +1,35 @@
|
||||
import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consumeLocalize } from "../../../src/common/decorators/consume-context-entry";
|
||||
import { fireEvent } from "../../../src/common/dom/fire_event";
|
||||
import { type CSSResultGroup, LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type {
|
||||
LandingPageKeys,
|
||||
LocalizeFunc,
|
||||
} from "../../../src/common/translations/localize";
|
||||
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 "../../../src/components/ha-alert";
|
||||
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>
|
||||
`;
|
||||
}
|
||||
@@ -49,21 +47,19 @@ 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
|
||||
@@ -76,7 +72,7 @@ class LandingPageNetwork extends LitElement {
|
||||
.index=${key}
|
||||
.disabled=${!dnsPrimaryInterfaceNameservers}
|
||||
@click=${this._setDns}
|
||||
>${this._localize(translationKey)}</ha-button
|
||||
>${this.localize(translationKey)}</ha-button
|
||||
>`
|
||||
)}
|
||||
</div>
|
||||
@@ -122,12 +118,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,7 +9,6 @@ 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";
|
||||
@@ -29,7 +28,7 @@ const SCHEDULE_FETCH_NETWORK_INFO_SECONDS = 5;
|
||||
const SCHEDULE_FETCH_JOBS_INFO_SECONDS = 2;
|
||||
|
||||
@customElement("ha-landing-page")
|
||||
class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
class HaLandingPage extends LandingPageBaseElement {
|
||||
@property({ attribute: false }) public translationFragment = "landing-page";
|
||||
|
||||
@state() private _supervisorError = false;
|
||||
@@ -83,6 +82,7 @@ class HaLandingPage extends provideLiteI18nMixin(LandingPageBaseElement) {
|
||||
networkIssue || this._networkInfoError
|
||||
? html`
|
||||
<landing-page-network
|
||||
.localize=${this.localize}
|
||||
.networkInfo=${this._networkInfo}
|
||||
.error=${this._networkInfoError}
|
||||
@dns-set=${this._fetchSupervisorInfo}
|
||||
@@ -103,11 +103,13 @@ class HaLandingPage extends provideLiteI18nMixin(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">
|
||||
|
||||
+26
-24
@@ -39,7 +39,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "8.0.5",
|
||||
"@babel/runtime": "8.0.0",
|
||||
"@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.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",
|
||||
"@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",
|
||||
"@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.1",
|
||||
"@home-assistant/webawesome": "3.7.0-ha.0",
|
||||
"@lezer/highlight": "1.2.3",
|
||||
"@lit-labs/motion": "1.1.0",
|
||||
"@lit-labs/observers": "2.1.0",
|
||||
@@ -84,6 +84,7 @@
|
||||
"@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",
|
||||
@@ -103,20 +104,20 @@
|
||||
"echarts-extension-chart2music": "0.1.1",
|
||||
"element-internals-polyfill": "3.0.2",
|
||||
"fuse.js": "7.5.0",
|
||||
"hls.js": "1.7.3",
|
||||
"hls.js": "1.7.2",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.3.0",
|
||||
"intl-messageformat": "11.2.15",
|
||||
"intl-messageformat": "11.2.14",
|
||||
"js-yaml": "5.4.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
"leaflet.markercluster": "1.5.3",
|
||||
"lit": "3.3.3",
|
||||
"lit-html": "3.3.3",
|
||||
"luxon": "3.7.2",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"marked": "18.0.12",
|
||||
"marked": "18.0.11",
|
||||
"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 +140,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@babel/core": "8.0.5",
|
||||
"@babel/core": "8.0.1",
|
||||
"@babel/helper-define-polyfill-provider": "1.0.0",
|
||||
"@babel/plugin-transform-runtime": "8.0.1",
|
||||
"@babel/preset-env": "8.0.5",
|
||||
"@babel/preset-env": "8.0.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.3",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@gfx/zopfli": "1.0.15",
|
||||
@@ -152,8 +153,8 @@
|
||||
"@octokit/plugin-retry": "8.1.1",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@rsdoctor/rspack-plugin": "1.6.4",
|
||||
"@rspack/core": "2.2.3",
|
||||
"@rsdoctor/rspack-plugin": "1.6.3",
|
||||
"@rspack/core": "2.2.2",
|
||||
"@rspack/dev-server": "2.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
"@types/chromecast-caf-receiver": "6.0.26",
|
||||
@@ -162,6 +163,7 @@
|
||||
"@types/culori": "4.0.1",
|
||||
"@types/html-minifier-terser": "7.0.2",
|
||||
"@types/leaflet": "1.9.22",
|
||||
"@types/leaflet-draw": "1.0.13",
|
||||
"@types/leaflet.markercluster": "1.5.6",
|
||||
"@types/lodash.merge": "4.6.9",
|
||||
"@types/luxon": "3.7.5",
|
||||
@@ -195,10 +197,10 @@
|
||||
"html-minifier-terser": "7.2.0",
|
||||
"husky": "9.1.7",
|
||||
"jsdom": "30.0.1",
|
||||
"jszip": "3.10.2",
|
||||
"jszip": "3.10.1",
|
||||
"license-checker-rseidelsohn": "5.0.1",
|
||||
"lightningcss": "1.33.0",
|
||||
"lint-staged": "17.5.1",
|
||||
"lint-staged": "17.5.0",
|
||||
"lit-analyzer": "2.0.3",
|
||||
"lodash.merge": "4.6.2",
|
||||
"lodash.template": "4.18.1",
|
||||
@@ -213,8 +215,8 @@
|
||||
"terser-webpack-plugin": "5.6.1",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.0",
|
||||
"vite": "8.3.0",
|
||||
"typescript-eslint": "8.69.0",
|
||||
"vite": "8.2.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "5.0.0",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -232,6 +234,6 @@
|
||||
},
|
||||
"packageManager": "[email protected]",
|
||||
"volta": {
|
||||
"node": "24.21.0"
|
||||
"node": "24.20.0"
|
||||
}
|
||||
}
|
||||
|
||||
+18
-20
@@ -23,7 +23,6 @@ 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";
|
||||
|
||||
@@ -37,14 +36,12 @@ 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";
|
||||
@@ -187,8 +184,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>
|
||||
@@ -196,20 +193,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:
|
||||
@@ -221,8 +218,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}`
|
||||
)}
|
||||
`;
|
||||
@@ -231,14 +228,15 @@ 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}
|
||||
@@ -258,7 +256,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>
|
||||
`
|
||||
: ""
|
||||
@@ -268,7 +266,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>
|
||||
`;
|
||||
@@ -338,13 +336,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}`
|
||||
);
|
||||
}
|
||||
@@ -352,13 +350,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,7 +1,6 @@
|
||||
/* eslint-disable lit/prefer-static-styles */
|
||||
import { html } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import { HaForm } from "../components/ha-form/ha-form";
|
||||
import "./ha-auth-form-string";
|
||||
@@ -10,13 +9,11 @@ const localizeBaseKey = "ui.panel.page-authorize.form";
|
||||
|
||||
@customElement("ha-auth-form")
|
||||
export class HaAuthForm extends HaForm {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
protected getFormProperties(): Record<string, any> {
|
||||
return {
|
||||
localize: this._localize,
|
||||
localize: this.localize,
|
||||
localizeBaseKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
|
||||
@@ -24,9 +23,7 @@ const appNames = {
|
||||
};
|
||||
|
||||
@customElement("ha-authorize")
|
||||
export class HaAuthorize extends provideLiteI18nMixin(
|
||||
litLocalizeLiteMixin(LitElement)
|
||||
) {
|
||||
export class HaAuthorize extends litLocalizeLiteMixin(LitElement) {
|
||||
@property({ attribute: false }) public clientId?: string;
|
||||
|
||||
@property({ attribute: false }) public redirectUri?: string;
|
||||
@@ -186,12 +183,14 @@ export class HaAuthorize extends provideLiteI18nMixin(
|
||||
.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,6 +1,5 @@
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import "../components/ha-icon-next";
|
||||
@@ -21,15 +20,13 @@ declare global {
|
||||
export class HaPickAuthProvider extends LitElement {
|
||||
@property({ attribute: false }) public authProviders: AuthProvider[] = [];
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public 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,34 +9,36 @@ export const createDurationData = (
|
||||
}
|
||||
if (typeof duration !== "object") {
|
||||
if (typeof duration === "string" || isNaN(duration)) {
|
||||
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)
|
||||
);
|
||||
|
||||
const parts = duration?.toString().split(":") || [];
|
||||
if (parts.length === 1) {
|
||||
return { seconds: parts[0] };
|
||||
return { seconds: Number(parts[0]) };
|
||||
}
|
||||
if (parts.length > 3) {
|
||||
return undefined;
|
||||
}
|
||||
const seconds = parts[2] || 0;
|
||||
const secondsWhole = Math.trunc(seconds);
|
||||
const seconds = Number(parts[2]) || 0;
|
||||
const seconds_whole = Math.floor(seconds);
|
||||
return {
|
||||
hours: parts[0] || 0,
|
||||
minutes: parts[1] || 0,
|
||||
seconds: secondsWhole,
|
||||
milliseconds: Math.trunc(
|
||||
Number((seconds - secondsWhole).toFixed(4)) * 1000
|
||||
hours: Number(parts[0]) || 0,
|
||||
minutes: Number(parts[1]) || 0,
|
||||
seconds: seconds_whole,
|
||||
milliseconds: Math.floor(
|
||||
Number((seconds - seconds_whole).toFixed(4)) * 1000
|
||||
),
|
||||
};
|
||||
}
|
||||
return { seconds: duration };
|
||||
}
|
||||
return 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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1 @@
|
||||
export const stopPropagation = (ev) => ev.stopPropagation();
|
||||
|
||||
export const stopKeydownEnterSpacePropagation = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Enter" || ev.key === " " || ev.key === "Spacebar") {
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,70 +1,33 @@
|
||||
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 {
|
||||
computeEntityEntryName,
|
||||
computeEntityName,
|
||||
entityUseDeviceName,
|
||||
} from "./compute_entity_name";
|
||||
import { computeEntityName, entityUseDeviceName } from "./compute_entity_name";
|
||||
import { computeFloorName } from "./compute_floor_name";
|
||||
import { computeStateName } from "./compute_state_name";
|
||||
import {
|
||||
getEntityContext,
|
||||
getEntityEntryContext,
|
||||
} from "./context/get_entity_context";
|
||||
import type { EntityNameItem, EntityNameOptions } from "./entity_name_config";
|
||||
import { getEntityContext } from "./context/get_entity_context";
|
||||
|
||||
const DEFAULT_SEPARATOR = " ";
|
||||
|
||||
export { DEFAULT_ENTITY_NAME, ENTITY_NAME_TYPES } from "./entity_name_config";
|
||||
export type {
|
||||
EntityNameItem,
|
||||
EntityNameOptions,
|
||||
EntityNameType,
|
||||
} from "./entity_name_config";
|
||||
export const DEFAULT_ENTITY_NAME = [
|
||||
{ type: "device" },
|
||||
{ type: "entity" },
|
||||
] satisfies EntityNameItem[];
|
||||
|
||||
// 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 EntityNameItem =
|
||||
| {
|
||||
type: "entity" | "device" | "area" | "floor";
|
||||
}
|
||||
| {
|
||||
type: "text";
|
||||
text: 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 interface EntityNameOptions {
|
||||
separator?: string;
|
||||
}
|
||||
|
||||
export const computeEntityNameDisplay = (
|
||||
stateObj: HassEntity,
|
||||
@@ -89,9 +52,8 @@ export const computeEntityNameDisplay = (
|
||||
const separator = options?.separator ?? DEFAULT_SEPARATOR;
|
||||
|
||||
// If all items are text, just join them
|
||||
const textOnlyName = computeTextOnlyName(items, options);
|
||||
if (textOnlyName !== undefined) {
|
||||
return textOnlyName;
|
||||
if (items.every((n) => n.type === "text")) {
|
||||
return items.map((item) => item.text).join(separator);
|
||||
}
|
||||
|
||||
const useDeviceName = entityUseDeviceName(stateObj, entities, devices);
|
||||
@@ -129,53 +91,20 @@ export const computeEntityNameList = (
|
||||
areas: HomeAssistant["areas"],
|
||||
floors: HomeAssistant["floors"]
|
||||
): (string | undefined)[] => {
|
||||
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,
|
||||
const { device, area, floor } = getEntityContext(
|
||||
stateObj,
|
||||
entities,
|
||||
devices,
|
||||
areas,
|
||||
floors
|
||||
);
|
||||
|
||||
return name.map((item) => {
|
||||
const names = name.map((item) => {
|
||||
switch (item.type) {
|
||||
case "entity":
|
||||
return computeEntityEntryName(entry, devices);
|
||||
return computeEntityName(stateObj, entities, devices);
|
||||
case "device":
|
||||
return device ? computeDeviceName(device) : undefined;
|
||||
case "parent_device":
|
||||
return parentDevice ? computeDeviceName(parentDevice) : undefined;
|
||||
case "area":
|
||||
return area ? computeAreaName(area) : undefined;
|
||||
case "floor":
|
||||
@@ -186,29 +115,8 @@ export const computeEntityEntryNameList = (
|
||||
return "";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
return names;
|
||||
};
|
||||
|
||||
export interface EntityPickerDisplay {
|
||||
@@ -228,20 +136,14 @@ export const computeEntityPickerDisplay = (
|
||||
>,
|
||||
stateObj: HassEntity
|
||||
): EntityPickerDisplay => {
|
||||
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 [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
@@ -250,7 +152,7 @@ export const computeEntityPickerDisplay = (
|
||||
|
||||
const primary = entityName || deviceName || stateObj.entity_id;
|
||||
const secondary =
|
||||
[areaName, parentDeviceName, entityName ? deviceName : undefined]
|
||||
[areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ") || undefined;
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import { getDeviceAreaId } from "./get_device_context";
|
||||
interface EntityContext {
|
||||
entity: EntityRegistryDisplayEntry | null;
|
||||
device: DeviceRegistryEntry | null;
|
||||
parentDevice: DeviceRegistryEntry | null;
|
||||
area: AreaRegistryEntry | null;
|
||||
floor: FloorRegistryEntry | null;
|
||||
}
|
||||
@@ -32,7 +31,6 @@ export const getEntityContext = (
|
||||
return {
|
||||
entity: null,
|
||||
device: null,
|
||||
parentDevice: null,
|
||||
area: null,
|
||||
floor: null,
|
||||
};
|
||||
@@ -67,9 +65,6 @@ export const getEntityEntryContext = (
|
||||
const entity = entities[entry.entity_id];
|
||||
const deviceId = entry?.device_id;
|
||||
const device = deviceId ? devices[deviceId] : undefined;
|
||||
const parentDevice = device?.parent_device_id
|
||||
? devices[device.parent_device_id]
|
||||
: undefined;
|
||||
const areaId =
|
||||
entry?.area_id || (device ? getDeviceAreaId(device, devices) : undefined);
|
||||
const area = areaId ? areas[areaId] : undefined;
|
||||
@@ -79,7 +74,6 @@ export const getEntityEntryContext = (
|
||||
return {
|
||||
entity: entity,
|
||||
device: device || null,
|
||||
parentDevice: parentDevice || null,
|
||||
area: area || null,
|
||||
floor: floor || null,
|
||||
};
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 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", "idle"],
|
||||
lawn_mower: ["error", "paused", "mowing", "returning", "docked"],
|
||||
light: ["on", "off"],
|
||||
lock: [
|
||||
"jammed",
|
||||
@@ -95,7 +95,6 @@ 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", "idle"].includes(compareState);
|
||||
return !["docked", "paused"].includes(compareState);
|
||||
case "lock":
|
||||
return compareState !== "locked";
|
||||
case "media_player":
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
/** Handle DOM and styles of the editable circle (MapEngine.addEditableCircle) */
|
||||
|
||||
/** Hit target for the radius handle; comfortably above touch minimums */
|
||||
export const RESIZE_HANDLE_SIZE = 24;
|
||||
|
||||
/** The visible dot inside the hit target */
|
||||
export const RESIZE_HANDLE_DOT_SIZE = 12;
|
||||
|
||||
/** Relative radius change per arrow key press on the handle */
|
||||
export const RESIZE_KEY_STEP = 0.1;
|
||||
|
||||
/** Advertised slider maximum; a larger radius raises it (see the engine) */
|
||||
export const RADIUS_ARIA_MAX = 100000;
|
||||
|
||||
export const createResizeHandleElement = (label?: string): HTMLElement => {
|
||||
const element = document.createElement("div");
|
||||
element.className = "editable-circle-resize";
|
||||
element.tabIndex = 0;
|
||||
element.setAttribute("role", "slider");
|
||||
element.setAttribute("aria-valuemin", "1");
|
||||
element.setAttribute("aria-valuemax", String(RADIUS_ARIA_MAX));
|
||||
if (label) {
|
||||
element.setAttribute("aria-label", label);
|
||||
}
|
||||
const dot = document.createElement("div");
|
||||
dot.className = "editable-circle-resize-dot";
|
||||
element.appendChild(dot);
|
||||
return element;
|
||||
};
|
||||
|
||||
/** Styles for the handles, included by ha-map for both engines */
|
||||
export const editableCircleStyles = `
|
||||
.editable-circle-center {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color);
|
||||
border: 2px solid var(--card-background-color, #fff);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
.editable-circle-resize {
|
||||
width: ${RESIZE_HANDLE_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_SIZE}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
}
|
||||
.editable-circle-resize-dot {
|
||||
width: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
height: ${RESIZE_HANDLE_DOT_SIZE}px;
|
||||
border-radius: 50%;
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 2px solid var(--primary-color);
|
||||
box-sizing: border-box;
|
||||
box-shadow: var(--ha-box-shadow-s);
|
||||
}
|
||||
`;
|
||||
@@ -34,9 +34,17 @@ interface LeafletMarkerHandle extends MapMarkerHandle {
|
||||
marker: HandledMarker;
|
||||
}
|
||||
|
||||
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
|
||||
/**
|
||||
* The Leaflet implementation of MapEngine. Renders vector tiles through the
|
||||
* maplibre-gl-leaflet adapter when WebGL2 is available and raster tiles
|
||||
* otherwise (see createBaseLayer), so it is both the non-WebGL2 fallback and
|
||||
* the engine ha-locations-editor requires for leaflet-draw.
|
||||
*/
|
||||
export class LeafletMapEngine implements MapEngine {
|
||||
/** For the ha-map jsdom tests only */
|
||||
/**
|
||||
* Escape hatch for ha-locations-editor, which manages its own Leaflet
|
||||
* layers (leaflet-draw). Not for use anywhere else.
|
||||
*/
|
||||
public leafletMap?: Map;
|
||||
|
||||
public Leaflet?: LeafletModuleType;
|
||||
@@ -186,22 +194,9 @@ 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,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
public panTo(location: MapLatLng): void {
|
||||
this.leafletMap?.panTo(location);
|
||||
}
|
||||
|
||||
public containsLocation(location: MapLatLng): boolean {
|
||||
return this.leafletMap?.getBounds().contains(location) ?? false;
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
@@ -218,8 +213,10 @@ export class LeafletMapEngine implements MapEngine {
|
||||
// Leaflet's keyboard support focuses its own wrapper, where the element's
|
||||
// activation handlers never hear a key; the element itself takes focus
|
||||
const interactive = options.interactive ?? true;
|
||||
const focusable = options.focusable ?? interactive;
|
||||
setMarkerAccessibility(element, options.title, focusable);
|
||||
if (interactive) {
|
||||
element.tabIndex = 0;
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, interactive);
|
||||
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
|
||||
icon: this.Leaflet!.divIcon({
|
||||
html: element,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Feature, FeatureCollection, Polygon } from "geojson";
|
||||
import type {
|
||||
GeoJSONSource,
|
||||
IControl,
|
||||
LayerSpecification,
|
||||
Map as MapLibreMap,
|
||||
@@ -10,10 +9,7 @@ import type {
|
||||
StyleSpecification,
|
||||
} from "maplibre-gl";
|
||||
import type maplibregl from "maplibre-gl";
|
||||
import {
|
||||
clearMarkerAccessibility,
|
||||
setMarkerAccessibility,
|
||||
} from "../marker-accessibility";
|
||||
import { setMarkerAccessibility } from "../marker-accessibility";
|
||||
import {
|
||||
CONTEXT_RESTORE_GRACE,
|
||||
ensureRTLTextPlugin,
|
||||
@@ -33,27 +29,16 @@ import type {
|
||||
MapCircleOptions,
|
||||
MapClusterOptions,
|
||||
MapControlPosition,
|
||||
MapDraggableMarkerOptions,
|
||||
MapEditableCircleHandle,
|
||||
MapEditableCircleOptions,
|
||||
MapEditableMarkerHandle,
|
||||
MapEditingSupport,
|
||||
MapEngine,
|
||||
MapMarkerHandle,
|
||||
MapEngineEvents,
|
||||
MapEngineOptions,
|
||||
MapFitOptions,
|
||||
MapItemHandle,
|
||||
MapLatLng,
|
||||
MapMarkerHandle,
|
||||
MapMarkerOptions,
|
||||
MapPath,
|
||||
} from "../map-engine";
|
||||
import { destinationPoint, distanceMeters, pointEastOf } from "../map-engine";
|
||||
import {
|
||||
createResizeHandleElement,
|
||||
RADIUS_ARIA_MAX,
|
||||
RESIZE_KEY_STEP,
|
||||
} from "../editable-circle";
|
||||
|
||||
type MapLibreModule = typeof maplibregl;
|
||||
|
||||
@@ -94,7 +79,6 @@ const resetMarkerElement = (element: HTMLElement): void => {
|
||||
element.style.transform = "";
|
||||
element.style.opacity = "";
|
||||
element.style.pointerEvents = "";
|
||||
element.style.cursor = "";
|
||||
};
|
||||
|
||||
// A meter-radius circle as a polygon (spherical approximation)
|
||||
@@ -103,10 +87,16 @@ const circlePolygon = (
|
||||
radiusMeters: number
|
||||
): Feature<Polygon> => {
|
||||
const steps = 64;
|
||||
const latOffset = radiusMeters / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
|
||||
const ring: [number, number][] = [];
|
||||
for (let i = 0; i <= steps; i++) {
|
||||
const point = destinationPoint(center, radiusMeters, (360 * i) / steps);
|
||||
ring.push([point[1], point[0]]);
|
||||
const theta = (2 * Math.PI * i) / steps;
|
||||
ring.push([
|
||||
center[1] + lngOffset * Math.sin(theta),
|
||||
center[0] + latOffset * Math.cos(theta),
|
||||
]);
|
||||
}
|
||||
return {
|
||||
type: "Feature",
|
||||
@@ -119,10 +109,7 @@ interface ManagedMarker {
|
||||
element: HTMLElement;
|
||||
location: MapLatLng;
|
||||
options: MapMarkerOptions;
|
||||
handle: MapEditableMarkerHandle;
|
||||
draggable?: boolean;
|
||||
onDragEnd?: (location: MapLatLng) => void;
|
||||
dragging?: boolean;
|
||||
handle: MapMarkerHandle;
|
||||
mlMarker?: MapLibreMarker;
|
||||
decoration?: MapItemHandle;
|
||||
removed?: boolean;
|
||||
@@ -131,8 +118,6 @@ 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;
|
||||
@@ -198,10 +183,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
private _settleInit?: () => void;
|
||||
|
||||
// Caller-owned elements on the map, reset when they leave it: a host may
|
||||
// reuse them on another engine after a fallback
|
||||
private _placedElements = new Set<HTMLElement>();
|
||||
|
||||
public async init(
|
||||
container: HTMLElement,
|
||||
options: MapEngineOptions
|
||||
@@ -367,11 +348,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
this._clusterGroups = [];
|
||||
this._markers = [];
|
||||
this._pendingStyleOps = [];
|
||||
this._placedElements.forEach((element) => {
|
||||
resetMarkerElement(element);
|
||||
clearMarkerAccessibility(element);
|
||||
});
|
||||
this._placedElements.clear();
|
||||
this._map?.remove();
|
||||
this._map = undefined;
|
||||
}
|
||||
@@ -435,14 +411,12 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
}
|
||||
|
||||
private _carryCustomLayers(
|
||||
_previous: StyleSpecification | undefined,
|
||||
previous: StyleSpecification | undefined,
|
||||
next: StyleSpecification
|
||||
): StyleSpecification {
|
||||
const sources = { ...next.sources };
|
||||
// Our record, not MapLibre's serialization: an update made while the
|
||||
// style was unloaded only reached the record
|
||||
for (const [id, source] of this._customSources) {
|
||||
sources[id] = source;
|
||||
sources[id] = previous?.sources?.[id] ?? source;
|
||||
}
|
||||
const nextIds = new Set(next.layers.map((layer) => layer.id));
|
||||
const customLayers = [...this._customLayers.values()].filter(
|
||||
@@ -518,26 +492,13 @@ 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, keeping the zoom unless given
|
||||
this._map.fitBounds(
|
||||
[
|
||||
[minLng, minLat],
|
||||
[minLng, minLat],
|
||||
],
|
||||
{
|
||||
maxZoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
padding,
|
||||
}
|
||||
);
|
||||
// Zero-area bounds: center on the point
|
||||
this._map.easeTo({
|
||||
center: [minLng, minLat],
|
||||
zoom: maxZoom ?? this._map.getZoom(),
|
||||
animate: options?.animate,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pad = options?.pad ?? 0.5;
|
||||
@@ -548,86 +509,40 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
[minLng - lngPad, minLat - latPad],
|
||||
[maxLng + lngPad, maxLat + latPad],
|
||||
],
|
||||
{ maxZoom, animate: options?.animate, padding }
|
||||
{ maxZoom, animate: options?.animate }
|
||||
);
|
||||
}
|
||||
|
||||
public panTo(location: MapLatLng): void {
|
||||
this._map?.panTo([location[1], location[0]]);
|
||||
}
|
||||
|
||||
public containsLocation(location: MapLatLng): boolean {
|
||||
return this._map?.getBounds().contains([location[1], location[0]]) ?? false;
|
||||
}
|
||||
|
||||
public addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapMarkerOptions
|
||||
): MapMarkerHandle {
|
||||
return this._addMarker(element, location, options);
|
||||
}
|
||||
|
||||
public editing: MapEditingSupport = {
|
||||
addDraggableMarker: (element, location, options) =>
|
||||
this._addMarker(element, location, options, true),
|
||||
addEditableCircle: (center, options) =>
|
||||
this._addEditableCircle(center, options),
|
||||
};
|
||||
|
||||
private _addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapDraggableMarkerOptions,
|
||||
draggable = false
|
||||
): MapEditableMarkerHandle {
|
||||
element.style.width = `${options.size[0]}px`;
|
||||
element.style.height = `${options.size[1]}px`;
|
||||
if (options.title) {
|
||||
element.title = options.title;
|
||||
}
|
||||
const interactive = options.interactive ?? true;
|
||||
const focusable = options.focusable ?? interactive;
|
||||
if (!interactive) {
|
||||
if (options.interactive ?? true) {
|
||||
element.tabIndex = 0;
|
||||
} else {
|
||||
// Leaflet lets input through non-interactive markers; MapLibre does not
|
||||
element.style.pointerEvents = "none";
|
||||
}
|
||||
setMarkerAccessibility(element, options.title, focusable);
|
||||
if (draggable) {
|
||||
// The engine, not the host, knows whether this element really drags
|
||||
element.style.cursor = "move";
|
||||
}
|
||||
this._placedElements.add(element);
|
||||
setMarkerAccessibility(element, options.title, options.interactive ?? true);
|
||||
|
||||
const managed: ManagedMarker = {
|
||||
element,
|
||||
location,
|
||||
options,
|
||||
draggable,
|
||||
onDragEnd: options.onDragEnd,
|
||||
handle: undefined as unknown as MapEditableMarkerHandle,
|
||||
handle: undefined as unknown as MapMarkerHandle,
|
||||
};
|
||||
managed.handle = {
|
||||
get location() {
|
||||
return managed.location;
|
||||
},
|
||||
location,
|
||||
clusterData: options.clusterData,
|
||||
setLocation: (newLocation) => {
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
if (managed.dragging) {
|
||||
return;
|
||||
}
|
||||
managed.location = newLocation;
|
||||
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
|
||||
},
|
||||
remove: () => {
|
||||
managed.removed = true;
|
||||
this._hideMarker(managed);
|
||||
// Still inside an open cluster bubble otherwise
|
||||
element.remove();
|
||||
resetMarkerElement(element);
|
||||
clearMarkerAccessibility(element);
|
||||
this._placedElements.delete(element);
|
||||
const index = this._markers.indexOf(managed);
|
||||
if (index !== -1) {
|
||||
this._markers.splice(index, 1);
|
||||
@@ -651,7 +566,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const { options } = managed;
|
||||
managed.mlMarker = new this._maplibre.Marker({
|
||||
element: managed.element,
|
||||
draggable: managed.draggable ?? false,
|
||||
...(options.anchor
|
||||
? {
|
||||
anchor: "top-left" as const,
|
||||
@@ -664,17 +578,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
})
|
||||
.setLngLat([managed.location[1], managed.location[0]])
|
||||
.addTo(this._map);
|
||||
if (managed.draggable) {
|
||||
managed.mlMarker.on("dragstart", () => {
|
||||
managed.dragging = true;
|
||||
});
|
||||
managed.mlMarker.on("dragend", () => {
|
||||
managed.dragging = false;
|
||||
const lngLat = managed.mlMarker!.getLngLat();
|
||||
managed.location = [lngLat.lat, lngLat.lng];
|
||||
managed.onDragEnd?.(managed.location);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (managed.options.decoration && !managed.decoration) {
|
||||
managed.decoration = this.addCircle(
|
||||
@@ -721,243 +624,6 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
};
|
||||
}
|
||||
|
||||
private _addEditableCircle(
|
||||
center: MapLatLng,
|
||||
options: MapEditableCircleOptions
|
||||
): MapEditableCircleHandle {
|
||||
if (!this._map || !this._maplibre) {
|
||||
return {
|
||||
center,
|
||||
radius: options.radius,
|
||||
update: () => undefined,
|
||||
remove: () => undefined,
|
||||
};
|
||||
}
|
||||
const map = this._map;
|
||||
const maplibre = this._maplibre;
|
||||
let currentCenter = center;
|
||||
let currentRadius = options.radius;
|
||||
|
||||
const id = `${CUSTOM_PREFIX}editable-${this._idCounter++}`;
|
||||
this._addCustomSource(id, circlePolygon(center, options.radius));
|
||||
this._addCustomLayer({
|
||||
id: `${id}-fill`,
|
||||
type: "fill",
|
||||
source: id,
|
||||
paint: { "fill-color": options.color, "fill-opacity": 0.2 },
|
||||
});
|
||||
this._addCustomLayer({
|
||||
id: `${id}-line`,
|
||||
type: "line",
|
||||
source: id,
|
||||
paint: { "line-color": options.color, "line-width": 3 },
|
||||
});
|
||||
// Redraw at most once per frame while dragging
|
||||
let frame: number | undefined;
|
||||
const redraw = () => {
|
||||
if (frame !== undefined) {
|
||||
return;
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = undefined;
|
||||
this._setCustomSourceData(
|
||||
id,
|
||||
circlePolygon(currentCenter, currentRadius)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const centerSize = options.centerSize ?? [16, 16];
|
||||
const centerEl = options.centerElement ?? document.createElement("div");
|
||||
if (!options.centerElement) {
|
||||
centerEl.className = "editable-circle-center";
|
||||
}
|
||||
centerEl.style.width = `${centerSize[0]}px`;
|
||||
centerEl.style.height = `${centerSize[1]}px`;
|
||||
if (options.title) {
|
||||
centerEl.title = options.title;
|
||||
}
|
||||
setMarkerAccessibility(centerEl, options.title, !!options.onClick);
|
||||
if (options.moveable) {
|
||||
centerEl.style.cursor = "move";
|
||||
}
|
||||
this._placedElements.add(centerEl);
|
||||
const centerMarker = new maplibre.Marker({
|
||||
element: centerEl,
|
||||
draggable: options.moveable ?? false,
|
||||
})
|
||||
.setLngLat([center[1], center[0]])
|
||||
.addTo(map);
|
||||
|
||||
let resizeMarker: MapLibreMarker | undefined;
|
||||
let resizeHandle: HTMLElement | undefined;
|
||||
const placeResizeHandle = () => {
|
||||
const east = pointEastOf(currentCenter, currentRadius);
|
||||
resizeMarker?.setLngLat([east[1], east[0]]);
|
||||
const radiusText = String(Math.round(currentRadius));
|
||||
resizeHandle?.setAttribute(
|
||||
"aria-valuemax",
|
||||
String(Math.max(RADIUS_ARIA_MAX, Math.round(currentRadius)))
|
||||
);
|
||||
resizeHandle?.setAttribute("aria-valuenow", radiusText);
|
||||
// Without a value text the value is read as a percentage of the range
|
||||
resizeHandle?.setAttribute("aria-valuetext", radiusText);
|
||||
};
|
||||
|
||||
// The user's hand wins while dragging; the host is the truth otherwise
|
||||
let dragging = false;
|
||||
|
||||
if (options.resizable) {
|
||||
const east = pointEastOf(center, options.radius);
|
||||
resizeHandle = createResizeHandleElement(options.resizeLabel);
|
||||
resizeMarker = new maplibre.Marker({
|
||||
element: resizeHandle,
|
||||
draggable: true,
|
||||
})
|
||||
.setLngLat([east[1], east[0]])
|
||||
.addTo(map);
|
||||
placeResizeHandle();
|
||||
resizeMarker.on("drag", () => {
|
||||
const lngLat = resizeMarker!.getLngLat();
|
||||
currentRadius = Math.max(
|
||||
1,
|
||||
distanceMeters(currentCenter, [lngLat.lat, lngLat.lng])
|
||||
);
|
||||
redraw();
|
||||
});
|
||||
resizeMarker.on("dragend", () => {
|
||||
// Snap the handle back onto the east edge
|
||||
placeResizeHandle();
|
||||
options.onResize?.(currentRadius);
|
||||
});
|
||||
// Arrow keys resize in steps, committed on key release
|
||||
let keyboardRadius: number | undefined;
|
||||
resizeHandle.addEventListener("keydown", (ev) => {
|
||||
const direction =
|
||||
ev.key === "ArrowRight" || ev.key === "ArrowUp"
|
||||
? 1
|
||||
: ev.key === "ArrowLeft" || ev.key === "ArrowDown"
|
||||
? -1
|
||||
: 0;
|
||||
if (!direction) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
// MapLibre pans on arrow keys reaching the map
|
||||
ev.stopPropagation();
|
||||
if (keyboardRadius === undefined) {
|
||||
// Host updates treat a key resize like a drag
|
||||
dragging = true;
|
||||
}
|
||||
currentRadius = Math.max(
|
||||
1,
|
||||
currentRadius * (1 + direction * RESIZE_KEY_STEP)
|
||||
);
|
||||
keyboardRadius = currentRadius;
|
||||
placeResizeHandle();
|
||||
redraw();
|
||||
});
|
||||
const commitKeyboardResize = () => {
|
||||
if (keyboardRadius !== undefined) {
|
||||
keyboardRadius = undefined;
|
||||
dragging = false;
|
||||
options.onResize?.(currentRadius);
|
||||
}
|
||||
};
|
||||
resizeHandle.addEventListener("keyup", commitKeyboardResize);
|
||||
// Focus can leave while a key is still held
|
||||
resizeHandle.addEventListener("blur", commitKeyboardResize);
|
||||
// Like the other markers: a click on the handle is not a map click
|
||||
resizeHandle.addEventListener("click", (ev) => ev.stopPropagation());
|
||||
}
|
||||
|
||||
[centerMarker, resizeMarker].forEach((handleMarker) => {
|
||||
handleMarker?.on("dragstart", () => {
|
||||
dragging = true;
|
||||
});
|
||||
handleMarker?.on("dragend", () => {
|
||||
dragging = false;
|
||||
});
|
||||
});
|
||||
|
||||
if (options.moveable) {
|
||||
centerMarker.on("drag", () => {
|
||||
const lngLat = centerMarker.getLngLat();
|
||||
currentCenter = [lngLat.lat, lngLat.lng];
|
||||
placeResizeHandle();
|
||||
redraw();
|
||||
});
|
||||
centerMarker.on("dragend", () => options.onMove?.(currentCenter));
|
||||
}
|
||||
|
||||
// The center element may be reused for a rebuilt circle; its listeners go with this one
|
||||
let removeCenterListeners: (() => void) | undefined;
|
||||
if (options.onClick) {
|
||||
// A drag can end in a click; only one with its own pointer down counts
|
||||
let dragged = false;
|
||||
centerMarker.on("dragstart", () => {
|
||||
dragged = true;
|
||||
});
|
||||
const onPointerDown = () => {
|
||||
dragged = false;
|
||||
};
|
||||
const onClick = (ev: MouseEvent) => {
|
||||
ev.stopPropagation();
|
||||
if (dragged) {
|
||||
return;
|
||||
}
|
||||
options.onClick!();
|
||||
};
|
||||
const onKeydown = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
options.onClick!();
|
||||
}
|
||||
};
|
||||
centerEl.addEventListener("pointerdown", onPointerDown);
|
||||
centerEl.addEventListener("click", onClick);
|
||||
centerEl.addEventListener("keydown", onKeydown);
|
||||
removeCenterListeners = () => {
|
||||
centerEl.removeEventListener("pointerdown", onPointerDown);
|
||||
centerEl.removeEventListener("click", onClick);
|
||||
centerEl.removeEventListener("keydown", onKeydown);
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
get center() {
|
||||
return currentCenter;
|
||||
},
|
||||
get radius() {
|
||||
return currentRadius;
|
||||
},
|
||||
update: (newCenter, newRadius) => {
|
||||
if (dragging) {
|
||||
return;
|
||||
}
|
||||
currentCenter = newCenter;
|
||||
currentRadius = newRadius;
|
||||
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
|
||||
placeResizeHandle();
|
||||
redraw();
|
||||
},
|
||||
remove: () => {
|
||||
if (frame !== undefined) {
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
removeCenterListeners?.();
|
||||
centerMarker.remove();
|
||||
resetMarkerElement(centerEl);
|
||||
clearMarkerAccessibility(centerEl);
|
||||
this._placedElements.delete(centerEl);
|
||||
resizeMarker?.remove();
|
||||
this._removeCustomLayer(`${id}-fill`);
|
||||
this._removeCustomLayer(`${id}-line`);
|
||||
this._removeCustomSource(id);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
public addPath(path: MapPath): MapItemHandle {
|
||||
if (!this._map || !this._maplibre) {
|
||||
return { remove: () => undefined };
|
||||
@@ -1094,58 +760,40 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
id: string,
|
||||
data: Feature<Polygon> | FeatureCollection
|
||||
): void {
|
||||
// Recorded now, so an update while the style loads reaches the record and
|
||||
// a swap in between carries it; the map itself may already have it then
|
||||
const source: GeoJSONSourceSpecification = { type: "geojson", data };
|
||||
this._customSources.set(id, source);
|
||||
this._whenStyleLoaded(() => {
|
||||
if (!this._map!.getSource(id)) {
|
||||
this._map!.addSource(id, source);
|
||||
}
|
||||
const source: GeoJSONSourceSpecification = { type: "geojson", data };
|
||||
this._map!.addSource(id, source);
|
||||
this._customSources.set(id, source);
|
||||
});
|
||||
}
|
||||
|
||||
private _setCustomSourceData(
|
||||
id: string,
|
||||
data: Feature<Polygon> | FeatureCollection
|
||||
): void {
|
||||
const source = this._customSources.get(id);
|
||||
if (source) {
|
||||
source.data = data;
|
||||
}
|
||||
(this._map?.getSource(id) as GeoJSONSource | undefined)?.setData(data);
|
||||
}
|
||||
|
||||
private _addCustomLayer(layer: LayerSpecification): void {
|
||||
this._customLayers.set(layer.id, layer);
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getLayer(layer.id)) {
|
||||
return;
|
||||
}
|
||||
// Under the labels, over the base cartography
|
||||
const symbolLayer = this._map!.getStyle().layers.find(
|
||||
(styleLayer) =>
|
||||
styleLayer.type === "symbol" && !this._customLayers.has(styleLayer.id)
|
||||
);
|
||||
this._map!.addLayer(layer, symbolLayer?.id);
|
||||
this._customLayers.set(layer.id, layer);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeCustomLayer(id: string): void {
|
||||
this._customLayers.delete(id);
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getLayer(id)) {
|
||||
this._map!.removeLayer(id);
|
||||
}
|
||||
this._customLayers.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
private _removeCustomSource(id: string): void {
|
||||
this._customSources.delete(id);
|
||||
this._whenStyleLoaded(() => {
|
||||
if (this._map!.getSource(id)) {
|
||||
this._map!.removeSource(id);
|
||||
}
|
||||
this._customSources.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1204,16 +852,13 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const clusterable = this._markers.filter(
|
||||
(managed) => managed.options.cluster && !managed.removed
|
||||
);
|
||||
// A member or bubble that had focus hands it to the icon replacing it;
|
||||
// read before the bubble holding it is removed
|
||||
// A member that had focus hands it to the icon replacing it; read before
|
||||
// the open 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)) ??
|
||||
this._clusterGroups.find((group) =>
|
||||
group.iconMarker?.getElement().contains(active)
|
||||
)?.members[0])
|
||||
? clusterable.find((managed) => managed.element.contains(active))
|
||||
: undefined;
|
||||
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
|
||||
|
||||
@@ -1238,7 +883,6 @@ 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
|
||||
@@ -1261,10 +905,8 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
Math.max(...xs) - Math.min(...xs),
|
||||
Math.max(...ys) - Math.min(...ys)
|
||||
);
|
||||
// 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 });
|
||||
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
|
||||
groups.push({ seed: points[0], members });
|
||||
} else {
|
||||
ungrouped.push(...members);
|
||||
}
|
||||
@@ -1290,7 +932,6 @@ 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,
|
||||
@@ -1302,9 +943,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
|
||||
for (const group of this._clusterGroups) {
|
||||
group.iconMarker = undefined;
|
||||
// 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) {
|
||||
if (group.members.length === 1) {
|
||||
this._showMarker(group.members[0]);
|
||||
continue;
|
||||
}
|
||||
@@ -1334,6 +973,7 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
group.iconMarker?.remove();
|
||||
this._openGroup(group);
|
||||
};
|
||||
icon.element.tabIndex = 0;
|
||||
setMarkerAccessibility(
|
||||
icon.element,
|
||||
group.members
|
||||
@@ -1373,10 +1013,8 @@ export class MapLibreMapEngine implements MapEngine {
|
||||
const group = this._clusterGroups.find((candidate) =>
|
||||
candidate.members.includes(focusedMember)
|
||||
);
|
||||
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();
|
||||
if (group?.iconMarker && !group.open) {
|
||||
group.iconMarker.getElement().focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
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);
|
||||
};
|
||||
+37
-141
@@ -1,14 +1,27 @@
|
||||
/**
|
||||
* Map engine abstraction for ha-map: MapLibre GL where WebGL2 is available,
|
||||
* Leaflet as the viewing fallback. The Leaflet engine is frozen at this
|
||||
* contract; new capabilities go on MapLibre only, as optional members like
|
||||
* `editing`.
|
||||
* Engine abstraction for ha-map.
|
||||
*
|
||||
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
|
||||
* ha-map keeps all Home Assistant semantics (entities, zones, cluster bubble
|
||||
* DOM, history path math, fit policy) and delegates the primitive map
|
||||
* operations to a MapEngine, so the engine can be selected at runtime:
|
||||
* MapLibre GL native where WebGL2 is available, Leaflet otherwise (and always
|
||||
* for ha-locations-editor, which edits with leaflet-draw).
|
||||
*
|
||||
* The interface exposes no engine types: positions are [latitude, longitude]
|
||||
* tuples and marker content is caller-owned HTML elements.
|
||||
*
|
||||
* Zoom levels use Leaflet semantics (zoom 0 = one world tile), the historical
|
||||
* convention across Home Assistant map configs. The MapLibre engine converts
|
||||
* internally (MapLibre zoom = Leaflet zoom - 1).
|
||||
*/
|
||||
|
||||
export type MapLatLng = [latitude: number, longitude: number];
|
||||
|
||||
export interface MapPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type MapControlPosition =
|
||||
"topleft" | "topright" | "bottomleft" | "bottomright";
|
||||
|
||||
@@ -42,15 +55,6 @@ 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 {
|
||||
@@ -58,10 +62,8 @@ export interface MapMarkerOptions {
|
||||
size: [width: number, height: number];
|
||||
/** Point of the element placed on the coordinate, from its top left; defaults to the center */
|
||||
anchor?: [x: number, y: number];
|
||||
/** Takes pointer input; defaults to true */
|
||||
/** Takes pointer input and keyboard focus; defaults to true */
|
||||
interactive?: boolean;
|
||||
/** A keyboard-focusable button, for markers that act on activation; defaults to interactive */
|
||||
focusable?: boolean;
|
||||
/** Accessible name */
|
||||
title?: string;
|
||||
/** A meter-radius circle sharing the marker's lifecycle (GPS accuracy) */
|
||||
@@ -107,58 +109,6 @@ export interface MapMarkerHandle extends MapItemHandle {
|
||||
readonly clusterData?: unknown;
|
||||
}
|
||||
|
||||
export interface MapDraggableMarkerOptions extends MapMarkerOptions {
|
||||
onDragEnd?(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapEditableCircleOptions {
|
||||
/** Radius in meters */
|
||||
radius: number;
|
||||
/** Stroke color; the fill is derived from it, translucent */
|
||||
color: string;
|
||||
/** Element shown at the center, e.g. the zone icon; a plain dot otherwise */
|
||||
centerElement?: HTMLElement;
|
||||
centerSize?: [width: number, height: number];
|
||||
title?: string;
|
||||
/** The center can be dragged */
|
||||
moveable?: boolean;
|
||||
/** A handle on the edge can be dragged to change the radius */
|
||||
resizable?: boolean;
|
||||
/** Accessible name of the radius handle, e.g. "Radius of Home in meters" */
|
||||
resizeLabel?: string;
|
||||
onMove?(center: MapLatLng): void;
|
||||
onResize?(radius: number): void;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
export interface MapEditingSupport {
|
||||
/** Place a draggable HTML element marker */
|
||||
addDraggableMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
options: MapDraggableMarkerOptions
|
||||
): MapEditableMarkerHandle;
|
||||
|
||||
/** Draw a circle whose center and radius can be dragged */
|
||||
addEditableCircle(
|
||||
center: MapLatLng,
|
||||
options: MapEditableCircleOptions
|
||||
): MapEditableCircleHandle;
|
||||
}
|
||||
|
||||
/** A circle with drag handles for its center and radius */
|
||||
export interface MapEditableCircleHandle extends MapItemHandle {
|
||||
readonly center: MapLatLng;
|
||||
readonly radius: number;
|
||||
/** Move and resize without recreating (no-op mid-drag) */
|
||||
update(center: MapLatLng, radius: number): void;
|
||||
}
|
||||
|
||||
export interface MapEditableMarkerHandle extends MapMarkerHandle {
|
||||
/** Move without recreating (no-op mid-drag) */
|
||||
setLocation(location: MapLatLng): void;
|
||||
}
|
||||
|
||||
export interface MapClusterIcon {
|
||||
element: HTMLElement;
|
||||
size: [width: number, height: number];
|
||||
@@ -209,13 +159,13 @@ export interface MapEngine {
|
||||
/** Fit the given points into view; a single point centers on it */
|
||||
fitBounds(points: MapLatLng[], options?: MapFitOptions): void;
|
||||
|
||||
/** Pan to the location, keeping the zoom */
|
||||
panTo(location: MapLatLng): void;
|
||||
// Content ------------------------------------------------------------
|
||||
|
||||
/** Whether the location is inside the current viewport */
|
||||
containsLocation(location: MapLatLng): boolean;
|
||||
|
||||
/** Place a caller-owned element on the map */
|
||||
/**
|
||||
* Place an HTML element on the map. The element is owned by the caller;
|
||||
* the engine positions it and, for interactive markers, makes it
|
||||
* focusable.
|
||||
*/
|
||||
addMarker(
|
||||
element: HTMLElement,
|
||||
location: MapLatLng,
|
||||
@@ -225,87 +175,33 @@ export interface MapEngine {
|
||||
/** Draw a meter-radius circle (zone radius) */
|
||||
addCircle(center: MapLatLng, options: MapCircleOptions): MapItemHandle;
|
||||
|
||||
/** Editing support, MapLibre only; undefined on the Leaflet fallback */
|
||||
editing?: MapEditingSupport;
|
||||
|
||||
/** Draw one history trail (points with tooltips, connecting segments) */
|
||||
addPath(path: MapPath): MapItemHandle;
|
||||
|
||||
/** Cluster the markers added with cluster: true; call after each batch of addMarker calls */
|
||||
/**
|
||||
* Enable or disable clustering of the markers added with cluster: true.
|
||||
* Must be called after each batch of addMarker calls to place clusterable
|
||||
* markers on the map; null places them unclustered.
|
||||
*/
|
||||
setClustering(options: MapClusterOptions | null): void;
|
||||
|
||||
/** Rebuild cluster icons without regrouping (e.g. after a style change) */
|
||||
refreshClusters(): void;
|
||||
}
|
||||
|
||||
const EARTH_RADIUS = 6371008.8;
|
||||
|
||||
const toRadians = (degrees: number) => (degrees * Math.PI) / 180;
|
||||
const toDegrees = (radians: number) => (radians * 180) / Math.PI;
|
||||
|
||||
/**
|
||||
* The point a distance away from center along a bearing (degrees clockwise
|
||||
* from north), on the great circle. Longitude is left unwrapped so a ring of
|
||||
* points stays continuous across the antimeridian.
|
||||
*/
|
||||
export const destinationPoint = (
|
||||
center: MapLatLng,
|
||||
distanceInMeters: number,
|
||||
bearingDegrees: number
|
||||
): MapLatLng => {
|
||||
const angular = distanceInMeters / EARTH_RADIUS;
|
||||
const lat = toRadians(center[0]);
|
||||
const bearing = toRadians(bearingDegrees);
|
||||
const destLat = Math.asin(
|
||||
Math.sin(lat) * Math.cos(angular) +
|
||||
Math.cos(lat) * Math.sin(angular) * Math.cos(bearing)
|
||||
);
|
||||
const dLng = Math.atan2(
|
||||
Math.sin(bearing) * Math.sin(angular) * Math.cos(lat),
|
||||
Math.cos(angular) - Math.sin(lat) * Math.sin(destLat)
|
||||
);
|
||||
return [toDegrees(destLat), center[1] + toDegrees(dLng)];
|
||||
};
|
||||
|
||||
/** Great-circle distance in meters */
|
||||
export const distanceMeters = (a: MapLatLng, b: MapLatLng): number => {
|
||||
const dLat = toRadians(b[0] - a[0]);
|
||||
const dLng = toRadians(b[1] - a[1]);
|
||||
const h =
|
||||
Math.sin(dLat / 2) ** 2 +
|
||||
Math.cos(toRadians(a[0])) *
|
||||
Math.cos(toRadians(b[0])) *
|
||||
Math.sin(dLng / 2) ** 2;
|
||||
return 2 * EARTH_RADIUS * Math.asin(Math.sqrt(h));
|
||||
};
|
||||
|
||||
/** The point the given distance due east of center, e.g. for a resize handle */
|
||||
export const pointEastOf = (
|
||||
center: MapLatLng,
|
||||
distanceInMeters: number
|
||||
): MapLatLng => destinationPoint(center, distanceInMeters, 90);
|
||||
|
||||
/**
|
||||
* Bounding box corners of a circle, for fitting a radius into view. A circle
|
||||
* that reaches a pole spans every longitude.
|
||||
* Bounding box corners of a circle, for fitting a radius into view without
|
||||
* engine-specific circle bounds.
|
||||
*/
|
||||
export const circleBoundsPoints = (
|
||||
center: MapLatLng,
|
||||
radiusMeters: number
|
||||
): MapLatLng[] => {
|
||||
const angular = radiusMeters / EARTH_RADIUS;
|
||||
const latMin = Math.max(-90, center[0] - toDegrees(angular));
|
||||
const latMax = Math.min(90, center[0] + toDegrees(angular));
|
||||
const sinRatio = Math.sin(angular) / Math.cos(toRadians(center[0]));
|
||||
if (latMin <= -90 || latMax >= 90 || Math.abs(sinRatio) >= 1) {
|
||||
return [
|
||||
[latMin, -180],
|
||||
[latMax, 180],
|
||||
];
|
||||
}
|
||||
const dLng = toDegrees(Math.asin(sinRatio));
|
||||
const latOffset = radiusMeters / 111320;
|
||||
const lngOffset =
|
||||
latOffset / Math.max(Math.cos((center[0] * Math.PI) / 180), 0.01);
|
||||
return [
|
||||
[latMin, center[1] - dLng],
|
||||
[latMax, center[1] + dLng],
|
||||
[center[0] - latOffset, center[1] - lngOffset],
|
||||
[center[0] + latOffset, center[1] + lngOffset],
|
||||
];
|
||||
};
|
||||
|
||||
@@ -1,57 +1,23 @@
|
||||
/**
|
||||
* Marker elements may be focusable buttons on both engines, so the caller's
|
||||
* Marker elements are focusable buttons on both engines, so the caller's
|
||||
* element is the keyboard target and needs a name and a role. MapLibre would
|
||||
* otherwise label it "Map marker".
|
||||
*
|
||||
* What the engine sets is recorded on the element, so a rebuilt marker on a
|
||||
* reused element gets fresh attributes while the caller's own are left alone.
|
||||
*/
|
||||
|
||||
const OWNED = "haMapA11y";
|
||||
|
||||
export const setMarkerAccessibility = (
|
||||
element: HTMLElement,
|
||||
title: string | undefined,
|
||||
focusable: boolean
|
||||
interactive: boolean
|
||||
): void => {
|
||||
clearMarkerAccessibility(element);
|
||||
const owned: string[] = [];
|
||||
if (
|
||||
title &&
|
||||
!element.hasAttribute("aria-label") &&
|
||||
!element.hasAttribute("aria-labelledby")
|
||||
) {
|
||||
if (title && !element.hasAttribute("aria-label")) {
|
||||
element.setAttribute("aria-label", title);
|
||||
owned.push("aria-label");
|
||||
}
|
||||
if (!element.hasAttribute("role")) {
|
||||
if (focusable) {
|
||||
if (interactive) {
|
||||
element.setAttribute("role", "button");
|
||||
owned.push("role");
|
||||
} else if (title) {
|
||||
element.setAttribute("role", "img");
|
||||
owned.push("role");
|
||||
} else {
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
owned.push("aria-hidden");
|
||||
}
|
||||
}
|
||||
if (focusable && !element.hasAttribute("tabindex")) {
|
||||
element.tabIndex = 0;
|
||||
owned.push("tabindex");
|
||||
}
|
||||
element.dataset[OWNED] = owned.join(" ");
|
||||
};
|
||||
|
||||
/** Removes what setMarkerAccessibility set on the element */
|
||||
export const clearMarkerAccessibility = (element: HTMLElement): void => {
|
||||
const owned = element.dataset[OWNED];
|
||||
if (owned === undefined) {
|
||||
return;
|
||||
}
|
||||
owned
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.forEach((name) => element.removeAttribute(name));
|
||||
delete element.dataset[OWNED];
|
||||
};
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
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;
|
||||
}
|
||||
`;
|
||||
@@ -31,9 +31,11 @@ export type FormatEntityAttributeNameFunc = (
|
||||
attribute: string
|
||||
) => string;
|
||||
|
||||
export type EntityNameType = "entity" | "device" | "area" | "floor";
|
||||
|
||||
export type FormatEntityNameFunc = (
|
||||
stateObj: HassEntity,
|
||||
name: string | EntityNameItem | EntityNameItem[] | undefined,
|
||||
name: EntityNameItem | EntityNameItem[],
|
||||
options?: EntityNameOptions
|
||||
) => string;
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
export const GITHUB_CORE_ISSUES_URL =
|
||||
"https://github.com/home-assistant/core/issues";
|
||||
|
||||
export const GITHUB_FRONTEND_ISSUES_URL =
|
||||
"https://github.com/home-assistant/frontend/issues";
|
||||
@@ -90,7 +90,6 @@ export class HaAutomationConditionSummary extends LitElement {
|
||||
></ha-automation-row-options>`
|
||||
: nothing
|
||||
}
|
||||
<slot name="references"></slot>
|
||||
${
|
||||
note
|
||||
? html`
|
||||
|
||||
@@ -93,9 +93,9 @@ export class HaAutomationRowEventChip extends LitElement {
|
||||
}
|
||||
|
||||
:host([variant="neutral"]) {
|
||||
--background-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
--background-color-hover: var(--ha-color-fill-neutral-loud-hover);
|
||||
--text-color: var(--ha-color-on-neutral-loud);
|
||||
--background-color: var(--ha-color-fill-neutral-normal-resting);
|
||||
--background-color-hover: var(--ha-color-fill-neutral-normal-hover);
|
||||
--text-color: var(--ha-color-on-neutral-normal);
|
||||
}
|
||||
|
||||
:host([variant="success"]) {
|
||||
|
||||
@@ -152,7 +152,7 @@ export class HaAutomationRow extends LitElement {
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
:host([building-block]) .leading-icon-wrapper {
|
||||
border: 1px solid var(--ha-color-border-neutral-normal);
|
||||
background-color: var(--ha-color-fill-neutral-loud-resting);
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
padding: var(--ha-space-1);
|
||||
margin-top: 10px;
|
||||
@@ -172,6 +172,7 @@ export class HaAutomationRow extends LitElement {
|
||||
:host([building-block]) ::slotted([slot="leading-icon"].action-icon),
|
||||
:host([building-block]) ::slotted(#condition-icon) {
|
||||
--mdc-icon-size: var(--ha-space-5);
|
||||
color: var(--white-color);
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
:host([collapsed]) .expand-button {
|
||||
@@ -184,16 +185,9 @@ export class HaAutomationRow extends LitElement {
|
||||
outline-offset: -2px;
|
||||
outline-width: 2px;
|
||||
}
|
||||
:host([disabled]) .row,
|
||||
:host([disabled]) ::slotted([slot="leading-icon"]) {
|
||||
color: var(--ha-color-text-disabled);
|
||||
}
|
||||
:host([disabled]) .leading-icon-wrapper {
|
||||
opacity: 0.6;
|
||||
}
|
||||
:host([disabled]) ::slotted([slot="header"]) {
|
||||
opacity: 0.6;
|
||||
text-decoration: line-through;
|
||||
:host([disabled]) .row {
|
||||
border-top-right-radius: var(--ha-border-radius-square);
|
||||
border-top-left-radius: var(--ha-border-radius-square);
|
||||
}
|
||||
.header {
|
||||
position: relative;
|
||||
|
||||
@@ -39,7 +39,7 @@ const FRAME_SIZES = [
|
||||
].flat();
|
||||
|
||||
// Always rounds down, so no chart ends up with fewer frames than it asked for.
|
||||
export function snapFrameSize(step: number): number {
|
||||
function snapFrameSize(step: number): number {
|
||||
if (step >= DAY) {
|
||||
return Math.floor(step / DAY) * DAY;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ 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,17 +57,7 @@ export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
|
||||
const LEGEND_OVERFLOW_LIMIT = 10;
|
||||
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
|
||||
const DOUBLE_TAP_TIME = 300;
|
||||
export const DEFAULT_CHART_WIDTH = 500;
|
||||
// Slack so a chart is up to date before a scroll can reach it. A phone screen
|
||||
// is short enough for a whole screenful; on a desktop that would cover the page.
|
||||
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
|
||||
const VISIBILITY_ROOT_MARGIN = "300px";
|
||||
const DEFERRED_PROPS = [
|
||||
"options",
|
||||
"data",
|
||||
"_hiddenDatasets",
|
||||
"_isZoomed",
|
||||
] as const;
|
||||
const DEFAULT_CHART_WIDTH = 500;
|
||||
|
||||
type RawSeriesOption = Exclude<
|
||||
NonNullable<ECOption["series"]>,
|
||||
@@ -117,7 +106,7 @@ export type CustomLegendOption = ECOption["legend"] & {
|
||||
};
|
||||
|
||||
@customElement("ha-chart-base")
|
||||
export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
export class HaChartBase extends LitElement {
|
||||
public chart?: EChartsType;
|
||||
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -194,38 +183,20 @@ export class HaChartBase extends MobileAwareMixin(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: (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;
|
||||
}
|
||||
callback: () => {
|
||||
if (this.chart) {
|
||||
if (this._suspendResize) {
|
||||
this._shouldResizeChart = true;
|
||||
} else if (!this.chart.getZr().animation.isFinished()) {
|
||||
return;
|
||||
}
|
||||
if (!this.chart.getZr().animation.isFinished()) {
|
||||
this._shouldResizeChart = true;
|
||||
} else {
|
||||
this.chart.resize();
|
||||
}
|
||||
}
|
||||
if (!this._suspendResize) {
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -239,24 +210,10 @@ export class HaChartBase extends MobileAwareMixin(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()!();
|
||||
}
|
||||
@@ -270,30 +227,13 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
super.connectedCallback();
|
||||
if (this.hasUpdated) {
|
||||
this._pendingSetup = true;
|
||||
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();
|
||||
afterNextRender(() => {
|
||||
if (this.isConnected && this._pendingSetup) {
|
||||
this._pendingSetup = false;
|
||||
this._setupChart();
|
||||
}
|
||||
},
|
||||
{
|
||||
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) => {
|
||||
@@ -360,7 +300,6 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
this._suspendResize = this._layoutTransitionActive;
|
||||
if (!this._suspendResize) {
|
||||
this._resizeChartIfNeeded();
|
||||
this._applyDeferredWork();
|
||||
}
|
||||
};
|
||||
window.addEventListener("hass-layout-transition", handleLayoutTransition);
|
||||
@@ -373,105 +312,24 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
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()) {
|
||||
if (this.isConnected) {
|
||||
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;
|
||||
}
|
||||
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();
|
||||
}
|
||||
if (changedProps.has("_themes") && this.hasUpdated) {
|
||||
this._setupChart();
|
||||
return;
|
||||
}
|
||||
let chartOptions: ECOption = {};
|
||||
if (changedProps.has("options")) {
|
||||
// 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.
|
||||
// Separate 'if' from below since this must updated before _getSeries()
|
||||
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
|
||||
@@ -489,7 +347,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
if (changedProps.has("options")) {
|
||||
chartOptions = { ...chartOptions, ...this._createOptions() };
|
||||
if (this._compareCustomLegendOptions(previousOptions, this.options)) {
|
||||
if (
|
||||
this._compareCustomLegendOptions(
|
||||
changedProps.get("options"),
|
||||
this.options
|
||||
)
|
||||
) {
|
||||
// custom legend changes may require a resize to layout properly
|
||||
this._shouldResizeChart = true;
|
||||
this._resizeAnimationDuration = 250;
|
||||
@@ -600,7 +463,10 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
}
|
||||
|
||||
const overflowLimit = this._isMobileSize
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
const overflowLimit = isMobile
|
||||
? LEGEND_OVERFLOW_LIMIT_MOBILE
|
||||
: LEGEND_OVERFLOW_LIMIT;
|
||||
return html`<div
|
||||
@@ -712,11 +578,7 @@ export class HaChartBase extends MobileAwareMixin(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) {
|
||||
return;
|
||||
}
|
||||
await this._applyDeferredWork();
|
||||
if (!this.chart) {
|
||||
if (this._sonification || this._sonificationLoading || !this.chart) {
|
||||
return;
|
||||
}
|
||||
this._sonificationLoading = true;
|
||||
@@ -773,22 +635,14 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
);
|
||||
|
||||
private async _setupChart() {
|
||||
if (this._loading) {
|
||||
this._pendingSetup = true;
|
||||
return;
|
||||
}
|
||||
if (this._loading) 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;
|
||||
|
||||
@@ -926,14 +780,8 @@ export class HaChartBase extends MobileAwareMixin(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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1079,7 +927,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
};
|
||||
|
||||
if (options.tooltip) {
|
||||
const isMobile = this._isMobileSize;
|
||||
const isMobile = window.matchMedia(
|
||||
"all and (max-width: 450px), all and (max-height: 500px)"
|
||||
).matches;
|
||||
// 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
|
||||
@@ -1316,8 +1166,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
data: downSampleLineData(
|
||||
data as LineSeriesOption["data"],
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
((this._contentWidth ?? this.clientWidth) ||
|
||||
DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) *
|
||||
window.devicePixelRatio,
|
||||
minX,
|
||||
maxX
|
||||
),
|
||||
@@ -1330,7 +1180,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _getDefaultHeight() {
|
||||
return Math.max((this._contentWidth ?? this.clientWidth) / 2, 200);
|
||||
return Math.max(this.clientWidth / 2, 200);
|
||||
}
|
||||
|
||||
private _setChartOptions(options: ECOption) {
|
||||
@@ -1397,16 +1247,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
|
||||
};
|
||||
|
||||
public zoom(start: number, end: number, silent = false) {
|
||||
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({
|
||||
this.chart?.dispatchAction({
|
||||
type: "dataZoom",
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { HaECOption } from "../../resources/echarts/echarts";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import "./ha-chart-base";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { NODE_SIZE } from "../trace/hat-graph-const";
|
||||
import "../ha-alert";
|
||||
|
||||
export interface Node {
|
||||
@@ -42,7 +43,6 @@ const OVERFLOW_MARGIN = 5;
|
||||
const FONT_SIZE = 12;
|
||||
const NODE_GAP = 6;
|
||||
const LABEL_DISTANCE = 5;
|
||||
const NODE_SIZE = 30;
|
||||
const LABEL_MIN_MARGIN = 5;
|
||||
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
|
||||
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
import type {
|
||||
CustomSeriesOption,
|
||||
CustomSeriesRenderItem,
|
||||
} from "echarts/types/dist/shared";
|
||||
import type { HassEntities } from "home-assistant-js-websocket";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import { snapFrameSize } from "./down-sample";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
|
||||
export interface StateHistoryChartTimelineDataParams {
|
||||
states: HassEntities;
|
||||
data: TimelineEntity[];
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
names?: Record<string, string>;
|
||||
showNames: boolean;
|
||||
computedStyles: CSSStyleDeclaration;
|
||||
renderItem: CustomSeriesRenderItem;
|
||||
/** Chart width in device pixels; bounds how many rectangles are emitted. */
|
||||
chartWidth: number;
|
||||
}
|
||||
|
||||
export interface TimelineSegment {
|
||||
state: string;
|
||||
locState: string | null;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** Resolves each frame of a run to the state covering most of that frame. */
|
||||
function collapseRun(
|
||||
segments: TimelineSegment[],
|
||||
from: number,
|
||||
to: number,
|
||||
frameMs: number,
|
||||
push: (segment: TimelineSegment) => void
|
||||
) {
|
||||
const runEnd = segments[to - 1].end;
|
||||
const frameStates = new Map<
|
||||
string,
|
||||
{ duration: number; locState: string | null }
|
||||
>();
|
||||
let frameStart = segments[from].start;
|
||||
let index = from;
|
||||
|
||||
while (frameStart < runEnd) {
|
||||
const boundary = (Math.floor(frameStart / frameMs) + 1) * frameMs;
|
||||
// a frame size that rounds back onto frameStart would never advance
|
||||
const next = boundary > frameStart ? boundary : frameStart + frameMs;
|
||||
const frameEnd = next < runEnd ? next : runEnd;
|
||||
|
||||
frameStates.clear();
|
||||
let bestState: string | null = null;
|
||||
let bestLocState: string | null = null;
|
||||
let bestDuration = 0;
|
||||
// Segments are narrower than a frame, so each is visited at most twice:
|
||||
// index stops at the one spilling into the next frame.
|
||||
let cursor = index;
|
||||
while (cursor < to && segments[cursor].start < frameEnd) {
|
||||
const segment = segments[cursor];
|
||||
cursor++;
|
||||
const overlapStart =
|
||||
segment.start > frameStart ? segment.start : frameStart;
|
||||
const overlapEnd = segment.end < frameEnd ? segment.end : frameEnd;
|
||||
if (overlapEnd <= overlapStart) {
|
||||
continue;
|
||||
}
|
||||
let entry = frameStates.get(segment.state);
|
||||
if (entry) {
|
||||
entry.duration += overlapEnd - overlapStart;
|
||||
} else {
|
||||
entry = {
|
||||
duration: overlapEnd - overlapStart,
|
||||
locState: segment.locState,
|
||||
};
|
||||
frameStates.set(segment.state, entry);
|
||||
}
|
||||
if (entry.duration > bestDuration) {
|
||||
bestDuration = entry.duration;
|
||||
bestState = segment.state;
|
||||
bestLocState = entry.locState;
|
||||
}
|
||||
}
|
||||
while (index < to && segments[index].end <= frameEnd) {
|
||||
index++;
|
||||
}
|
||||
if (bestState !== null) {
|
||||
push({
|
||||
state: bestState,
|
||||
locState: bestLocState,
|
||||
start: frameStart,
|
||||
end: frameEnd,
|
||||
});
|
||||
}
|
||||
frameStart = frameEnd;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the rectangle count by the chart's pixel width. Segments at least one
|
||||
* frame wide are kept as they are; narrower ones are resolved per frame, and
|
||||
* neighbours resolving to the same state merge into one rectangle.
|
||||
*/
|
||||
export function downSampleTimelineSegments(
|
||||
segments: TimelineSegment[],
|
||||
frameMs: number
|
||||
): TimelineSegment[] {
|
||||
if (!(frameMs > 0)) {
|
||||
return segments;
|
||||
}
|
||||
const result: TimelineSegment[] = [];
|
||||
const push = (segment: TimelineSegment) => {
|
||||
const last = result[result.length - 1];
|
||||
if (last && last.state === segment.state && last.end === segment.start) {
|
||||
last.end = segment.end;
|
||||
return;
|
||||
}
|
||||
result.push(segment);
|
||||
};
|
||||
|
||||
let index = 0;
|
||||
while (index < segments.length) {
|
||||
if (segments[index].end - segments[index].start >= frameMs) {
|
||||
push({ ...segments[index] });
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const from = index;
|
||||
index++;
|
||||
// A gap of its own frame or more stays a gap; a narrower one is invisible
|
||||
// and is absorbed, so that a row of gap-separated slivers stays bounded.
|
||||
while (
|
||||
index < segments.length &&
|
||||
segments[index].end - segments[index].start < frameMs &&
|
||||
segments[index].start - segments[index - 1].end < frameMs
|
||||
) {
|
||||
index++;
|
||||
}
|
||||
collapseRun(segments, from, index, frameMs, push);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms processed history (`TimelineEntity[]`) into ECharts custom series
|
||||
* for `state-history-chart-timeline`. Pure data processing: all environment
|
||||
* inputs (theme style, entity states, chart width, the render callback) are injected so
|
||||
* the transform is deterministic and benchmarkable.
|
||||
*/
|
||||
export function generateStateHistoryChartTimelineData(
|
||||
params: StateHistoryChartTimelineDataParams
|
||||
): CustomSeriesOption[] {
|
||||
const { states, computedStyles, startTime, endTime, renderItem } = params;
|
||||
const stateHistory = params.data ?? [];
|
||||
const startTimeMs = startTime.getTime();
|
||||
const endTimeMs = endTime.getTime();
|
||||
// Snapped, and placed on absolute time, so a chart following "now" keeps
|
||||
// resolving the same frames instead of reshaping on every refresh.
|
||||
const rawFrameMs = Math.ceil(
|
||||
(endTimeMs - startTimeMs) / Math.floor(params.chartWidth)
|
||||
);
|
||||
const frameMs =
|
||||
Number.isFinite(rawFrameMs) && rawFrameMs > 0
|
||||
? snapFrameSize(rawFrameMs)
|
||||
: 0;
|
||||
const datasets: CustomSeriesOption[] = [];
|
||||
const names = params.names || {};
|
||||
// stateHistory is a list of lists of sorted state objects
|
||||
stateHistory.forEach((stateInfo) => {
|
||||
let prevState: string | null = null;
|
||||
let locState: string | null = null;
|
||||
let prevLastChanged = startTimeMs;
|
||||
const entityDisplay: string = params.showNames
|
||||
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
|
||||
: "";
|
||||
|
||||
const segments: TimelineSegment[] = [];
|
||||
stateInfo.data.forEach((entityState) => {
|
||||
let newState: string | null = entityState.state;
|
||||
const timeStamp = entityState.last_changed;
|
||||
if (!newState) {
|
||||
newState = null;
|
||||
}
|
||||
if (timeStamp > endTimeMs) {
|
||||
// Drop datapoints that are after the requested endTime. This could happen if
|
||||
// endTime is 'now' and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
if (prevState === null) {
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = timeStamp;
|
||||
} else if (newState !== prevState) {
|
||||
segments.push({
|
||||
state: prevState,
|
||||
locState,
|
||||
start: prevLastChanged,
|
||||
end: timeStamp,
|
||||
});
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = timeStamp;
|
||||
}
|
||||
});
|
||||
|
||||
if (prevState !== null) {
|
||||
segments.push({
|
||||
state: prevState,
|
||||
locState,
|
||||
start: prevLastChanged,
|
||||
end: endTimeMs,
|
||||
});
|
||||
}
|
||||
|
||||
const stateObj = states[stateInfo.entity_id];
|
||||
const dataRow = downSampleTimelineSegments(segments, frameMs).map(
|
||||
(segment) => {
|
||||
const color = computeTimelineColor(
|
||||
segment.state,
|
||||
computedStyles,
|
||||
stateObj
|
||||
);
|
||||
return {
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
new Date(segment.start),
|
||||
new Date(segment.end),
|
||||
segment.locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
datasets.push({
|
||||
id: stateInfo.entity_id,
|
||||
data: dataRow,
|
||||
name: entityDisplay,
|
||||
dimensions: ["id", "start", "end", "name", "color", "textColor"],
|
||||
type: "custom",
|
||||
encode: {
|
||||
x: [1, 2],
|
||||
y: 0,
|
||||
itemName: 3,
|
||||
},
|
||||
renderItem,
|
||||
progressive: 0,
|
||||
});
|
||||
});
|
||||
|
||||
return datasets;
|
||||
}
|
||||
@@ -11,18 +11,18 @@ import millisecondsToDuration from "../../common/datetime/milliseconds_to_durati
|
||||
import { computeRTL } from "../../common/util/compute_rtl";
|
||||
import type { TimelineEntity } from "../../data/history";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
|
||||
import { itemTooltipPosition } from "./chart-tooltip-position";
|
||||
import "./ha-chart-tooltip-marker";
|
||||
import { computeTimelineColor } from "./timeline-color";
|
||||
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
|
||||
import echarts from "../../resources/echarts/echarts";
|
||||
import { luminosity } from "../../common/color/rgb";
|
||||
import { hex2rgb } from "../../common/color/convert-color";
|
||||
import { measureTextWidth } from "../../util/text";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { generateStateHistoryChartTimelineData } from "./state-history-chart-timeline-data";
|
||||
|
||||
const ROW_HEIGHT = 30;
|
||||
// Taller rows when the name is drawn under the bar instead of in a column.
|
||||
const ROW_HEIGHT_INSIDE_LABELS = 64;
|
||||
const GRID_BOTTOM = 30;
|
||||
|
||||
@customElement("state-history-chart-timeline")
|
||||
@@ -41,12 +41,6 @@ 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 and
|
||||
// history-graph card.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -76,13 +70,7 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
<ha-chart-base
|
||||
.hass=${this.hass}
|
||||
.options=${this._chartOptions}
|
||||
.height=${`${
|
||||
this.data.length *
|
||||
(this.insideLabels && (this.chunked || this.showNames)
|
||||
? ROW_HEIGHT_INSIDE_LABELS
|
||||
: ROW_HEIGHT) +
|
||||
GRID_BOTTOM
|
||||
}px`}
|
||||
.height=${`${this.data.length * ROW_HEIGHT + GRID_BOTTOM}px`}
|
||||
.data=${this._chartData as HaECSeries}
|
||||
small-controls
|
||||
@chart-click=${this._handleChartClick}
|
||||
@@ -197,7 +185,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
changedProps.has("startTime") ||
|
||||
changedProps.has("endTime") ||
|
||||
changedProps.has("showNames") ||
|
||||
changedProps.has("insideLabels") ||
|
||||
changedProps.has("paddingYAxis") ||
|
||||
changedProps.has("_yWidth")
|
||||
) {
|
||||
@@ -209,11 +196,9 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
const narrow = this.narrow;
|
||||
const showNames = this.chunked || this.showNames;
|
||||
const maxInternalLabelWidth = narrow ? 105 : 185;
|
||||
const insideLabels = this.insideLabels;
|
||||
const labelWidth =
|
||||
showNames && !insideLabels
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelWidth = showNames
|
||||
? Math.max(this.paddingYAxis, this._yWidth)
|
||||
: 0;
|
||||
const labelMargin = 5;
|
||||
const rtl = computeRTL(
|
||||
this.hass.language,
|
||||
@@ -242,47 +227,31 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
axisLine: {
|
||||
show: false,
|
||||
},
|
||||
axisLabel: insideLabels
|
||||
? {
|
||||
// Draw the name inside the plot, under each row's bar, matching
|
||||
// the line charts whose legend sits under the plot. The taller
|
||||
// rows keep a name clear of the next row's bar.
|
||||
show: showNames,
|
||||
inside: true,
|
||||
margin: 0,
|
||||
padding: [18, 0, 0, rtl ? 0 : 2],
|
||||
align: rtl ? "right" : "left",
|
||||
verticalAlign: "top",
|
||||
formatter: (id: string) =>
|
||||
(this._chartData.find((d) => d.id === id)?.name as string) ??
|
||||
"",
|
||||
hideOverlap: true,
|
||||
axisLabel: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
: {
|
||||
show: showNames,
|
||||
width: labelWidth,
|
||||
overflow: "truncate",
|
||||
margin: labelMargin,
|
||||
formatter: (id: string) => {
|
||||
const label = this._chartData.find((d) => d.id === id)
|
||||
?.name as string;
|
||||
const width = label
|
||||
? Math.min(
|
||||
measureTextWidth(label, 12) + labelMargin,
|
||||
maxInternalLabelWidth
|
||||
)
|
||||
: 0;
|
||||
if (width > this._yWidth) {
|
||||
this._yWidth = width;
|
||||
fireEvent(this, "y-width-changed", {
|
||||
value: this._yWidth,
|
||||
chartIndex: this.chartIndex,
|
||||
});
|
||||
}
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
return label;
|
||||
},
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
top: 10,
|
||||
@@ -313,20 +282,109 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
private _generateData() {
|
||||
const computedStyles = getComputedStyle(this);
|
||||
let stateHistory = this.data;
|
||||
|
||||
if (!stateHistory) {
|
||||
stateHistory = [];
|
||||
}
|
||||
|
||||
this._chartTime = new Date();
|
||||
this._chartData = generateStateHistoryChartTimelineData({
|
||||
states: this.hass.states,
|
||||
data: this.data,
|
||||
startTime: this.startTime,
|
||||
endTime: this.endTime,
|
||||
names: this.names,
|
||||
showNames: this.showNames,
|
||||
computedStyles: getComputedStyle(this),
|
||||
renderItem: this._renderItem,
|
||||
// 0 while inside a hidden container, e.g. a section with a visibility condition
|
||||
chartWidth:
|
||||
(this.clientWidth || DEFAULT_CHART_WIDTH) * window.devicePixelRatio,
|
||||
const startTime = this.startTime;
|
||||
const endTime = this.endTime;
|
||||
const datasets: CustomSeriesOption[] = [];
|
||||
const names = this.names || {};
|
||||
// stateHistory is a list of lists of sorted state objects
|
||||
stateHistory.forEach((stateInfo) => {
|
||||
let newLastChanged: Date;
|
||||
let prevState: string | null = null;
|
||||
let locState: string | null = null;
|
||||
let prevLastChanged = startTime;
|
||||
const entityDisplay: string = this.showNames
|
||||
? names[stateInfo.entity_id] || stateInfo.name || stateInfo.entity_id
|
||||
: "";
|
||||
|
||||
const dataRow: unknown[] = [];
|
||||
stateInfo.data.forEach((entityState) => {
|
||||
let newState: string | null = entityState.state;
|
||||
const timeStamp = new Date(entityState.last_changed);
|
||||
if (!newState) {
|
||||
newState = null;
|
||||
}
|
||||
if (timeStamp > endTime) {
|
||||
// Drop datapoints that are after the requested endTime. This could happen if
|
||||
// endTime is 'now' and client time is not in sync with server time.
|
||||
return;
|
||||
}
|
||||
if (prevState === null) {
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = new Date(entityState.last_changed);
|
||||
} else if (newState !== prevState) {
|
||||
newLastChanged = new Date(entityState.last_changed);
|
||||
|
||||
const color = computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
);
|
||||
dataRow.push({
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
prevLastChanged,
|
||||
newLastChanged,
|
||||
locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
});
|
||||
|
||||
prevState = newState;
|
||||
locState = entityState.state_localize;
|
||||
prevLastChanged = newLastChanged;
|
||||
}
|
||||
});
|
||||
|
||||
if (prevState !== null) {
|
||||
const color = computeTimelineColor(
|
||||
prevState,
|
||||
computedStyles,
|
||||
this.hass.states[stateInfo.entity_id]
|
||||
);
|
||||
dataRow.push({
|
||||
value: [
|
||||
stateInfo.entity_id,
|
||||
prevLastChanged,
|
||||
endTime,
|
||||
locState,
|
||||
color,
|
||||
luminosity(hex2rgb(color)) > 0.5 ? "#000" : "#fff",
|
||||
],
|
||||
itemStyle: {
|
||||
color,
|
||||
},
|
||||
});
|
||||
}
|
||||
datasets.push({
|
||||
id: stateInfo.entity_id,
|
||||
data: dataRow,
|
||||
name: entityDisplay,
|
||||
dimensions: ["id", "start", "end", "name", "color", "textColor"],
|
||||
type: "custom",
|
||||
encode: {
|
||||
x: [1, 2],
|
||||
y: 0,
|
||||
itemName: 3,
|
||||
},
|
||||
renderItem: this._renderItem,
|
||||
progressive: 0,
|
||||
});
|
||||
});
|
||||
|
||||
this._chartData = datasets;
|
||||
}
|
||||
|
||||
private _handleChartClick(
|
||||
@@ -343,9 +401,6 @@ export class StateHistoryChartTimeline extends LitElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
ha-chart-base {
|
||||
--chart-max-height: none;
|
||||
}
|
||||
|
||||
@@ -79,11 +79,6 @@ 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 and history-graph card.
|
||||
@property({ attribute: "inside-labels", type: Boolean })
|
||||
public insideLabels = false;
|
||||
|
||||
@property({ attribute: "click-for-more-info", type: Boolean })
|
||||
public clickForMoreInfo = true;
|
||||
|
||||
@@ -232,7 +227,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
.startTime=${this._computedStartTime}
|
||||
.endTime=${this._computedEndTime}
|
||||
.showNames=${this.showNames}
|
||||
.insideLabels=${this.insideLabels}
|
||||
.names=${this.names}
|
||||
.narrow=${this.narrow}
|
||||
.chunked=${this.virtualize}
|
||||
@@ -375,7 +369,7 @@ export class StateHistoryCharts extends LitElement {
|
||||
const chartBase =
|
||||
chartComponent.renderRoot?.querySelector("ha-chart-base");
|
||||
|
||||
if (chartBase) {
|
||||
if (chartBase && chartBase.chart) {
|
||||
chartBase.zoom(0, 100);
|
||||
}
|
||||
});
|
||||
@@ -450,10 +444,6 @@ export class StateHistoryCharts extends LitElement {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.entry-container.timeline:not(:first-child) {
|
||||
margin-top: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.container,
|
||||
lit-virtualizer {
|
||||
height: 100%;
|
||||
|
||||
@@ -53,41 +53,6 @@ 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,
|
||||
@@ -298,10 +263,8 @@ 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 || (chartStacked && chartType === "line") ? "lttb" : "minmax",
|
||||
sampling: band && drawBands ? "lttb" : "minmax",
|
||||
animationDurationUpdate: 0,
|
||||
lineStyle: {
|
||||
width: 1.5,
|
||||
@@ -476,15 +439,6 @@ 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,23 +125,7 @@ export class StatisticsChart extends LitElement {
|
||||
private _yAxisFractionDigits = 1;
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues<this>): boolean {
|
||||
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;
|
||||
return changedProps.size > 1 || !changedProps.has("hass");
|
||||
}
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
@@ -151,8 +135,7 @@ export class StatisticsChart extends LitElement {
|
||||
changedProps.has("chartType") ||
|
||||
changedProps.has("hideLegend") ||
|
||||
changedProps.has("_hiddenStats") ||
|
||||
changedProps.has("names") ||
|
||||
this._entityNamesChanged(changedProps)
|
||||
changedProps.has("names")
|
||||
) {
|
||||
this._generateData();
|
||||
}
|
||||
@@ -270,8 +253,6 @@ 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];
|
||||
@@ -549,26 +530,17 @@ export class StatisticsChart extends LitElement {
|
||||
this.unit = data.unit;
|
||||
this._yAxisFractionDigits = data.yAxisFractionDigits;
|
||||
this._chartData = data.datasets;
|
||||
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
|
||||
)
|
||||
) {
|
||||
if (data.legendData.length !== this._legendData?.length) {
|
||||
// only update the legend if it has changed or it will trigger options update
|
||||
this._legendData = legendData;
|
||||
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._statisticIds = data.statisticIds;
|
||||
}
|
||||
|
||||
@@ -35,14 +35,6 @@ 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
|
||||
|
||||
@@ -32,11 +32,8 @@ import { internationalizationContext } from "../../data/context";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import "../animation/ha-fade-in";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../skeleton/ha-skeleton-icon";
|
||||
import "../skeleton/ha-skeleton-text";
|
||||
import "../ha-svg-icon";
|
||||
import "../input/ha-input-search";
|
||||
import { filterData, sortData } from "./sort-filter";
|
||||
@@ -114,25 +111,6 @@ export type SortableColumnContainer = Record<string, ClonedDataTableColumnData>;
|
||||
const UNDEFINED_GROUP_KEY = "zzzzz_undefined";
|
||||
const AUTO_FOCUS_ALLOWED_ACTIVE_TAGS = ["BODY", "HTML", "HOME-ASSISTANT"];
|
||||
|
||||
// Default row height, used to fill the viewport with skeleton rows.
|
||||
const ROW_HEIGHT = 52;
|
||||
|
||||
const cellClasses = (column: DataTableColumnData) => ({
|
||||
"mdc-data-table__cell--flex": column.type === "flex",
|
||||
"mdc-data-table__cell--numeric": column.type === "numeric",
|
||||
"mdc-data-table__cell--icon": column.type === "icon",
|
||||
"mdc-data-table__cell--icon-button": column.type === "icon-button",
|
||||
"mdc-data-table__cell--overflow-menu": column.type === "overflow-menu",
|
||||
"mdc-data-table__cell--overflow": column.type === "overflow",
|
||||
forceLTR: Boolean(column.forceLTR),
|
||||
});
|
||||
|
||||
const cellStyles = (column: DataTableColumnData) => ({
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth,
|
||||
flex: column.flex || 1,
|
||||
});
|
||||
|
||||
@customElement("ha-data-table")
|
||||
export class HaDataTable extends LitElement {
|
||||
@state()
|
||||
@@ -145,8 +123,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@property({ type: Array }) public data: DataTableRowData[] = [];
|
||||
|
||||
@property({ type: Boolean }) public loading = false;
|
||||
|
||||
@property({ type: Boolean }) public selectable = false;
|
||||
|
||||
@property({ type: Boolean }) public clickable = false;
|
||||
@@ -189,9 +165,6 @@ export class HaDataTable extends LitElement {
|
||||
|
||||
@state() private _filteredData?: DataTableRowData[];
|
||||
|
||||
// Row count of the data that _filteredData was computed from
|
||||
@state() private _filteredDataSourceLength = 0;
|
||||
|
||||
@state() private _headerHeight = 0;
|
||||
|
||||
@query("slot[name='header']") private _header!: HTMLSlotElement;
|
||||
@@ -542,79 +515,18 @@ export class HaDataTable extends LitElement {
|
||||
</div>
|
||||
${
|
||||
!this._filteredData?.length
|
||||
? this.loading ||
|
||||
!this._filteredData ||
|
||||
(this.data.length && !this._filteredDataSourceLength)
|
||||
? html`
|
||||
<div class="mdc-data-table__content" role="row">
|
||||
<ha-fade-in .duration=${300} easing="ease-in">
|
||||
<div role="cell">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label=${
|
||||
this._i18n?.localize?.("ui.common.loading") ||
|
||||
? html`
|
||||
<div class="mdc-data-table__content">
|
||||
<div class="mdc-data-table__row" role="row">
|
||||
<div
|
||||
class="mdc-data-table__cell grows center"
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
!this._filteredData
|
||||
? this._i18n?.localize?.("ui.common.loading") ||
|
||||
"Loading"
|
||||
}
|
||||
>
|
||||
${Array.from(
|
||||
{
|
||||
length: this.autoHeight
|
||||
? 1
|
||||
: Math.ceil(window.innerHeight / ROW_HEIGHT),
|
||||
},
|
||||
() => html`
|
||||
<div class="mdc-data-table__row">
|
||||
${
|
||||
this.selectable
|
||||
? html`<div
|
||||
class="mdc-data-table__cell mdc-data-table__cell--checkbox"
|
||||
></div>`
|
||||
: nothing
|
||||
}
|
||||
${Object.entries(columns).map(
|
||||
([key, column]) =>
|
||||
(this.narrow &&
|
||||
!column.main &&
|
||||
!column.showNarrow) ||
|
||||
!this._isColumnVisible(key, column)
|
||||
? nothing
|
||||
: html`
|
||||
<div
|
||||
class="mdc-data-table__cell ${classMap(
|
||||
cellClasses(column)
|
||||
)}"
|
||||
style=${styleMap(cellStyles(column))}
|
||||
>
|
||||
${
|
||||
column.type === "icon"
|
||||
? html`<ha-skeleton-icon></ha-skeleton-icon>`
|
||||
: column.type ===
|
||||
"icon-button" ||
|
||||
column.type ===
|
||||
"overflow-menu"
|
||||
? nothing
|
||||
: html`<ha-skeleton-text></ha-skeleton-text>`
|
||||
}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ha-fade-in>
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<div class="mdc-data-table__content">
|
||||
<div class="mdc-data-table__row" role="row">
|
||||
<div
|
||||
class="mdc-data-table__cell grows center"
|
||||
role="cell"
|
||||
>
|
||||
${
|
||||
this.data.length
|
||||
: this.data.length
|
||||
? this._i18n?.localize?.(
|
||||
"ui.components.data-table.no_match_filter"
|
||||
) || "No rows matching current filters"
|
||||
@@ -623,11 +535,11 @@ export class HaDataTable extends LitElement {
|
||||
"ui.components.data-table.no-data"
|
||||
) ||
|
||||
"No data"
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
</div>
|
||||
`
|
||||
: html`
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
@@ -721,8 +633,22 @@ export class HaDataTable extends LitElement {
|
||||
@mouseover=${this._setTitle}
|
||||
@focus=${this._setTitle}
|
||||
role=${column.main ? "rowheader" : "cell"}
|
||||
class="mdc-data-table__cell ${classMap(cellClasses(column))}"
|
||||
style=${styleMap(cellStyles(column))}
|
||||
class="mdc-data-table__cell ${classMap({
|
||||
"mdc-data-table__cell--flex": column.type === "flex",
|
||||
"mdc-data-table__cell--numeric": column.type === "numeric",
|
||||
"mdc-data-table__cell--icon": column.type === "icon",
|
||||
"mdc-data-table__cell--icon-button":
|
||||
column.type === "icon-button",
|
||||
"mdc-data-table__cell--overflow-menu":
|
||||
column.type === "overflow-menu",
|
||||
"mdc-data-table__cell--overflow": column.type === "overflow",
|
||||
forceLTR: Boolean(column.forceLTR),
|
||||
})}"
|
||||
style=${styleMap({
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth,
|
||||
flex: column.flex || 1,
|
||||
})}
|
||||
>
|
||||
${
|
||||
column.template
|
||||
@@ -795,11 +721,10 @@ export class HaDataTable extends LitElement {
|
||||
!this._lastUpdate ||
|
||||
(timeBetweenUpdate > 500 && timeBetweenRequest < 500);
|
||||
|
||||
const sourceData = this.data;
|
||||
let filteredData = sourceData;
|
||||
let filteredData = this.data;
|
||||
if (this._filter) {
|
||||
filteredData = await this._memFilterData(
|
||||
sourceData,
|
||||
this.data,
|
||||
this._sortColumns,
|
||||
this._filter.trim()
|
||||
);
|
||||
@@ -835,13 +760,8 @@ export class HaDataTable extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (startTime < this._lastUpdate) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._lastUpdate = startTime;
|
||||
this._filteredData = data;
|
||||
this._filteredDataSourceLength = sourceData.length;
|
||||
}
|
||||
|
||||
private _groupData = memoizeOne(
|
||||
@@ -1282,20 +1202,6 @@ 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;
|
||||
@@ -1397,8 +1303,7 @@ export class HaDataTable extends LitElement {
|
||||
.mdc-data-table__cell--icon:first-child ha-svg-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-state-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-domain-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-service-icon,
|
||||
.mdc-data-table__cell--icon:first-child ha-skeleton-icon {
|
||||
.mdc-data-table__cell--icon:first-child ha-service-icon {
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: initial;
|
||||
@@ -1594,16 +1499,6 @@ 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;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,9 @@ import { repeat } from "lit/directives/repeat";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { ensureArray } from "../../common/array/ensure-array";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import {
|
||||
ENTITY_NAME_TYPES,
|
||||
type EntityNameItem,
|
||||
type EntityNameType,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
import type { EntityNameItem } from "../../common/entity/compute_entity_name_display";
|
||||
import { getEntityContext } from "../../common/entity/context/get_entity_context";
|
||||
import type { EntityNameType } from "../../common/translations/entity-state";
|
||||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../chips/ha-assist-chip";
|
||||
@@ -38,7 +35,9 @@ const rowRenderer: RenderItemFunction<PickerComboBoxItem> = (item) => html`
|
||||
</ha-combo-box-item>
|
||||
`;
|
||||
|
||||
const KNOWN_TYPES = new Set<string>(ENTITY_NAME_TYPES);
|
||||
const KNOWN_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const UNIQUE_TYPES = new Set(["entity", "device", "area", "floor"]);
|
||||
|
||||
const formatOptionValue = (item: EntityNameItem) => {
|
||||
if (item.type === "text" && item.text) {
|
||||
@@ -136,7 +135,9 @@ export class HaEntityNamePicker extends LitElement {
|
||||
${
|
||||
this.helper
|
||||
? html`
|
||||
<ha-input-helper-text> ${this.helper} </ha-input-helper-text>
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing
|
||||
}
|
||||
@@ -169,7 +170,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
.getItems=${this._getFilteredItems}
|
||||
.rowRenderer=${rowRenderer}
|
||||
.value=${this._getPickerValue()}
|
||||
no-sort
|
||||
allow-custom-value
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.entity.entity-name-picker.custom_name"
|
||||
@@ -387,7 +387,6 @@ export class HaEntityNamePicker extends LitElement {
|
||||
);
|
||||
|
||||
if (context.device) options.add("device");
|
||||
if (context.parentDevice) options.add("parent_device");
|
||||
if (context.area) options.add("area");
|
||||
if (context.floor) options.add("floor");
|
||||
return options;
|
||||
@@ -400,8 +399,8 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const types = this._validTypes(entityId);
|
||||
|
||||
const items = ENTITY_NAME_TYPES.filter(
|
||||
(name) => name !== "parent_device" || types.has(name)
|
||||
const items = (
|
||||
["entity", "device", "area", "floor"] as const
|
||||
).map<PickerComboBoxItem>((name) => {
|
||||
const stateObj = this.hass.states[entityId];
|
||||
const isValid = types.has(name);
|
||||
@@ -465,7 +464,7 @@ export class HaEntityNamePicker extends LitElement {
|
||||
|
||||
const excludedValues = new Set(
|
||||
this._items
|
||||
.filter((item) => KNOWN_TYPES.has(item.type))
|
||||
.filter((item) => UNIQUE_TYPES.has(item.type))
|
||||
.map((item) => formatOptionValue(item))
|
||||
);
|
||||
|
||||
|
||||
@@ -178,17 +178,6 @@ export class HaStateContentPicker extends LitElement {
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.parentDevice) {
|
||||
contextItems.push({
|
||||
id: "parent_device_name",
|
||||
primary: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
sorting_label: this.hass.localize(
|
||||
"ui.components.state-content-picker.parent_device_name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if (context.area) {
|
||||
contextItems.push({
|
||||
id: "area_name",
|
||||
@@ -329,7 +318,11 @@ export class HaStateContentPicker extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -11,10 +11,9 @@ 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 {
|
||||
computeEntityPickerDisplay,
|
||||
computeEntitySearchLabels,
|
||||
} from "../../common/entity/compute_entity_name_display";
|
||||
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 { domainToName } from "../../data/integration";
|
||||
import {
|
||||
getStatisticLabel,
|
||||
@@ -53,7 +52,6 @@ const SEARCH_KEYS = [
|
||||
{ name: "search_labels.entityName", weight: 10 },
|
||||
{ name: "search_labels.friendlyName", weight: 9 },
|
||||
{ name: "search_labels.deviceName", weight: 8 },
|
||||
{ name: "search_labels.parentDeviceName", weight: 6 },
|
||||
{ name: "search_labels.areaName", weight: 6 },
|
||||
{ name: "search_labels.domainName", weight: 4 },
|
||||
{ name: "statisticId", weight: 3 },
|
||||
@@ -234,6 +232,11 @@ export class HaStatisticPicker extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
const isRTL = computeRTL(
|
||||
hass.language,
|
||||
hass.translationMetadata.translations
|
||||
);
|
||||
|
||||
const output: StatisticComboBoxItem[] = [];
|
||||
|
||||
statisticIds.forEach((meta) => {
|
||||
@@ -287,18 +290,22 @@ export class HaStatisticPicker extends LitElement {
|
||||
}
|
||||
const id = meta.statistic_id;
|
||||
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
hass,
|
||||
stateObj
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
hass.entities,
|
||||
hass.devices,
|
||||
hass.areas,
|
||||
hass.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || id;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||
output.push({
|
||||
id,
|
||||
@@ -307,12 +314,13 @@ export class HaStatisticPicker extends LitElement {
|
||||
secondary,
|
||||
stateObj: stateObj,
|
||||
type: "entity",
|
||||
sorting_label: [
|
||||
sortingPrefix,
|
||||
searchLabels.deviceName,
|
||||
searchLabels.entityName,
|
||||
].join("_"),
|
||||
search_labels: searchLabels,
|
||||
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -387,18 +395,26 @@ export class HaStatisticPicker extends LitElement {
|
||||
const stateObj = this.hass.states[statisticId];
|
||||
|
||||
if (stateObj) {
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
this.hass,
|
||||
stateObj
|
||||
);
|
||||
const searchLabels = computeEntitySearchLabels(
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "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 = entityName || deviceName || statisticId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
const friendlyName = computeStateName(stateObj); // Keep this for search
|
||||
|
||||
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
|
||||
return {
|
||||
id: statisticId,
|
||||
@@ -407,12 +423,14 @@ export class HaStatisticPicker extends LitElement {
|
||||
secondary,
|
||||
stateObj: stateObj,
|
||||
type: "entity",
|
||||
sorting_label: [
|
||||
sortingPrefix,
|
||||
searchLabels.deviceName,
|
||||
searchLabels.entityName,
|
||||
].join("_"),
|
||||
search_labels: { ...searchLabels, statisticId },
|
||||
sorting_label: [sortingPrefix, deviceName, entityName].join("_"),
|
||||
search_labels: {
|
||||
entityName: entityName || null,
|
||||
deviceName: deviceName || null,
|
||||
areaName: areaName || null,
|
||||
friendlyName,
|
||||
statisticId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../common/decorators/consume-context-entry";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import type { Analytics, AnalyticsPreferences } from "../data/analytics";
|
||||
@@ -21,15 +20,13 @@ 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;
|
||||
@@ -37,12 +34,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
|
||||
>
|
||||
@@ -53,22 +50,18 @@ export class HaAnalytics extends LitElement {
|
||||
.preference=${"base"}
|
||||
.disabled=${loading}
|
||||
name="base"
|
||||
>
|
||||
${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
></ha-switch>
|
||||
</ha-row-item>
|
||||
${ADDITIONAL_PREFERENCES.map(
|
||||
(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
|
||||
>
|
||||
@@ -79,11 +72,7 @@ export class HaAnalytics extends LitElement {
|
||||
.checked=${!!this.analytics?.preferences[preference]}
|
||||
.preference=${preference}
|
||||
name=${preference}
|
||||
>
|
||||
${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
></ha-switch>
|
||||
${
|
||||
baseEnabled
|
||||
? nothing
|
||||
@@ -91,7 +80,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>`
|
||||
@@ -101,12 +90,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
|
||||
>
|
||||
@@ -117,11 +106,7 @@ export class HaAnalytics extends LitElement {
|
||||
.preference=${"diagnostics"}
|
||||
.disabled=${loading}
|
||||
name="diagnostics"
|
||||
>
|
||||
${this._localize(
|
||||
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
|
||||
)}
|
||||
</ha-switch>
|
||||
></ha-switch>
|
||||
</ha-row-item>
|
||||
`;
|
||||
}
|
||||
@@ -158,19 +143,6 @@ export class HaAnalytics extends LitElement {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
/* The visible headline already names the row. Keep the switch's
|
||||
slotted label available to assistive technology without repeating it. */
|
||||
ha-switch::part(label) {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
ha-row-item {
|
||||
--ha-row-item-padding-inline: 0;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import Fuse from "fuse.js";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeEntityPickerDisplay } from "../common/entity/compute_entity_name_display";
|
||||
import { computeEntityNameList } from "../common/entity/compute_entity_name_display";
|
||||
import { computeRTL } from "../common/util/compute_rtl";
|
||||
import type { LocalizeFunc } from "../common/translations/localize";
|
||||
import {
|
||||
multiTermSortedSearch,
|
||||
@@ -182,6 +183,11 @@ 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;
|
||||
@@ -191,11 +197,20 @@ export class HaAreaControlsPicker extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const { primary, secondary } = computeEntityPickerDisplay(
|
||||
this.hass!,
|
||||
stateObj
|
||||
const [entityName, deviceName, areaName] = computeEntityNameList(
|
||||
stateObj,
|
||||
[{ type: "entity" }, { type: "device" }, { type: "area" }],
|
||||
this.hass!.entities,
|
||||
this.hass!.devices,
|
||||
this.hass!.areas,
|
||||
this.hass!.floors
|
||||
);
|
||||
|
||||
const primary = entityName || deviceName || entityId;
|
||||
const secondary = [areaName, entityName ? deviceName : undefined]
|
||||
.filter(Boolean)
|
||||
.join(isRTL ? " ◂ " : " ▸ ");
|
||||
|
||||
entityItems.push({
|
||||
type: "entity",
|
||||
id: entityId,
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
type ConversationChatLogToolResultDelta,
|
||||
type PipelineRunEvent,
|
||||
} from "../data/assist_pipeline";
|
||||
import type { ChatLogToolResult } from "../data/chat_log";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
@@ -59,7 +58,7 @@ interface AssistMessage {
|
||||
{
|
||||
tool_name: string;
|
||||
tool_args: Record<string, unknown>;
|
||||
result?: ChatLogToolResult;
|
||||
result?: any;
|
||||
}
|
||||
>;
|
||||
error?: boolean;
|
||||
@@ -835,7 +834,7 @@ ${JSON.stringify(toolCall.result, null, 2)}</pre>
|
||||
} else if (isToolResult(delta)) {
|
||||
if (progress.hassMessage.tool_calls[delta.tool_call_id]) {
|
||||
progress.hassMessage.tool_calls[delta.tool_call_id].result =
|
||||
delta.result;
|
||||
delta.tool_result;
|
||||
this.requestUpdate("_conversation");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,7 +312,9 @@ export class HaBaseTimeInput extends LitElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -42,8 +42,8 @@ export class HaCard extends LitElement {
|
||||
font-family: var(--ha-card-header-font-family, inherit);
|
||||
font-size: var(--ha-card-header-font-size, var(--ha-font-size-2xl));
|
||||
letter-spacing: -0.012em;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
padding: var(--ha-space-5) var(--ha-space-4) var(--ha-space-6);
|
||||
line-height: var(--ha-line-height-expanded);
|
||||
padding: var(--ha-space-3) var(--ha-space-4) var(--ha-space-4);
|
||||
display: block;
|
||||
margin-block-start: 0;
|
||||
margin-block-end: 0;
|
||||
|
||||
@@ -198,7 +198,11 @@ export class HaClockDateFormatPicker extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html` <ha-input-helper-text> ${this.helper} </ha-input-helper-text> `
|
||||
? html`
|
||||
<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>
|
||||
`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -761,9 +761,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const deviceName = context.device
|
||||
? computeDeviceName(context.device)
|
||||
: undefined;
|
||||
const parentDeviceName = context.parentDevice
|
||||
? computeDeviceName(context.parentDevice)
|
||||
: undefined;
|
||||
const areaName = context.area ? computeAreaName(context.area) : undefined;
|
||||
const floorName = context.floor
|
||||
? computeFloorName(context.floor)
|
||||
@@ -798,15 +795,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
});
|
||||
}
|
||||
|
||||
if (parentDeviceName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize(
|
||||
"ui.components.device-picker.parent_device"
|
||||
),
|
||||
value: parentDeviceName,
|
||||
});
|
||||
}
|
||||
|
||||
if (areaName) {
|
||||
completionItems.push({
|
||||
label: this._i18n!.localize("ui.components.area-picker.area"),
|
||||
|
||||
@@ -103,87 +103,69 @@ 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;
|
||||
}
|
||||
@@ -254,8 +236,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]);
|
||||
@@ -272,11 +254,6 @@ 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 {
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public localize?: LocalizeFunc;
|
||||
|
||||
@state() @consumeLocalize() private _localize?: LocalizeFunc;
|
||||
|
||||
@state()
|
||||
@consume({ context: internationalizationContext, subscribe: true })
|
||||
@@ -92,6 +92,7 @@ export class HaFileUpload extends LitElement {
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const localize = this.localize || this._localize!;
|
||||
return html`
|
||||
${
|
||||
this.uploading
|
||||
@@ -101,13 +102,10 @@ export class HaFileUpload extends LitElement {
|
||||
>${
|
||||
this.uploadingLabel ||
|
||||
(this.value
|
||||
? this._localize(
|
||||
"ui.components.file-upload.uploading_name",
|
||||
{
|
||||
name: this._name,
|
||||
}
|
||||
)
|
||||
: this._localize("ui.components.file-upload.uploading"))
|
||||
? localize("ui.components.file-upload.uploading_name", {
|
||||
name: this._name,
|
||||
})
|
||||
: localize("ui.components.file-upload.uploading"))
|
||||
}</span
|
||||
>
|
||||
${
|
||||
@@ -149,12 +147,12 @@ export class HaFileUpload extends LitElement {
|
||||
slot="start"
|
||||
.path=${this.icon || mdiFileUpload}
|
||||
></ha-svg-icon>
|
||||
${this.label || this._localize("ui.components.file-upload.label")}
|
||||
${this.label || localize("ui.components.file-upload.label")}
|
||||
</ha-button>
|
||||
<span class="secondary"
|
||||
>${
|
||||
this.secondary ||
|
||||
this._localize("ui.components.file-upload.secondary")
|
||||
localize("ui.components.file-upload.secondary")
|
||||
}</span
|
||||
>
|
||||
<span class="supports">${this.supports}</span>`
|
||||
@@ -168,7 +166,7 @@ export class HaFileUpload extends LitElement {
|
||||
</div>
|
||||
<ha-icon-button
|
||||
@click=${this._clearValue}
|
||||
.label=${this.deleteLabel || this._localize("ui.common.delete")}
|
||||
.label=${this.deleteLabel || localize("ui.common.delete")}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
</div>`
|
||||
@@ -187,8 +185,7 @@ export class HaFileUpload extends LitElement {
|
||||
<ha-icon-button
|
||||
@click=${this._clearValue}
|
||||
.label=${
|
||||
this.deleteLabel ||
|
||||
this._localize("ui.common.delete")
|
||||
this.deleteLabel || localize("ui.common.delete")
|
||||
}
|
||||
.path=${mdiDelete}
|
||||
></ha-icon-button>
|
||||
|
||||
@@ -60,9 +60,6 @@ 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;
|
||||
@@ -230,14 +227,7 @@ export class HaFilterDevices extends LitElement {
|
||||
for (const deviceId of this.value) {
|
||||
value.push(deviceId);
|
||||
if (this.type) {
|
||||
relatedPromises.push(
|
||||
findRelated(
|
||||
this._api,
|
||||
"device",
|
||||
deviceId,
|
||||
this.includeDisabledEntities
|
||||
)
|
||||
);
|
||||
relatedPromises.push(findRelated(this._api, "device", deviceId));
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(relatedPromises);
|
||||
|
||||
@@ -65,9 +65,6 @@ 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;
|
||||
@@ -298,9 +295,7 @@ 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, this.includeDisabledEntities)
|
||||
);
|
||||
relatedPromises.push(findRelated(this._api, "area", areaId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,14 +303,7 @@ 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,
|
||||
this.includeDisabledEntities
|
||||
)
|
||||
);
|
||||
relatedPromises.push(findRelated(this._api, "floor", floorId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import "./ha-list";
|
||||
import "./ha-list-item";
|
||||
import "./voice-assistant-brand-icon";
|
||||
import { voiceAssistants } from "../data/expose";
|
||||
import "../panels/config/voice-assistants/expose/expose-assistant-icon";
|
||||
|
||||
@customElement("ha-filter-voice-assistants")
|
||||
export class HaFilterVoiceAssistants extends LitElement {
|
||||
|
||||
@@ -91,7 +91,7 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
|
||||
@@ -3,6 +3,7 @@ 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,7 +57,6 @@ 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>
|
||||
return html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${
|
||||
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({
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -166,8 +166,8 @@ export class HaIcon extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
const iconPromise = fetch(`${__STATIC_PATH__}mdi/${chunk}.json`).then(
|
||||
(response) => response.json()
|
||||
const iconPromise = fetch(`/static/mdi/${chunk}.json`).then((response) =>
|
||||
response.json()
|
||||
);
|
||||
chunks[chunk] = iconPromise;
|
||||
this._setPath(iconPromise, iconName, requestedIcon);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
import { customElement, property } 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>`;
|
||||
}
|
||||
@@ -23,6 +25,9 @@ class InputHelperText extends LitElement {
|
||||
);
|
||||
line-height: normal;
|
||||
}
|
||||
:host([disabled]) {
|
||||
color: var(--mdc-text-field-disabled-ink-color, rgba(0, 0, 0, 0.6));
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ class HaLabeledSlider extends LitElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text> ${this.helper} </ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}>
|
||||
${this.helper}
|
||||
</ha-input-helper-text>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -28,11 +28,6 @@ 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,7 +239,9 @@ export class HaMediaItemPicker extends LitElement {
|
||||
}
|
||||
${
|
||||
hideEntityPicker && this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -6,7 +6,6 @@ import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
configContext,
|
||||
connectionContext,
|
||||
narrowViewportContext,
|
||||
uiContext,
|
||||
@@ -33,10 +32,6 @@ class HaMenuButton extends LitElement {
|
||||
@consume({ context: uiContext, subscribe: true })
|
||||
private _ui?: ContextType<typeof uiContext>;
|
||||
|
||||
@state()
|
||||
@consume({ context: configContext, subscribe: true })
|
||||
private _config?: ContextType<typeof configContext>;
|
||||
|
||||
@state() private _hasNotifications = false;
|
||||
|
||||
@state() private _show = false;
|
||||
@@ -87,8 +82,7 @@ class HaMenuButton extends LitElement {
|
||||
if (
|
||||
!changedProps.has("_narrow") &&
|
||||
!changedProps.has("_ui") &&
|
||||
!changedProps.has("_connection") &&
|
||||
!changedProps.has("_config")
|
||||
!changedProps.has("_connection")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -100,7 +94,6 @@ class HaMenuButton extends LitElement {
|
||||
|
||||
const showButton =
|
||||
this._ui?.kioskMode === false &&
|
||||
this._config?.auth.external?.config.hasSidebar !== true &&
|
||||
(this._narrow || this._ui.dockedSidebar === "always_hidden");
|
||||
|
||||
this._show = showButton || this._alwaysVisible;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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, property, query, state } from "lit/decorators";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { BOTTOM_SHEET_ANIMATION_DURATION_MS } from "./ha-bottom-sheet";
|
||||
|
||||
@@ -11,80 +11,39 @@ 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;
|
||||
|
||||
private _opened = false;
|
||||
@state() private _dialogMaxViewpointHeight = 70;
|
||||
|
||||
@state() private _dialogMaxViewpointHeight?: number;
|
||||
|
||||
@state() private _dialogMinViewpointHeight?: number;
|
||||
@state() private _dialogMinViewpointHeight = 55;
|
||||
|
||||
@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: ${maxHeight}vh;
|
||||
--max-height: ${maxHeight}dvh;
|
||||
--min-height: ${minHeight}vh;
|
||||
--min-height: ${minHeight}dvh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}vh;
|
||||
--max-height: ${this._dialogMaxViewpointHeight}dvh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}vh;
|
||||
--min-height: ${this._dialogMinViewpointHeight}dvh;
|
||||
`}
|
||||
>
|
||||
<div class="handle-wrapper">
|
||||
@@ -124,9 +83,7 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
this._dialogViewportHeight =
|
||||
(this._dialog.offsetHeight / window.innerHeight) * 100;
|
||||
this._dialogMaxViewpointHeight = 90;
|
||||
this._dialogMinViewpointHeight = this._minViewportHeight();
|
||||
this._opened = true;
|
||||
this._fireResized();
|
||||
this._dialogMinViewpointHeight = 20;
|
||||
} else {
|
||||
// after close animation is done close dialog element and fire closed event
|
||||
this._dialog.close();
|
||||
@@ -145,7 +102,6 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
});
|
||||
document.addEventListener("touchend", this._handleTouchEnd);
|
||||
document.addEventListener("touchcancel", this._handleTouchEnd);
|
||||
window.addEventListener("resize", this._handleViewportResize);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -157,16 +113,8 @@ 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);
|
||||
};
|
||||
@@ -208,10 +156,8 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
let newSize = this._initialSize + deltaVh;
|
||||
newSize = Math.max(10, Math.min(90, newSize));
|
||||
|
||||
if (this.persistent) {
|
||||
newSize = Math.max(this._minViewportHeight(), newSize);
|
||||
} else if (newSize < 20 && deltaY < 0) {
|
||||
// on drag down and below 20vh
|
||||
// on drag down and below 20vh
|
||||
if (newSize < 20 && deltaY < 0) {
|
||||
this._endDrag();
|
||||
this.closeSheet();
|
||||
return;
|
||||
@@ -220,45 +166,6 @@ 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();
|
||||
};
|
||||
@@ -273,8 +180,6 @@ 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`
|
||||
@@ -296,8 +201,7 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 7;
|
||||
/* Extends the grab area over the content below the handle */
|
||||
padding-bottom: var(--ha-bottom-sheet-handle-padding, 76px);
|
||||
padding-bottom: 76px;
|
||||
}
|
||||
.handle-wrapper .handle::after {
|
||||
content: "";
|
||||
@@ -330,20 +234,8 @@ 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% - var(--sheet-side-borders) - var(--sheet-inset-left) - var(
|
||||
--sheet-inset-right
|
||||
)
|
||||
100% - 4px - var(--safe-area-inset-left) - var(--safe-area-inset-right)
|
||||
);
|
||||
max-width: 100%;
|
||||
border: none;
|
||||
@@ -365,14 +257,14 @@ export class HaResizableBottomSheet extends LitElement {
|
||||
);
|
||||
transform: translateY(100%);
|
||||
transition: transform ${BOTTOM_SHEET_ANIMATION_DURATION_MS}ms ease;
|
||||
border-top-width: var(--sheet-border-width);
|
||||
border-right-width: var(--sheet-border-width);
|
||||
border-left-width: var(--sheet-border-width);
|
||||
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-bottom-width: 0;
|
||||
border-style: var(--ha-bottom-sheet-border-style);
|
||||
border-color: var(--ha-bottom-sheet-border-color);
|
||||
margin-left: var(--sheet-inset-left);
|
||||
margin-right: var(--sheet-inset-right);
|
||||
margin-left: var(--safe-area-inset-left);
|
||||
margin-right: var(--safe-area-inset-right);
|
||||
}
|
||||
|
||||
dialog.show {
|
||||
@@ -388,6 +280,5 @@ declare global {
|
||||
|
||||
interface HASSDomEvents {
|
||||
"bottom-sheet-closed": undefined;
|
||||
"bottom-sheet-resized": { height: number };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,7 +164,9 @@ export class HaSelect extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,9 @@ export class HaSelectorAutomationBehavior extends LitElement {
|
||||
></ha-select-box>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -45,11 +45,8 @@ export class HaChooseSelector extends LitElement {
|
||||
}
|
||||
if (
|
||||
changedProperties.has("value") &&
|
||||
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
|
||||
changedProperties.get("value")?.active_choice &&
|
||||
changedProperties.get("value")?.active_choice !== this._activeChoice
|
||||
) {
|
||||
this._setActiveChoice();
|
||||
}
|
||||
@@ -153,14 +150,14 @@ export class HaChooseSelector extends LitElement {
|
||||
if (this.value === null || this.value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return typeof this.value === "object" && "active_choice" in this.value
|
||||
return typeof this.value === "object"
|
||||
? this.value[choice || this.value.active_choice]
|
||||
: this.value;
|
||||
}
|
||||
|
||||
private _setActiveChoice() {
|
||||
if (this.value) {
|
||||
if (typeof this.value === "object" && "active_choice" in this.value) {
|
||||
if (typeof this.value === "object") {
|
||||
if (this.value.active_choice in this.selector.choose.choices) {
|
||||
this._activeChoice = this.value.active_choice;
|
||||
return;
|
||||
@@ -202,22 +199,6 @@ 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,7 +68,9 @@ export class HaDateTimeSelector extends LitElement {
|
||||
</div>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -4,7 +4,6 @@ import memoizeOne from "memoize-one";
|
||||
import type { DurationSelector } from "../../data/selector";
|
||||
import "../ha-duration-input";
|
||||
import type { HaDurationData, HaDurationInput } from "../ha-duration-input";
|
||||
import { createDurationData } from "../../common/datetime/create_duration_data";
|
||||
|
||||
@customElement("ha-selector-duration")
|
||||
export class HaTimeDuration extends LitElement {
|
||||
@@ -28,8 +27,33 @@ export class HaTimeDuration extends LitElement {
|
||||
}
|
||||
|
||||
private _data = memoizeOne(
|
||||
(value?: HaDurationData | string | number): HaDurationData | undefined =>
|
||||
createDurationData(value)
|
||||
(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;
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
|
||||
@@ -127,12 +127,11 @@ export class HaLocationSelector extends LitElement {
|
||||
? this.hass.config.longitude
|
||||
: value.longitude,
|
||||
radius: selector.location?.radius ? value?.radius || 1000 : undefined,
|
||||
radius_color: selector.location?.color || zoneRadiusColor,
|
||||
name: selector.location?.name,
|
||||
radius_color: zoneRadiusColor,
|
||||
icon:
|
||||
selector.location?.icon ||
|
||||
// No icon: show the name's initials, else a default marker
|
||||
(selector.location?.name ? undefined : "mdi:map-marker"),
|
||||
selector.location?.icon || selector.location?.radius
|
||||
? "mdi:map-marker-radius"
|
||||
: "mdi:map-marker",
|
||||
location_editable: true,
|
||||
radius_editable:
|
||||
!!selector.location?.radius && !selector.location?.radius_readonly,
|
||||
|
||||
@@ -143,7 +143,9 @@ export class HaNumberSelector extends LitElement {
|
||||
</div>
|
||||
${
|
||||
!isBox && this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -204,7 +204,9 @@ export class HaObjectSelector extends LitElement {
|
||||
></ha-yaml-editor>
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: ""
|
||||
} `;
|
||||
}
|
||||
|
||||
@@ -268,7 +268,9 @@ export class HaSelectSelector extends LitElement {
|
||||
|
||||
private _renderHelper() {
|
||||
return this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: "";
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,9 @@ ${
|
||||
}
|
||||
${
|
||||
this.helper
|
||||
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
|
||||
? html`<ha-input-helper-text .disabled=${this.disabled}
|
||||
>${this.helper}</ha-input-helper-text
|
||||
>`
|
||||
: nothing
|
||||
}
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user