Compare commits

..
Author SHA1 Message Date
Aidan Timson e20c643dd4 Remove the blocking-label workflow 2026-09-08 14:49:11 +01:00
413 changed files with 5911 additions and 17301 deletions
+1 -6
View File
@@ -7,12 +7,6 @@ description: Home Assistant frontend PR and review guidance, including implement
Use this skill when reviewing Home Assistant frontend changes or preparing a pull request.
## Review Preparation
1. Establish the review scope from the diff and the behavior changed by it.
2. Before identifying findings, use the [project skill catalogue](../../../AGENTS.md#project-skills) to load every matching companion's `SKILL.md`. Follow applicable companion references in those skills as well; this review skill supplies the workflow, and companions supply the domain-specific criteria.
3. Apply all loaded guidance within that scope. Report only problems introduced or worsened by the changeset, anchored to changed lines. Read surrounding code to understand affected behavior, not to generate unrelated cleanup or migration requests.
## Pull Request Body
When creating a pull request, use `.github/PULL_REQUEST_TEMPLATE.md` as the body.
@@ -122,3 +116,4 @@ For user-facing changes, establish the existing design context as part of fronte
- 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.
- Load the matching `ha-frontend-*` skill when a finding falls within its area.
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env node
// Fails the check when a pull request carries a label that blocks merging, and
// writes the outcome to the job summary. Invoked from the `check` job in
// .github/workflows/blocking-labels.yaml via actions/github-script:
//
// const { default: checkBlockingLabels } =
// await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`);
// await checkBlockingLabels({ github, context, core });
export default async function checkBlockingLabels({ context, core }) {
const blockingLabels = [
"wait for backend",
"Needs UX",
"Do Not Review",
"Blocked",
"has-parent",
];
const prLabels = context.payload.pull_request.labels.map((l) => l.name);
const found = blockingLabels.filter((bl) => prLabels.includes(bl));
if (found.length > 0) {
const message = `This Pull Request is blocked by label${found.length > 1 ? "s" : ""}: ${found.join(", ")}`;
await core.summary
.addHeading(":no_entry_sign: Pull Request is blocked", 2)
.addRaw(message)
.write();
core.setFailed(message);
} else {
await core.summary
.addHeading(
":white_check_mark: Pull Request is clear to merge after review",
2
)
.addRaw(
"This Pull Request is not blocked by any labels which prevent it from being merged."
)
.write();
}
}
-35
View File
@@ -1,35 +0,0 @@
name: Blocking labels
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
- unlabeled
branches:
- dev
- master
permissions:
contents: read
jobs:
check:
name: Check for labels which block the Pull Request from being merged
runs-on: ubuntu-latest
steps:
- name: Check out workflow scripts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check for blocking labels
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { default: checkBlockingLabels } = await import(
`${process.env.GITHUB_WORKSPACE}/.github/scripts/check-blocking-labels.mjs`
);
await checkBlockingLabels({ github, context, core });
+1 -1
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.20.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -37,7 +37,7 @@ Never run `tsc` or `yarn lint:types` with file arguments. When `tsc` receives fi
## Project Skills
Detailed guidance lives in `.agents/skills/<name>/SKILL.md`. Load every matching skill before detailed implementation or review. For reviews, load `ha-frontend-review` alongside all companions that apply to the changed code or behavior:
Detailed guidance lives in project skills under `.agents/skills/`. Load the matching skill before detailed implementation or review:
- `ha-frontend-contexts`: Lit contexts, `hass` migration, and rerender-sensitive state access.
- `ha-frontend-components`: dialogs, forms, alerts, shortcuts, tooltips, panels, and Lovelace cards.
-2
View File
@@ -81,8 +81,6 @@ async function copyMapPanel(staticDir) {
npmPath("@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js"),
staticPath("map/")
);
// Controls and popups of the native MapLibre engine
copyFileDir(npmPath("maplibre-gl/dist/maplibre-gl.css"), staticPath("map/"));
}
function copyZXingWasm(staticDir) {
-2
View File
@@ -1,7 +1,6 @@
import { bluetoothFixtures } from "./bluetooth/fixtures";
import { infraredFixtures } from "./infrared/fixtures";
import { matterFixtures } from "./matter/fixtures";
import { modbusFixtures } from "./modbus/fixtures";
import { mqttFixtures } from "./mqtt/fixtures";
import { radioFrequencyFixtures } from "./radio_frequency/fixtures";
import { serialFixtures } from "./serial/fixtures";
@@ -17,7 +16,6 @@ import { zwaveJsFixtures } from "./zwave_js/fixtures";
const INTEGRATIONS: ConnectivityFixtures[] = [
bluetoothFixtures,
serialFixtures,
modbusFixtures,
mqttFixtures,
matterFixtures,
infraredFixtures,
-2
View File
@@ -1,7 +1,6 @@
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { mockBluetooth } from "./bluetooth/mock";
import { mockMatter } from "./matter/mock";
import { mockModbus } from "./modbus/mock";
import { mockMqtt } from "./mqtt/mock";
import { mockRadioFrequency } from "./radio_frequency/mock";
import { mockSerial } from "./serial/mock";
@@ -13,7 +12,6 @@ import { mockZwaveJs } from "./zwave_js/mock";
const MOCKS = [
mockBluetooth,
mockSerial,
mockModbus,
mockMatter,
mockMqtt,
mockZwaveJs,
@@ -1,47 +0,0 @@
import { manifest } from "../../manifest";
import { configEntry } from "../helpers";
import type { ConnectivityFixtures } from "../types";
export const FLEXIT_ENTRY_ID = "mock-flexit";
export const SOLAREDGE_ENTRY_ID = "mock-solaredge";
export const FRONIUS_ENTRY_ID = "mock-fronius";
/** The RS-485 adapter the two serial devices share, as the Serial panel lists it. */
export const RS485_PORT = "/dev/ttyUSB1";
export const modbusFixtures: ConnectivityFixtures = {
components: ["modbus"],
commands: ["modbus/"],
manifests: [
manifest("flexit", "Flexit", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("solaredge_modbus", "SolarEdge Modbus", {
integration_type: "device",
iot_class: "local_polling",
}),
manifest("fronius", "Fronius", {
integration_type: "device",
iot_class: "local_polling",
}),
],
configEntries: [
{
type: "device",
entry: configEntry(FLEXIT_ENTRY_ID, "flexit", "Flexit Nordic S4"),
},
{
type: "device",
entry: configEntry(
SOLAREDGE_ENTRY_ID,
"solaredge_modbus",
"SolarEdge SE7K"
),
},
{
type: "device",
entry: configEntry(FRONIUS_ENTRY_ID, "fronius", "Fronius Symo"),
},
],
};
@@ -1,42 +0,0 @@
import type { ModbusConnection } from "../../../../../src/data/modbus";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
import {
FLEXIT_ENTRY_ID,
FRONIUS_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "./fixtures";
const CONNECTIONS: ModbusConnection[] = [
// Two integrations on one RS-485 bus, which is what the panel exists to show
{
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) => {
hass.mockWS("modbus/connections/list", () => ({ connections: CONNECTIONS }));
};
+2 -26
View File
@@ -1,12 +1,5 @@
import type { SerialPortUsage } from "../../../../../src/data/usb";
import type { MockHomeAssistant } from "../../../../../src/fake_data/provide_hass";
// The RS-485 port below carries the Modbus connections, so both sides name the
// same config entries.
import {
FLEXIT_ENTRY_ID,
RS485_PORT,
SOLAREDGE_ENTRY_ID,
} from "../modbus/fixtures";
const PORTS: SerialPortUsage[] = [
{
@@ -62,7 +55,7 @@ const PORTS: SerialPortUsage[] = [
discovery_flows: [],
},
{
device: RS485_PORT,
device: "/dev/ttyUSB1",
resolved_device:
"/dev/serial/by-id/usb-FTDI_FT232R_USB_UART_A50285BI-if00-port0",
serial_number: "A50285BI",
@@ -75,24 +68,7 @@ const PORTS: SerialPortUsage[] = [
bcd_device: 1536,
matching_integrations: [],
present: true,
consumers: [
{
kind: "config_entry",
title: "Flexit Nordic S4",
active: true,
domain: "flexit",
config_entry_id: FLEXIT_ENTRY_ID,
slug: null,
},
{
kind: "config_entry",
title: "SolarEdge SE7K",
active: true,
domain: "solaredge_modbus",
config_entry_id: SOLAREDGE_ENTRY_ID,
slug: null,
},
],
consumers: [],
discovery_flows: [],
},
{
-6
View File
@@ -358,12 +358,6 @@ export const mockHassioSupervisor = (hass: MockHomeAssistant) => {
return data;
}
if (msg.endpoint === "/ingress/panels") {
// The navigation picker waits for this collection before it enables
// itself, so it has to answer even when no add-on exposes a panel.
return { panels: {} };
}
if (msg.endpoint === "/store/reload") {
return null;
}
+1
View File
@@ -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";
+2
View File
@@ -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 {
+4 -2
View File
@@ -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. |
+1 -257
View File
@@ -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}
+3 -4
View File
@@ -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,
},
},
];
+2 -82
View File
@@ -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 {
-55
View File
@@ -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"),
});
}
}
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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() {
+3 -6
View File
@@ -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,
};
}
+3 -4
View File
@@ -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}
+3 -6
View File
@@ -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>
+21 -19
View File
@@ -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,
};
};
-6
View File
@@ -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();
}
};
+33 -131
View File
@@ -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,
};
-34
View File
@@ -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[];
+1 -2
View File
@@ -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",
+1 -1
View File
@@ -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":
+9 -11
View File
@@ -12,14 +12,14 @@ import {
// Generated by build-scripts/gulp/map-assets.js. The attribution comes from the
// TileJSON, deliberately: it follows whoever serves the tiles.
export const VECTOR_STYLES = {
const VECTOR_STYLES = {
light: "/static/map/light.json",
dark: "/static/map/dark.json",
} as const;
// Without it Arabic and Hebrew labels render reversed. Loaded by MapLibre's
// worker, hence a URL rather than an import.
export const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
const RTL_TEXT_PLUGIN_URL = "/static/map/mapbox-gl-rtl-text.js";
// MapLibre needs WebGL2 even for raster, so the fallback stays a Leaflet layer.
// OSM serves no @2x variant.
@@ -33,8 +33,8 @@ const OSM_ATTRIBUTION =
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
export const CONTEXT_RESTORE_GRACE = 2000;
export const RECOVERY_THROTTLE = 30000;
const CONTEXT_RESTORE_GRACE = 2000;
const RECOVERY_THROTTLE = 30000;
// On the map, not the layer: marker clustering throws without a maximum. The
// floor is 1 because at Leaflet zoom 0 the adapter drives MapLibre to -1.
@@ -55,7 +55,7 @@ export interface MapBaseLayer {
let webGL2Supported: boolean | undefined;
// Rules out iOS below 15, older Android tablets and blocklisted drivers.
export const supportsWebGL2 = (): boolean => {
const supportsWebGL2 = (): boolean => {
if (webGL2Supported === undefined) {
try {
const context = document.createElement("canvas").getContext("webgl2");
@@ -91,7 +91,7 @@ const useDemoUpstream = (style: StyleSpecification): StyleSpecification => {
return style;
};
export const loadStyle = async (url: string): Promise<StyleSpecification> => {
const loadStyle = async (url: string): Promise<StyleSpecification> => {
const style: StyleSpecification = await (await fetch(url)).json();
if (__DEMO__) {
@@ -111,7 +111,7 @@ export const loadStyle = async (url: string): Promise<StyleSpecification> => {
// Global to MapLibre, and it throws when set twice.
let rtlTextPluginRequested = false;
export const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
const ensureRTLTextPlugin = (setPlugin: typeof setRTLTextPlugin) => {
if (rtlTextPluginRequested) {
return;
}
@@ -303,11 +303,9 @@ export const createBaseLayer = async (
leaflet: LeafletModuleType,
map: LeafletMap,
darkMode: boolean,
token: string | undefined,
// Skip the vector layer, e.g. after a permanent WebGL context loss
rasterOnly = false
token: string | undefined
): Promise<MapBaseLayer> => {
if (!rasterOnly && supportsWebGL2()) {
if (supportsWebGL2()) {
let vectorLayer: MapBaseLayer | undefined;
try {
const [{ maplibreGL: createLayer }, maplibre] = await Promise.all([
-59
View File
@@ -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);
}
`;
@@ -1,348 +0,0 @@
import type {
CircleMarker,
Control,
Map,
MarkerClusterGroup,
Polyline,
} from "leaflet";
import type { LeafletModuleType } from "../../dom/setup-leaflet-map";
import type { MapBaseLayer } from "../base-layer";
import { createBaseLayer, MAP_MAX_ZOOM, MAP_MIN_ZOOM } from "../base-layer";
import { DecoratedMarker } from "../decorated_marker";
import { isTouch } from "../../../util/is_touch";
import type {
MapClusterOptions,
MapCircleOptions,
MapControlPosition,
MapEngine,
MapEngineOptions,
MapFitOptions,
MapItemHandle,
MapLatLng,
MapMarkerHandle,
MapMarkerOptions,
MapPath,
} from "../map-engine";
import { setMarkerAccessibility } from "../marker-accessibility";
/** A leaflet marker that knows the engine handle it was created for */
interface HandledMarker extends DecoratedMarker {
engineHandle?: LeafletMarkerHandle;
}
interface LeafletMarkerHandle extends MapMarkerHandle {
marker: HandledMarker;
}
/** The Leaflet engine: raster viewing fallback without WebGL2, no editing */
export class LeafletMapEngine implements MapEngine {
/** For the ha-map jsdom tests only */
public leafletMap?: Map;
public Leaflet?: LeafletModuleType;
private _baseLayer?: MapBaseLayer;
private _clusterable: HandledMarker[] = [];
private _cluster?: MarkerClusterGroup;
private _clusterOptions: MapClusterOptions | null = null;
private _scaleControl?: Control.Scale;
public async init(
container: HTMLElement,
options: MapEngineOptions
): Promise<void> {
const root = container.parentNode;
if (!root) {
throw new Error("Cannot set up a Leaflet map on a detached element");
}
// eslint-disable-next-line
const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(container, {
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
});
map.attributionControl.setPrefix("");
for (const href of [
"/static/images/leaflet/leaflet.css",
"/static/images/leaflet/MarkerCluster.css",
]) {
const style = document.createElement("link");
style.setAttribute("href", href);
style.setAttribute("rel", "stylesheet");
root.appendChild(style);
}
map.setView(options.center, options.zoom);
// The base layer adds itself; a vector layer may still fall back to raster
this._baseLayer = await createBaseLayer(
Leaflet,
map,
options.darkMode,
options.token,
options.rasterOnly ?? false
);
this.leafletMap = map;
this.Leaflet = Leaflet;
map.zoomControl?.setPosition(options.zoomControlPosition);
const { events } = options;
if (events.click) {
map.on("click", (ev) => {
events.click!([ev.latlng.lat, ev.latlng.lng]);
});
}
if (events.zoomStart) {
map.on("zoomstart", () => events.zoomStart!());
}
if (events.moveStart) {
map.on("movestart", () => events.moveStart!());
}
}
public destroy(): void {
this.leafletMap?.remove();
this.leafletMap = undefined;
this.Leaflet = undefined;
this._baseLayer = undefined;
this._cluster = undefined;
this._clusterable = [];
this._scaleControl = undefined;
}
public invalidateSize(): void {
this.leafletMap?.invalidateSize({ debounceMoveend: true });
}
public hasUsableSize(): boolean {
if (!this.leafletMap) {
return false;
}
const size = this.leafletMap.getSize();
if (size.x > 0 && size.y > 0) {
return true;
}
const container = this.leafletMap.getContainer();
if (container.clientWidth > 0 && container.clientHeight > 0) {
// The container was laid out since Leaflet last measured it
this.leafletMap.invalidateSize(false);
return true;
}
return false;
}
public setDarkMode(darkMode: boolean): void {
this._baseLayer?.setDarkMode(darkMode);
}
public setZoomControlPosition(position: MapControlPosition): void {
this.leafletMap?.zoomControl?.setPosition(position);
}
public setScaleRuler(options: { metric: boolean } | null): void {
if (this._scaleControl) {
this.leafletMap?.removeControl(this._scaleControl);
this._scaleControl = undefined;
}
if (!options || !this.leafletMap || !this.Leaflet) {
return;
}
this._scaleControl = this.Leaflet.control.scale({
position: "bottomleft",
metric: options.metric,
imperial: !options.metric,
});
this._scaleControl.addTo(this.leafletMap!);
}
public setView(center: MapLatLng, zoom?: number): void {
this.leafletMap?.setView(center, zoom);
}
public setZoom(zoom: number): void {
this.leafletMap?.setZoom(zoom);
}
private _getZoom(): number {
return this.leafletMap?.getZoom() ?? 0;
}
private _project(location: MapLatLng): { x: number; y: number } {
const point = this.leafletMap!.project(location, this._getZoom());
return { x: point.x, y: point.y };
}
public fitBounds(points: MapLatLng[], options?: MapFitOptions): void {
if (!this.leafletMap || !this.Leaflet || !points.length) {
return;
}
const bounds = this.Leaflet.latLngBounds(points).pad(options?.pad ?? 0.5);
this.leafletMap.fitBounds(bounds, {
maxZoom: options?.maxZoom,
animate: options?.animate,
});
}
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,
options: MapMarkerOptions
): MapMarkerHandle {
const decoration = options.decoration
? this.Leaflet!.circle(location, {
interactive: false,
color: options.decoration.color,
radius: options.decoration.radius,
})
: undefined;
// 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);
const marker: HandledMarker = new DecoratedMarker(location, decoration, {
icon: this.Leaflet!.divIcon({
html: element,
iconSize: options.size,
iconAnchor: options.anchor,
className: "",
}),
interactive,
keyboard: false,
title: options.title,
});
const handle: LeafletMarkerHandle = {
marker,
location,
clusterData: options.clusterData,
remove: () => {
this._cluster?.removeLayer(marker);
marker.remove();
const index = this._clusterable.indexOf(marker);
if (index !== -1) {
this._clusterable.splice(index, 1);
}
},
};
marker.engineHandle = handle;
if (options.cluster) {
// Placed on the map by the next setClustering call
this._clusterable.push(marker);
} else {
marker.addTo(this.leafletMap!);
}
return handle;
}
public addCircle(
center: MapLatLng,
options: MapCircleOptions
): MapItemHandle {
const circle = this.Leaflet!.circle(center, {
interactive: false,
color: options.color,
radius: options.radius,
}).addTo(this.leafletMap!);
return { remove: () => circle.remove() };
}
public addPath(path: MapPath): MapItemHandle {
const items: (Polyline | CircleMarker)[] = [];
for (const segment of path.segments) {
items.push(
this.Leaflet!.polyline(segment.points, {
color: path.color,
opacity: segment.opacity,
interactive: false,
})
);
}
for (const pathMarker of path.markers) {
items.push(
this.Leaflet!.circleMarker(pathMarker.location, {
radius: isTouch ? 8 : 3,
color: path.color,
opacity: pathMarker.opacity,
fillOpacity: pathMarker.opacity,
interactive: true,
}).bindTooltip(pathMarker.tooltipHtml, { direction: "top" })
);
}
items.forEach((item) => item.addTo(this.leafletMap!));
return { remove: () => items.forEach((item) => item.remove()) };
}
public setClustering(options: MapClusterOptions | null): void {
if (this._cluster) {
this._cluster.remove();
this._cluster = undefined;
}
this._clusterOptions = options;
if (!this.leafletMap || !this.Leaflet) {
return;
}
if (!options) {
this._clusterable.forEach((marker) => marker.addTo(this.leafletMap!));
return;
}
// markercluster groups by proximity only; groupKey is not supported here
this._cluster = this.Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: options.radius,
iconCreateFunction: (cluster) => {
const members = (cluster.getAllChildMarkers() as HandledMarker[]).map(
(marker) => marker.engineHandle!
);
const latLng = cluster.getLatLng();
const icon = this._clusterOptions!.iconBuilder(members, [
latLng.lat,
latLng.lng,
]);
// The element fills the divIcon wrapper, which gets the size
icon.element.style.width = `${icon.size[0]}px`;
icon.element.style.height = `${icon.size[1]}px`;
// markercluster pins icons to the cluster, so a location override becomes an anchor shift
let anchor = icon.anchor;
if (icon.location) {
const clusterPoint = this._project([latLng.lat, latLng.lng]);
const targetPoint = this._project(icon.location);
const base = anchor ?? [icon.size[0] / 2, icon.size[1] / 2];
anchor = [
base[0] - (targetPoint.x - clusterPoint.x),
base[1] - (targetPoint.y - clusterPoint.y),
];
}
return this.Leaflet!.divIcon({
html: icon.element,
iconSize: icon.size,
iconAnchor: anchor,
className: "",
});
},
});
this._cluster.addLayers(this._clusterable);
this.leafletMap!.addLayer(this._cluster!);
}
public refreshClusters(): void {
this._cluster?.refreshClusters();
}
}
File diff suppressed because it is too large Load Diff
-302
View File
@@ -1,302 +0,0 @@
/**
* 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`.
*
* Positions are [latitude, longitude]; zoom levels use Leaflet semantics.
*/
export type MapLatLng = [latitude: number, longitude: number];
export type MapControlPosition =
"topleft" | "topright" | "bottomleft" | "bottomright";
export interface MapEngineEvents {
/** Click on the map surface, not on a marker */
click(location: MapLatLng): void;
/** Zoom is starting, programmatic or not */
zoomStart(): void;
/** The map starts moving, programmatic or not */
moveStart(): void;
/** The engine can no longer render; the host switches to the fallback */
fatal(): void;
}
export interface MapEngineOptions {
center: MapLatLng;
zoom: number;
darkMode: boolean;
/** Token for core's tile proxy */
token?: string;
zoomControlPosition: MapControlPosition;
/** Render without WebGL after a permanent context loss; WebGL engines reject init */
rasterOnly?: boolean;
events: Partial<MapEngineEvents>;
}
export interface MapFitOptions {
/** Do not zoom in beyond this level even if the bounds would allow it */
maxZoom?: number;
/** Relative padding around the bounds, e.g. 0.5 grows them by 50% */
pad?: number;
/** Ease the camera to the bounds instead of jumping; defaults to true */
animate?: boolean;
}
export interface MapMarkerOptions {
/** Rendered size of the element in pixels */
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 */
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) */
decoration?: MapCircleOptions;
/** Cluster this marker; it appears once setClustering is called */
cluster?: boolean;
/** Caller data handed back to the cluster icon builder */
clusterData?: unknown;
}
export interface MapCircleOptions {
/** Radius in meters */
radius: number;
/** Stroke color; the fill is derived from it, translucent */
color: string;
}
export interface MapPathSegment {
points: MapLatLng[];
opacity?: number;
}
export interface MapPathMarker {
location: MapLatLng;
opacity?: number;
/** Tooltip/popup HTML shown on hover; caller is responsible for escaping */
tooltipHtml: string;
}
export interface MapPath {
color: string;
segments: MapPathSegment[];
markers: MapPathMarker[];
}
/** Handle to anything placed on the map; remove() must be idempotent */
export interface MapItemHandle {
remove(): void;
}
export interface MapMarkerHandle extends MapItemHandle {
readonly location: MapLatLng;
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];
/** Like MapMarkerOptions.anchor; defaults to the element's center */
anchor?: [x: number, y: number];
/** Show the icon here instead of at the cluster, e.g. attached to a zone */
location?: MapLatLng;
}
export interface MapClusterOptions {
/** Cluster markers closer than this many screen pixels */
radius: number;
/**
* Markers sharing a key (e.g. their zone) form one group while they span
* at most groupRadius pixels; beyond that, and without a key, they cluster
* by proximity.
*/
groupKey?(marker: MapMarkerHandle): string | undefined;
groupRadius?: number;
/** Builds a cluster's element; called when its members change and on refreshClusters() */
iconBuilder(members: MapMarkerHandle[], location: MapLatLng): MapClusterIcon;
}
export interface MapEngine {
/** Create the map in the container; call once */
init(container: HTMLElement, options: MapEngineOptions): Promise<void>;
/** Tear down the map and release its resources (DOM, workers, WebGL) */
destroy(): void;
/** Re-measure the container after a size change */
invalidateSize(): void;
/** Whether the map has a non-zero size, re-measuring if needed */
hasUsableSize(): boolean;
setDarkMode(darkMode: boolean): void;
setZoomControlPosition(position: MapControlPosition): void;
/** Show a scale ruler (bottom start); null hides it */
setScaleRuler(options: { metric: boolean } | null): void;
setView(center: MapLatLng, zoom?: number): void;
setZoom(zoom: number): void;
/** 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;
/** Whether the location is inside the current viewport */
containsLocation(location: MapLatLng): boolean;
/** Place a caller-owned element on the map */
addMarker(
element: HTMLElement,
location: MapLatLng,
options: MapMarkerOptions
): MapMarkerHandle;
/** 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 */
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.
*/
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));
return [
[latMin, center[1] - dLng],
[latMax, center[1] + dLng],
];
};
-53
View File
@@ -1,53 +0,0 @@
/**
* Marker elements may be 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
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
if (!element.hasAttribute("role")) {
if (focusable) {
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];
};
+3 -1
View File
@@ -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,155 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import { mdiCommentTextOutline } from "@mdi/js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { truncateWithEllipsis } from "../../common/string/truncate-with-ellipsis";
import type { Condition } from "../../data/automation";
import type { ConditionDescription } from "../../data/condition";
import { internationalizationContext } from "../../data/context";
import "../../panels/config/automation/ha-automation-row-behavior";
import "../../panels/config/automation/ha-automation-row-options";
import { getDeviceTarget } from "../../panels/config/automation/target/get_device_target";
import { getEntityTarget } from "../../panels/config/automation/target/get_entity_target";
import "../../panels/config/automation/target/ha-automation-row-targets";
import "../ha-svg-icon";
import "../ha-tooltip";
@customElement("ha-automation-condition-summary")
export class HaAutomationConditionSummary extends LitElement {
@property() public label = "";
@property({ attribute: false }) public condition?: Condition;
@property({ attribute: false }) public description?: ConditionDescription;
@property({ attribute: false }) public isNew = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
private _getEntityTarget = memoizeOne(getEntityTarget);
private _getDeviceTarget = memoizeOne(getDeviceTarget);
protected render() {
const descriptionHasTarget = "target" in (this.description || {});
const hasEntityTarget =
this.condition?.condition === "state" ||
this.condition?.condition === "numeric_state";
const targetRequired =
(descriptionHasTarget || hasEntityTarget) && !this.isNew;
const note = this.condition?.note?.trim();
let target: HassServiceTarget | undefined;
if (this.condition) {
if (descriptionHasTarget && "target" in this.condition) {
target = this.condition.target;
} else if (
hasEntityTarget &&
"entity_id" in this.condition &&
this.condition.entity_id
) {
target = this._getEntityTarget(this.condition.entity_id);
} else if ("device_id" in this.condition && this.condition.device_id) {
target = this._getDeviceTarget(this.condition.device_id);
}
}
return html`
<h3>
${this.label}
${
this.description && this.condition
? html`<ha-automation-row-behavior
mode="condition"
.config=${this.condition}
></ha-automation-row-behavior>`
: nothing
}
${
target !== undefined || targetRequired
? html`<ha-automation-row-targets
.target=${target}
.targetRequired=${targetRequired}
.selector=${
this.description?.target
? { target: this.description.target }
: undefined
}
.interactive=${this.condition?.condition !== "device"}
></ha-automation-row-targets>`
: nothing
}
${
this.description && this.condition
? html`<ha-automation-row-options
.config=${this.condition}
></ha-automation-row-options>`
: nothing
}
<slot name="references"></slot>
${
note
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
role="img"
.path=${mdiCommentTextOutline}
aria-label=${this._i18n.localize(
"ui.panel.config.automation.editor.note.label"
)}
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"
><p>${truncateWithEllipsis(note, 250)}</p></ha-tooltip
>
`
: nothing
}
</h3>
`;
}
static styles = css`
:host {
display: block;
min-width: 0;
}
h3 {
margin: 0;
font-size: inherit;
font-weight: inherit;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--ha-space-2);
padding: var(--ha-space-2) 0;
min-height: 32px;
max-width: 100%;
}
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
ha-tooltip {
cursor: default;
}
ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-condition-summary": HaAutomationConditionSummary;
}
}
+16 -54
View File
@@ -43,8 +43,6 @@ const OVERFLOW_MARGIN = 5;
const FONT_SIZE = 12;
const NODE_GAP = 6;
const LABEL_DISTANCE = 5;
const LABEL_MIN_MARGIN = 5;
const BIDI_MARKS = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
@customElement("ha-sankey-chart")
export class HaSankeyChart extends LitElement {
@@ -57,9 +55,6 @@ export class HaSankeyChart extends LitElement {
@property({ type: Boolean }) public vertical = false;
@property({ type: Boolean, attribute: "show-values" }) public showValues =
false;
@property({ attribute: false }) public valueFormatter?: (
value: number
) => string;
@@ -89,11 +84,7 @@ export class HaSankeyChart extends LitElement {
return html`<ha-chart-base
.hass=${this.hass}
.data=${this._createData(
this.data,
this._sizeController.value?.width,
this.showValues
)}
.data=${this._createData(this.data, this._sizeController.value?.width)}
.options=${options}
height="100%"
.extraComponents=${[SankeyChart]}
@@ -151,11 +142,7 @@ export class HaSankeyChart extends LitElement {
}
};
private _computeData = (
data: SankeyChartData,
width = 0,
showValues = false
) => {
private _createData = memoizeOne((data: SankeyChartData, width = 0) => {
const filteredNodes = data.nodes.filter((n) => n.value > 0);
const indexes = [...new Set(filteredNodes.map((n) => n.index))].sort();
const depthMap = new Map<number, number>();
@@ -196,10 +183,6 @@ export class HaSankeyChart extends LitElement {
const links = this._processLinks(filteredNodes, data.links);
const sectionWidth = width / indexes.length;
const labelSpace = sectionWidth - NODE_SIZE - LABEL_DISTANCE;
// Two-line values can wrap the unit onto a third line; keep that text inside the chart.
const verticalBottom = showValues
? LABEL_DISTANCE + FONT_SIZE * 3 + OVERFLOW_MARGIN
: 25;
return {
id: "sankey",
@@ -226,48 +209,29 @@ export class HaSankeyChart extends LitElement {
layoutIterations: 0,
animationDuration: 500,
label: {
formatter: (params) => {
const nodeData = params.data as { id: string; value: number };
const node = data.nodes.find((n) => n.id === nodeData.id);
const label = node?.label ?? nodeData.id;
if (!showValues || !nodeData.id) return label;
const formatted = this.valueFormatter
? this.valueFormatter(nodeData.value).trim()
: String(nodeData.value);
// LRM keeps numeric values LTR on the canvas without creating wrap points.
return `${label}\n\u200E${formatted}`;
},
formatter: (params) =>
data.nodes.find((node) => node.id === (params.data as Node).id)
?.label ?? (params.data as Node).id,
position: this.vertical ? "bottom" : "right",
distance: LABEL_DISTANCE,
minMargin: LABEL_MIN_MARGIN,
minMargin: 5,
overflow: "break",
},
labelLayout: (params) => {
if (this.vertical) {
// reduce the label font size so the longest word fits on one line
const longestWord = params.text
.replace(BIDI_MARKS, "")
.split(/[ \n]+/)
.reduce((longest, current) => {
if (!current) {
return longest;
}
if (!longest) {
return current;
}
return measureTextWidth(current, FONT_SIZE) >
measureTextWidth(longest, FONT_SIZE)
? current
: longest;
}, "");
const wordWidth = measureTextWidth(longestWord, FONT_SIZE) || 1;
.split(" ")
.reduce(
(longest, current) =>
longest.length > current.length ? longest : current,
""
);
const wordWidth = measureTextWidth(longestWord, FONT_SIZE);
const availableWidth = (params.rect.width + 6) * this._currentZoom;
// minMargin is applied as padding on the label box, so words must
// fit in the inner wrap width or overflow:break splits them.
const wrapWidth = Math.max(availableWidth - LABEL_MIN_MARGIN, 1);
const fontSize = Math.min(
FONT_SIZE,
(wrapWidth / wordWidth) * FONT_SIZE
(availableWidth / wordWidth) * FONT_SIZE
);
return {
fontSize: fontSize > 1 ? fontSize : 0,
@@ -290,16 +254,14 @@ export class HaSankeyChart extends LitElement {
};
},
top: this.vertical ? 0 : OVERFLOW_MARGIN,
bottom: this.vertical ? verticalBottom : OVERFLOW_MARGIN,
bottom: this.vertical ? 25 : OVERFLOW_MARGIN,
left: this.vertical ? OVERFLOW_MARGIN : 0,
right: this.vertical ? OVERFLOW_MARGIN : labelSpace + LABEL_DISTANCE,
emphasis: {
focus: "adjacency",
},
} as SankeySeriesOption;
};
private _createData = memoizeOne(this._computeData);
});
private _processLinks(nodes: Node[], rawLinks: Link[]) {
const accountedIn = new Map<string, number>();
@@ -23,8 +23,6 @@ import { measureTextWidth } from "../../util/text";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
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")
@@ -43,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;
@@ -78,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}
@@ -199,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")
) {
@@ -211,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,
@@ -244,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,
@@ -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}
@@ -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%;
+1 -47
View File
@@ -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);
}
-2
View File
@@ -253,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];
-8
View File
@@ -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
+11 -12
View File
@@ -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;
}
+45 -27
View File
@@ -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,
},
};
}
+13 -41
View File
@@ -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;
}
+19 -4
View File
@@ -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,
+3 -1
View File
@@ -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
}
`;
@@ -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;
}
-12
View File
@@ -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"),
+32 -55
View File
@@ -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);
}
+12 -15
View File
@@ -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>
+1 -11
View File
@@ -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);
+2 -14
View File
@@ -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 {
+1 -1
View File
@@ -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";
-1
View File
@@ -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")
+1 -1
View File
@@ -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> ${
-16
View File
@@ -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;
}
}
-16
View File
@@ -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;
}
}
-52
View File
@@ -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;
}
}
-52
View File
@@ -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;
}
}
+6 -1
View File
@@ -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));
}
`;
}
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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
}
`;
+26
View File
@@ -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;
}
}
+36
View File
@@ -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;
}
}
+3 -1
View File
@@ -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() {
@@ -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
}
`;
+42
View File
@@ -0,0 +1,42 @@
import { SubMenu } from "@material/web/menu/internal/submenu/sub-menu";
import { styles } from "@material/web/menu/internal/submenu/sub-menu-styles.cssresult.js";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-sub-menu")
export class HaSubMenu extends SubMenu {
async show() {
super.show();
this.menu.hasOverflow = false;
}
static override styles = [
styles,
css`
:host {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-primary: var(--primary-text-color);
--md-sys-color-secondary: var(--secondary-text-color);
--md-sys-color-surface: var(--card-background-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--secondary-text-color);
--md-sys-color-secondary-container: rgba(
var(--rgb-primary-color),
0.15
);
--md-sys-color-on-secondary-container: var(--text-primary-color);
--mdc-icon-size: 16px;
--md-sys-color-on-primary-container: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-sub-menu": HaSubMenu;
}
}
+3 -1
View File
@@ -208,7 +208,9 @@ class HaInputMulti 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
}
`;
-48
View File
@@ -77,7 +77,6 @@ export type InputType =
* @attr {boolean} invalid - Marks the input as invalid.
* @attr {boolean} inset-label - Uses an inset label style where the label stays inside the input.
* @attr {string} validation-message - Custom validation message shown when the input is invalid.
* @attr {string} input-id - Sets the native input's `id` (and associated label `for`) on the inner `wa-input`.
*/
@customElement("ha-input")
export class HaInput extends WaInputMixin(LitElement) {
@@ -125,10 +124,6 @@ export class HaInput extends WaInputMixin(LitElement) {
@property({ type: Boolean, attribute: "inset-label" })
public insetLabel = false;
/** Sets the native input id (and label `for`) on the inner `wa-input`. */
@property({ attribute: "input-id" })
public inputId?: string;
@query("wa-input")
private _input?: WaInput;
@@ -165,39 +160,6 @@ export class HaInput extends WaInputMixin(LitElement) {
this._input?.stepDown();
}
/**
* Copies a native input value that was written without `input`/`change` events
* (password-manager autofill) into the component so labels and form data update.
*/
public syncFromNativeInput(): boolean {
const native = this._getNativeInput();
if (!native) {
return false;
}
const nativeValue = native.value;
if ((this.value ?? "") === nativeValue) {
return false;
}
if (this._input) {
this._input.value = nativeValue;
}
this._handleInput();
this.dispatchEvent(new Event("input", { bubbles: true, composed: true }));
return true;
}
public override reportValidity(): boolean {
this.syncFromNativeInput();
return super.reportValidity();
}
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener("focusin", this._syncFromNativeInput);
}
protected override async firstUpdated(
changedProperties: PropertyValues<this>
): Promise<void> {
@@ -213,7 +175,6 @@ export class HaInput extends WaInputMixin(LitElement) {
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("focusin", this._syncFromNativeInput);
this._startSlotResizeObserver?.disconnect();
}
@@ -254,7 +215,6 @@ export class HaInput extends WaInputMixin(LitElement) {
.inputmode=${this.inputmode || undefined}
.name=${this.name}
.disabled=${this.disabled}
.inputId=${this.inputId || "input"}
class=${classMap({
input: true,
invalid: this.invalid || this._invalid,
@@ -386,14 +346,6 @@ export class HaInput extends WaInputMixin(LitElement) {
this.passwordVisible = !this.passwordVisible;
}
private _getNativeInput(): HTMLInputElement | undefined {
return this._input?.input ?? undefined;
}
private _syncFromNativeInput = (): void => {
this.syncFromNativeInput();
};
static styles = [
waInputStyles,
css`
-18
View File
@@ -58,24 +58,6 @@ class HaEntityMarker extends LitElement {
`;
}
public connectedCallback() {
super.connectedCallback();
this.addEventListener("keydown", this._handleKeydown);
}
public disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeydown);
}
// The map engines make the marker a focusable button
private _handleKeydown = (ev: KeyboardEvent) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._badgeTap(ev);
}
};
private _badgeTap(ev: Event) {
ev.stopPropagation();
if (this.entityId) {
+234 -160
View File
@@ -1,19 +1,22 @@
import { consume } from "@lit/context";
import type {
Circle,
DivIcon,
DragEndEvent,
LatLng,
LatLngExpression,
Marker,
MarkerOptions,
} from "leaflet";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import type { LeafletModuleType } from "../../common/dom/setup-leaflet-map";
import type { ThemeMode } from "../../types";
import "../ha-input-helper-text";
import "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaMap } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
@@ -38,16 +41,8 @@ export interface MarkerLocation {
radius_color?: string;
location_editable?: boolean;
radius_editable?: boolean;
/** Clicking or activating the marker fires marker-clicked */
clickable?: boolean;
}
const ICON_SIZE = 24;
/**
* A map with draggable markers and zone circles, drawn by ha-map. Without
* editing support (the Leaflet fallback) they are static, with a notice.
*/
@customElement("ha-locations-editor")
export class HaLocationsEditor extends LitElement {
@property({ attribute: false }) public locations?: MarkerLocation[];
@@ -64,20 +59,35 @@ export class HaLocationsEditor extends LitElement {
@property({ type: Boolean, attribute: "pin-on-click" })
public pinOnClick = false;
@state() private _locationMarkers?: Record<string, Marker | Circle>;
@state() private _circles: Record<string, Circle> = {};
@query("ha-map", true) private map!: HaMap;
@state() private _editingAvailable = true;
private Leaflet?: LeafletModuleType;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type
private _loadPromise: Promise<boolean | undefined | void>;
constructor() {
super();
this._loadPromise = import("leaflet").then((module) =>
import("leaflet-draw").then(() => {
this.Leaflet = module.default as LeafletModuleType;
this._updateMarkers();
return this.updateComplete.then(() => this.fitMap());
})
);
}
public fitMap(options?: { zoom?: number; pad?: number }): void {
this.map.fitMap(options);
}
public fitBounds(
boundingbox: MapLatLng[],
boundingbox: LatLngExpression[],
options?: { zoom?: number; pad?: number }
) {
this.map.fitBounds(boundingbox, options);
@@ -87,50 +97,42 @@ export class HaLocationsEditor extends LitElement {
id: string,
options?: { zoom?: number }
): Promise<void> {
await this.updateComplete;
const location = this.locations?.find((loc) => loc.id === id);
if (!location) {
if (!this.Leaflet) {
await this._loadPromise;
}
if (!this.map.leafletMap || !this._locationMarkers) {
return;
}
const center: MapLatLng = [location.latitude, location.longitude];
if (location.radius) {
// Only the map's maximum caps the zoom, however small the zone
this.map.fitBounds(circleBoundsPoints(center, location.radius), {
pad: 0,
zoom: MAP_MAX_ZOOM,
});
const marker = this._locationMarkers[id];
if (!marker) {
return;
}
if ("getBounds" in marker) {
this.map.leafletMap.fitBounds(marker.getBounds());
(marker as Circle).bringToFront();
} else {
this.map.setView(center, options?.zoom || this.zoom);
const circle = this._circles[id];
if (circle) {
this.map.leafletMap.fitBounds(circle.getBounds());
} else {
this.map.leafletMap.setView(
marker.getLatLng(),
options?.zoom || this.zoom
);
}
}
}
protected render(): TemplateResult {
return html`
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
@editable-location-moved=${this._locationMoved}
@editable-location-resized=${this._radiusChanged}
@editable-location-clicked=${this._markerClicked}
@editing-available-changed=${this._editingAvailableChanged}
></ha-map>
${
!this._editingAvailable &&
this._i18n &&
this.locations?.some(
(location) => location.location_editable || location.radius_editable
)
? html`<ha-alert alert-type="warning">
${this._i18n.localize("ui.components.map.editing_unavailable")}
</ha-alert>`
: ""
}
</div>
<ha-map
.layers=${this._getLayers(this._circles, this._locationMarkers)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
.clickable=${this.pinOnClick}
@map-clicked=${this._mapClicked}
></ha-map>
${
this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
@@ -139,102 +141,63 @@ export class HaLocationsEditor extends LitElement {
`;
}
private _editableLocations = memoizeOne(
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
this._elements.delete(id);
}
private _getLayers = memoizeOne(
(
circles: Record<string, Circle>,
markers?: Record<string, Marker | Circle>
): (Marker | Circle)[] => {
const layers: (Marker | Circle)[] = [];
Array.prototype.push.apply(layers, Object.values(circles));
if (markers) {
Array.prototype.push.apply(layers, Object.values(markers));
}
return (locations ?? []).map((location) => ({
id: location.id,
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
radiusEditable: location.radius_editable,
activatable: location.clickable,
}));
return layers;
}
);
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
public willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const key = JSON.stringify([
location.icon,
location.iconPath,
location.name,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
// Still loading.
if (!this.Leaflet) {
return;
}
const element = this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
if (changedProps.has("locations")) {
this._updateMarkers();
}
const el = document.createElement("div");
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
return el;
}
public updated(changedProps: PropertyValues): void {
if (changedProps.has("locations")) {
fireEvent(this, "markers-updated");
// Still loading.
if (!this.Leaflet) {
return;
}
// Follow a location that was edited out of view
const oldLocations = changedProps.get("locations") as
MarkerLocation[] | undefined;
if (changedProps.has("locations")) {
const oldLocations = changedProps.get("locations");
const movedLocations = this.locations?.filter(
(loc, idx) =>
!oldLocations?.[idx] ||
!oldLocations[idx] ||
((loc.latitude !== oldLocations[idx].latitude ||
loc.longitude !== oldLocations[idx].longitude) &&
this.map.containsLocation([
oldLocations[idx].latitude,
oldLocations[idx].longitude,
]) &&
!this.map.containsLocation([loc.latitude, loc.longitude]))
this.map.leafletMap?.getBounds().contains({
lat: oldLocations[idx].latitude,
lng: oldLocations[idx].longitude,
}) &&
!this.map.leafletMap
?.getBounds()
.contains({ lat: loc.latitude, lng: loc.longitude }))
);
if (movedLocations?.length === 1) {
this.map.panTo([
movedLocations[0].latitude,
movedLocations[0].longitude,
]);
this.map.leafletMap?.panTo({
lat: movedLocations[0].latitude,
lng: movedLocations[0].longitude,
});
}
}
}
private _editingAvailableChanged(ev: HASSDomEvent<{ available: boolean }>) {
this._editingAvailable = ev.detail.available;
}
private _normalizeLongitude(longitude: number): number {
if (Math.abs(longitude) > 180.0) {
// Normalize longitude if map provides values beyond -180 to +180 degrees.
@@ -243,37 +206,40 @@ export class HaLocationsEditor extends LitElement {
return longitude;
}
private _locationMoved(
ev: HASSDomEvent<{ id: string; location: MapLatLng }>
) {
const [latitude, longitude] = ev.detail.location;
private _updateLocation(ev: DragEndEvent) {
const marker = ev.target;
const latlng: LatLng = marker.getLatLng();
const location: [number, number] = [
latlng.lat,
this._normalizeLongitude(latlng.lng),
];
fireEvent(
this,
"location-updated",
{
id: ev.detail.id,
location: [latitude, this._normalizeLongitude(longitude)],
},
{ id: marker.id, location },
{ bubbles: false }
);
}
private _radiusChanged(ev: HASSDomEvent<{ id: string; radius: number }>) {
private _updateRadius(ev: DragEndEvent) {
const marker = ev.target;
const circle = this._locationMarkers![marker.id] as Circle;
fireEvent(
this,
"radius-updated",
{ id: ev.detail.id, radius: ev.detail.radius },
{ id: marker.id, radius: circle.getRadius() },
{ bubbles: false }
);
}
private _markerClicked(ev: HASSDomEvent<{ id: string }>) {
fireEvent(this, "marker-clicked", { id: ev.detail.id }, { bubbles: false });
private _markerClicked(ev: DragEndEvent) {
const marker = ev.target;
fireEvent(this, "marker-clicked", { id: marker.id }, { bubbles: false });
}
private _mapClicked(ev: HASSDomEvent<{ location: [number, number] }>) {
if (this.pinOnClick && this.locations?.length) {
const id = this.locations[0].id;
private _mapClicked(ev) {
if (this.pinOnClick && this._locationMarkers) {
const id = Object.keys(this._locationMarkers)[0];
const location: [number, number] = [
ev.detail.location[0],
this._normalizeLongitude(ev.detail.location[1]),
@@ -282,28 +248,136 @@ export class HaLocationsEditor extends LitElement {
// If the normalized longitude wraps around the globe, pan to the new location.
if (location[1] !== ev.detail.location[1]) {
this.map.panTo(location);
this.map.leafletMap?.panTo({ lat: location[0], lng: location[1] });
}
}
}
static styles = css`
.map {
position: relative;
height: 100%;
private _updateMarkers(): void {
if (!this.locations || !this.locations.length) {
this._circles = {};
this._locationMarkers = undefined;
return;
}
const locationMarkers = {};
const circles = {};
const defaultZoneRadiusColor =
getComputedStyle(this).getPropertyValue("--accent-color");
this.locations.forEach((location: MarkerLocation) => {
let icon: DivIcon | undefined;
if (location.icon || location.iconPath) {
// create icon
const el = document.createElement("div");
el.className = "named-icon";
if (location.name !== undefined) {
el.innerText = location.name;
}
let iconEl: HaIcon | HaSvgIcon;
if (location.icon) {
iconEl = document.createElement("ha-icon");
iconEl.setAttribute("icon", location.icon);
} else {
iconEl = document.createElement("ha-svg-icon");
iconEl.setAttribute("path", location.iconPath!);
}
el.prepend(iconEl);
icon = this.Leaflet!.divIcon({
html: el.outerHTML,
iconSize: [24, 24],
className: "light",
});
}
if (location.radius) {
const circle = this.Leaflet!.circle(
[location.latitude, location.longitude],
{
color: location.radius_color || defaultZoneRadiusColor,
radius: location.radius,
}
);
if (location.radius_editable || location.location_editable) {
// @ts-ignore
circle.editing.enable();
circle.addEventListener("add", () => {
// @ts-ignore
const moveMarker = circle.editing._moveMarker;
// @ts-ignore
const resizeMarker = circle.editing._resizeMarkers[0];
if (icon) {
moveMarker.setIcon(icon);
}
resizeMarker.id = moveMarker.id = location.id;
moveMarker
.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateLocation(ev)
)
.addEventListener(
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
if (location.radius_editable) {
resizeMarker.addEventListener(
"dragend",
// @ts-ignore
(ev: DragEndEvent) => this._updateRadius(ev)
);
} else {
resizeMarker.remove();
}
});
locationMarkers[location.id] = circle;
} else {
circles[location.id] = circle;
}
}
if (
!location.radius ||
(!location.radius_editable && !location.location_editable)
) {
const options: MarkerOptions = {
title: location.name,
draggable: location.location_editable,
};
if (icon) {
options.icon = icon;
}
const marker = this.Leaflet!.marker(
[location.latitude, location.longitude],
options
)
.addEventListener("dragend", (ev: DragEndEvent) =>
this._updateLocation(ev)
)
.addEventListener(
// @ts-ignore
"click",
// @ts-ignore
(ev: MouseEvent) => this._markerClicked(ev)
);
(marker as any).id = location.id;
locationMarkers[location.id] = marker;
}
});
this._circles = circles;
this._locationMarkers = locationMarkers;
fireEvent(this, "markers-updated");
}
static styles = css`
ha-map {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
ha-alert {
position: absolute;
top: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
z-index: 1;
}
`;
}
File diff suppressed because it is too large Load Diff
@@ -33,6 +33,7 @@ import "../ha-list";
import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tip";
import "./ha-media-player-browse";
import "./ha-media-upload-button";
import type { MediaManageDialogParams } from "./show-media-manage-dialog";
@@ -7,11 +7,9 @@ import { customElement, property, query } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityPickerDisplay,
computeEntitySearchLabels,
} from "../../common/entity/compute_entity_name_display";
import { computeEntityNameList } from "../../common/entity/compute_entity_name_display";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { supportsFeature } from "../../common/entity/supports-feature";
import {
areasContext,
@@ -109,6 +107,9 @@ export class HaMediaPlayerPicker extends LitElement {
const webBrowserLabel = this._i18n.localize(
"ui.components.media-browser.web-browser"
);
const lang = this._i18n.language || "en";
const isRTL =
this._i18n.translationMetadata.translations[lang]?.isRTL || false;
return [
{
@@ -127,22 +128,24 @@ export class HaMediaPlayerPicker extends LitElement {
...Object.values(this._states)
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const { primary, secondary } = computeEntityPickerDisplay(
{
entities: this._entities,
devices: this._devices,
areas: this._areas,
floors: this._floors,
language: this._i18n.language,
translationMetadata: this._i18n.translationMetadata,
},
stateObj
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: entityId,
@@ -152,14 +155,11 @@ export class HaMediaPlayerPicker extends LitElement {
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
...computeEntitySearchLabels(
stateObj,
this._entities,
this._devices,
this._areas,
this._floors
),
entityName: entityName || null,
deviceName: deviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId,
},
stateObj,
@@ -7,7 +7,8 @@ import memoizeOne from "memoize-one";
import { consumeEntityState } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
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 {
areasContext,
devicesContext,
@@ -53,24 +54,30 @@ class HaMediaPlayerToggle extends LitElement {
private _computeDisplayData = memoizeOne(
(
entityId: string,
entities: ContextType<typeof entitiesContext>,
devices: ContextType<typeof devicesContext>,
areas: ContextType<typeof areasContext>,
floors: ContextType<typeof floorsContext>,
i18n: ContextType<typeof internationalizationContext>,
isRTL: boolean,
stateObj: HassEntity
) =>
computeEntityPickerDisplay(
{
entities,
devices,
areas,
floors,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
},
stateObj
)
) => {
const [entityName, deviceName, areaName] = computeEntityNameList(
stateObj,
[{ type: "entity" }, { type: "device" }, { type: "area" }],
entities,
devices,
areas,
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
);
protected render() {
@@ -87,12 +94,18 @@ class HaMediaPlayerToggle extends LitElement {
icon = mdiSpeakerPause;
}
const isRTL = computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
);
const { primary, secondary } = this._computeDisplayData(
this.entityId,
this._entities,
this._devices,
this._areas,
this._floors,
this._i18n,
isRTL,
stateObj
);
@@ -1,181 +0,0 @@
import { getDeviceAreaId } from "../../common/entity/context/get_device_context";
import type {
ExtractFromTargetResultReferenced,
TargetType,
} from "../../data/target";
import type { HomeAssistant } from "../../types";
export interface TargetSubRows {
nextType: TargetType;
rows: string[];
rowEntries?: ExtractFromTargetResultReferenced[];
deviceRows: string[];
deviceRowEntries?: ExtractFromTargetResultReferenced[];
entityRows: string[];
}
const emptyEntries = (): ExtractFromTargetResultReferenced => ({
referenced_areas: [],
referenced_devices: [],
referenced_entities: [],
});
export const computeTargetSubRows = (
type: TargetType,
itemId: string,
entries: ExtractFromTargetResultReferenced,
entityRegistry: HomeAssistant["entities"],
devices: HomeAssistant["devices"]
): TargetSubRows => {
let nextType: TargetType =
type === "floor" ? "area" : type === "area" ? "device" : "entity";
if (type === "label") {
if (entries.referenced_areas.length) {
nextType = "area";
} else if (entries.referenced_devices.length) {
nextType = "device";
}
}
const deviceOf = (entityId: string) => entityRegistry[entityId]?.device_id;
const parentOf = (deviceId: string) => devices[deviceId]?.parent_device_id;
// An entity belongs to a device row when it is on that device or on one of
// its child devices, so the row can nest the children below it.
const belongsTo = (entityId: string, deviceId: string) => {
const entityDevice = deviceOf(entityId);
return (
entityDevice === deviceId ||
(!!entityDevice && parentOf(entityDevice) === deviceId)
);
};
const entitiesOf = (deviceId: string) =>
entries.referenced_entities.filter((entityId) =>
belongsTo(entityId, deviceId)
);
const rootsOf = (deviceIds: string[]) =>
deviceIds.filter((deviceId) => {
const parentId = parentOf(deviceId);
return !parentId || !deviceIds.includes(parentId);
});
const childDevices =
type === "device"
? [
...new Set(
entries.referenced_entities
.map(deviceOf)
.filter(
(deviceId): deviceId is string =>
!!deviceId &&
deviceId !== itemId &&
parentOf(deviceId) === itemId
)
),
]
: [];
const rows =
nextType === "area"
? entries.referenced_areas
: nextType === "device" && type !== "label"
? rootsOf(entries.referenced_devices)
: type === "device"
? entries.referenced_entities.filter(
(entityId) => !childDevices.includes(deviceOf(entityId) || "")
)
: type !== "label"
? entries.referenced_entities
: [];
const devicesInAreas: string[] = [];
const rowEntries =
nextType === "entity"
? undefined
: rows.map((rowItem) => {
const nextEntries = emptyEntries();
if (nextType === "area") {
const areaDevices = entries.referenced_devices.filter(
(deviceId) => {
const device = devices[deviceId];
return (
!!device &&
getDeviceAreaId(device, devices) === rowItem &&
entries.referenced_entities.some((entityId) =>
belongsTo(entityId, deviceId)
)
);
}
);
devicesInAreas.push(...areaDevices);
nextEntries.referenced_devices = rootsOf(areaDevices);
nextEntries.referenced_entities =
entries.referenced_entities.filter((entityId) => {
const entity = entityRegistry[entityId];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
areaDevices.includes(entity.device_id)
);
});
return nextEntries;
}
nextEntries.referenced_entities = entitiesOf(rowItem);
return nextEntries;
});
const entityRows =
type === "label"
? entries.referenced_entities.filter((entityId) => {
const entity = entityRegistry[entityId];
if (!entity) {
return false;
}
return (
entity.labels.includes(itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
);
})
: nextType === "device"
? entries.referenced_entities.filter(
(entityId) => entityRegistry[entityId]?.area_id === itemId
)
: [];
const deviceRows =
type === "label"
? rootsOf(
entries.referenced_devices.filter(
(deviceId) =>
!devicesInAreas.includes(deviceId) &&
devices[deviceId]?.labels.includes(itemId)
)
)
: childDevices;
const deviceRowEntries = deviceRows.length
? deviceRows.map((deviceId) => ({
...emptyEntries(),
referenced_entities: entitiesOf(deviceId),
}))
: undefined;
return {
nextType,
rows,
rowEntries,
deviceRows,
deviceRowEntries,
entityRows,
};
};
@@ -19,6 +19,7 @@ import type { HassDialog } from "../../../dialogs/make-dialog-manager";
import type { HomeAssistant } from "../../../types";
import type { HaDevicePickerDeviceFilterFunc } from "../../device/ha-device-picker";
import "../../ha-adaptive-dialog";
import "../../ha-dialog-header";
import "../../ha-icon-button";
import "../../ha-icon-next";
import "../../ha-svg-icon";

Some files were not shown because too many files have changed in this diff Show More