Compare commits

..
205 changed files with 3498 additions and 6652 deletions
+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
@@ -0,0 +1,87 @@
diff --git a/round-slider.cjs b/round-slider.cjs
index f5abedb900513f18ab914f94900d0cb15b87b73b..0a8fdc22d2362ef67d0d25e72a69119001f06096 100644
--- a/round-slider.cjs
+++ b/round-slider.cjs
@@ -78,6 +78,7 @@ var nt;null===(nt=window.HTMLSlotElement)||void 0===nt||nt.prototype.assignedEle
viewBox="${-t.left} ${-t.up} ${t.width} ${t.height}"
style="margin: ${this.handleSize*this.handleZoom}px;"
?disabled=${this.disabled}
+ ?readonly=${!this._showHandle}
focusable="false"
>
<g class="slider">
@@ -160,6 +161,16 @@ var nt;null===(nt=window.HTMLSlotElement)||void 0===nt||nt.prototype.assignedEle
stroke-linecap: round;
cursor: var(--round-slider-handle-cursor, pointer);
}
+ .shadowpath {
+ cursor: var(
+ --round-slider-path-cursor,
+ var(--round-slider-handle-cursor, pointer)
+ );
+ }
+ svg[disabled] .shadowpath,
+ svg[readonly] .shadowpath {
+ cursor: default;
+ }
g.low.handle {
stroke: var(--round-slider-low-handle-color);
}
diff --git a/round-slider.iife.js b/round-slider.iife.js
index 8d9e578c5891ebfbdf6cff6bd9f7ea965bb6ab0b..d4b6c33d8620c4316b8890dfa4eb74cd7fb0933c 100644
--- a/round-slider.iife.js
+++ b/round-slider.iife.js
@@ -78,6 +78,7 @@ var ht;null===(ht=window.HTMLSlotElement)||void 0===ht||ht.prototype.assignedEle
viewBox="${-t.left} ${-t.up} ${t.width} ${t.height}"
style="margin: ${this.handleSize*this.handleZoom}px;"
?disabled=${this.disabled}
+ ?readonly=${!this._showHandle}
focusable="false"
>
<g class="slider">
@@ -160,6 +161,16 @@ var ht;null===(ht=window.HTMLSlotElement)||void 0===ht||ht.prototype.assignedEle
stroke-linecap: round;
cursor: var(--round-slider-handle-cursor, pointer);
}
+ .shadowpath {
+ cursor: var(
+ --round-slider-path-cursor,
+ var(--round-slider-handle-cursor, pointer)
+ );
+ }
+ svg[disabled] .shadowpath,
+ svg[readonly] .shadowpath {
+ cursor: default;
+ }
g.low.handle {
stroke: var(--round-slider-low-handle-color);
}
diff --git a/round-slider.js b/round-slider.js
index 9bfdf73cc3913e10133255b2d346ba36c11d8dc9..6c4b14674d52d28a6ab8a0c38b1cb1768eeabbba 100644
--- a/round-slider.js
+++ b/round-slider.js
@@ -363,6 +363,7 @@ export class RoundSlider extends LitElement {
viewBox="${-view.left} ${-view.up} ${view.width} ${view.height}"
style="margin: ${this.handleSize * this.handleZoom}px;"
?disabled=${this.disabled}
+ ?readonly=${!this._showHandle}
focusable="false"
>
<g class="slider">
@@ -456,6 +457,16 @@ export class RoundSlider extends LitElement {
stroke-linecap: round;
cursor: var(--round-slider-handle-cursor, pointer);
}
+ .shadowpath {
+ cursor: var(
+ --round-slider-path-cursor,
+ var(--round-slider-handle-cursor, pointer)
+ );
+ }
+ svg[disabled] .shadowpath,
+ svg[readonly] .shadowpath {
+ cursor: default;
+ }
g.low.handle {
stroke: var(--round-slider-low-handle-color);
}
@@ -12,29 +12,19 @@ const CONNECTIONS: ModbusConnection[] = [
{
endpoint: ["serial", RS485_PORT],
connected: true,
source: "config_entry",
units: { [FLEXIT_ENTRY_ID]: [1], [SOLAREDGE_ENTRY_ID]: [2] },
},
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
source: "config_entry",
units: { [FRONIUS_ENTRY_ID]: [1] },
},
// A second inverter behind a gateway that is not answering
{
endpoint: ["tcp", "modbus-gateway.local", 502],
connected: false,
source: "config_entry",
units: { [FRONIUS_ENTRY_ID]: [2] },
},
// A YAML hub keeps a link of its own to a device an integration also talks to
{
endpoint: ["tcp", "192.168.1.42", 502],
connected: true,
source: "yaml",
units: { modbus_hub: [10, 11] },
},
];
export const mockModbus = (hass: MockHomeAssistant) => {
-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;
}
+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;
}
+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")
@@ -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">
+18 -18
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,15 +52,15 @@
"@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",
@@ -84,6 +84,7 @@
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.23",
"@thomasloven/round-slider": "patch:@thomasloven/round-slider@npm%3A0.6.0#~/.yarn/patches/@thomasloven-round-slider-npm-0.6.0-d7176d9db7.patch",
"@tsparticles/engine": "4.4.0",
"@tsparticles/preset-links": "4.4.0",
"@vibrant/color": "4.0.4",
@@ -103,7 +104,7 @@
"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.14",
@@ -116,7 +117,6 @@
"maplibre-gl": "5.24.0",
"marked": "18.0.12",
"memoize-one": "6.0.0",
"nanoid": "6.0.1",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
"punycode": "2.3.1",
@@ -139,10 +139,10 @@
},
"devDependencies": {
"@ampproject/remapping": "2.3.0",
"@babel/core": "8.0.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,7 +152,7 @@
"@octokit/plugin-retry": "8.1.1",
"@octokit/rest": "22.0.1",
"@playwright/test": "1.62.1",
"@rsdoctor/rspack-plugin": "1.6.4",
"@rsdoctor/rspack-plugin": "1.6.3",
"@rspack/core": "2.2.3",
"@rspack/dev-server": "2.2.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -195,10 +195,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",
@@ -214,7 +214,7 @@
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.70.0",
"vite": "8.3.0",
"vite": "8.2.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "5.0.0",
"webpack-stats-plugin": "1.1.3",
+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,
};
};
@@ -1,24 +1,13 @@
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 { getEntityContext } from "./context/get_entity_context";
const DEFAULT_SEPARATOR = " ";
@@ -51,42 +40,6 @@ export interface EntityNameOptions {
separator?: string;
}
// 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;
/**
* Name formatter used before the registry-aware one is installed
* (see state/connection-mixin and state/state-display-mixin). It honours a
* configured string or text-only name and falls back to the friendly name for
* anything that needs entity context, so a configured name is never dropped
* while the real formatter is still loading.
*/
export const computeEntityNameDisplayWithoutContext = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
options?: EntityNameOptions
): string => {
if (typeof name === "string") {
return name;
}
if (!name) {
return computeStateName(stateObj);
}
return (
computeTextOnlyName(ensureArray(name), options) ??
computeStateName(stateObj)
);
};
export const computeEntityNameDisplay = (
stateObj: HassEntity,
name: string | EntityNameItem | EntityNameItem[] | undefined,
@@ -110,9 +63,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);
@@ -150,49 +102,18 @@ 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, parentDevice, 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":
@@ -207,29 +128,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 {
-1
View File
@@ -95,7 +95,6 @@ const FIXED_DOMAIN_ATTRIBUTE_STATES = {
"door",
"garage_door",
"gas",
"glass_break",
"heat",
"light",
"lock",
+40 -23
View File
@@ -123,6 +123,8 @@ interface ManagedMarker {
draggable?: boolean;
onDragEnd?: (location: MapLatLng) => void;
dragging?: boolean;
/** Pre-drag location; the next update echoing it is ignored (see setLocation) */
staleLocation?: MapLatLng;
mlMarker?: MapLibreMarker;
decoration?: MapItemHandle;
removed?: boolean;
@@ -131,8 +133,6 @@ interface ManagedMarker {
interface ClusterGroup {
/** Members are shown in a bubble at their spot instead of an icon */
open?: boolean;
/** Grouped by key (a zone), so it bubbles even with a single member */
keyed?: boolean;
members: ManagedMarker[];
center: MapLatLng;
iconMarker?: MapLibreMarker;
@@ -600,10 +600,19 @@ export class MapLibreMapEngine implements MapEngine {
},
clusterData: options.clusterData,
setLocation: (newLocation) => {
// The user's hand wins while dragging; the host is the truth otherwise
if (managed.dragging) {
return;
}
// Skip one update echoing the pre-drag location (the save has not returned yet)
const stale = managed.staleLocation;
managed.staleLocation = undefined;
if (
stale &&
newLocation[0] === stale[0] &&
newLocation[1] === stale[1]
) {
return;
}
managed.location = newLocation;
managed.mlMarker?.setLngLat([newLocation[1], newLocation[0]]);
},
@@ -654,6 +663,7 @@ export class MapLibreMapEngine implements MapEngine {
if (managed.draggable) {
managed.mlMarker.on("dragstart", () => {
managed.dragging = true;
managed.staleLocation = managed.location;
});
managed.mlMarker.on("dragend", () => {
managed.dragging = false;
@@ -791,8 +801,10 @@ export class MapLibreMapEngine implements MapEngine {
resizeHandle?.setAttribute("aria-valuetext", radiusText);
};
// The user's hand wins while dragging; the host is the truth otherwise
// Skip one update echoing the pre-edit values (the save has not returned yet)
let dragging = false;
let staleCenter: MapLatLng | undefined;
let staleRadius: number | undefined;
if (options.resizable) {
const east = pointEastOf(center, options.radius);
@@ -835,6 +847,8 @@ export class MapLibreMapEngine implements MapEngine {
if (keyboardRadius === undefined) {
// Host updates treat a key resize like a drag
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
}
currentRadius = Math.max(
1,
@@ -861,11 +875,19 @@ export class MapLibreMapEngine implements MapEngine {
[centerMarker, resizeMarker].forEach((handleMarker) => {
handleMarker?.on("dragstart", () => {
dragging = true;
staleCenter = currentCenter;
staleRadius = currentRadius;
});
handleMarker?.on("dragend", () => {
dragging = false;
});
});
const isStale = (candidateCenter: MapLatLng, candidateRadius: number) =>
staleCenter !== undefined &&
staleRadius !== undefined &&
candidateCenter[0] === staleCenter[0] &&
candidateCenter[1] === staleCenter[1] &&
candidateRadius === staleRadius;
if (options.moveable) {
centerMarker.on("drag", () => {
@@ -922,6 +944,12 @@ export class MapLibreMapEngine implements MapEngine {
if (dragging) {
return;
}
const stale = isStale(newCenter, newRadius);
staleCenter = undefined;
staleRadius = undefined;
if (stale) {
return;
}
currentCenter = newCenter;
currentRadius = newRadius;
centerMarker.setLngLat([newCenter[1], newCenter[0]]);
@@ -1191,16 +1219,13 @@ export class MapLibreMapEngine implements MapEngine {
const clusterable = this._markers.filter(
(managed) => managed.options.cluster && !managed.removed
);
// A member or bubble that had focus hands it to the icon replacing it;
// read before the bubble holding it is removed
// A member that had focus hands it to the icon replacing it; read before
// the open bubble holding it is removed
const active = (
this._map.getContainer().getRootNode() as Document | ShadowRoot
).activeElement;
const focusedMember = active
? (clusterable.find((managed) => managed.element.contains(active)) ??
this._clusterGroups.find((group) =>
group.iconMarker?.getElement().contains(active)
)?.members[0])
? clusterable.find((managed) => managed.element.contains(active))
: undefined;
this._clusterGroups.forEach((group) => group.iconMarker?.remove());
@@ -1225,7 +1250,6 @@ export class MapLibreMapEngine implements MapEngine {
const groups: {
seed: { x: number; y: number };
members: ManagedMarker[];
keyed?: boolean;
}[] = [];
// Keyed groups first; one spread too wide falls through to proximity
@@ -1248,10 +1272,8 @@ export class MapLibreMapEngine implements MapEngine {
Math.max(...xs) - Math.min(...xs),
Math.max(...ys) - Math.min(...ys)
);
// A keyed group (a zone) bubbles even with a single member, so a lone
// person or device in a zone still shows in a bubble pinned to it.
if (members.length === 1 || spread <= (groupRadius ?? radius)) {
groups.push({ seed: points[0], members, keyed: true });
if (members.length > 1 && spread <= (groupRadius ?? radius)) {
groups.push({ seed: points[0], members });
} else {
ungrouped.push(...members);
}
@@ -1277,7 +1299,6 @@ export class MapLibreMapEngine implements MapEngine {
}
this._clusterGroups = groups.map((group) => ({
members: group.members,
keyed: group.keyed,
center: [
group.members.reduce((sum, m) => sum + m.location[0], 0) /
group.members.length,
@@ -1289,9 +1310,7 @@ export class MapLibreMapEngine implements MapEngine {
for (const group of this._clusterGroups) {
group.iconMarker = undefined;
// A lone non-keyed marker shows plainly; a lone zone occupant falls
// through to the bubble path so it renders in a bubble at its zone.
if (group.members.length === 1 && !group.keyed) {
if (group.members.length === 1) {
this._showMarker(group.members[0]);
continue;
}
@@ -1360,10 +1379,8 @@ export class MapLibreMapEngine implements MapEngine {
const group = this._clusterGroups.find((candidate) =>
candidate.members.includes(focusedMember)
);
if (group && !group.open) {
// A singleton group has no bubble icon; the member shows on its own,
// so send focus back to that member rather than to the document.
(group.iconMarker?.getElement() ?? focusedMember.element).focus();
if (group?.iconMarker && !group.open) {
group.iconMarker.getElement().focus();
}
}
}
-73
View File
@@ -1,73 +0,0 @@
import memoizeOne from "memoize-one";
import { getColorByIndex } from "../color/colors";
import { computeDomain } from "../entity/compute_domain";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
/**
* Map colors for entities, by registry creation order, so an entity has the
* same color on every map. The registry comes from the fullEntitiesContext;
* entities without an entry (e.g. YAML zones) get a color derived from their
* id.
*/
export const HOME_ZONE_ENTITY_ID = "zone.home";
/** Domains whose entities are colored by creation order */
const ORDERED_DOMAINS = ["zone", "person", "device_tracker"];
// One index per registry update, shared by every map on the page. Zones,
// persons and trackers share one sequence, so a person never has the color
// of the zone it is in.
const creationIndex = memoizeOne(
(entries: EntityRegistryEntry[]): Record<string, number> => {
const index: Record<string, number> = {};
entries
.filter(
(entry) =>
ORDERED_DOMAINS.includes(computeDomain(entry.entity_id)) &&
// The home zone has a fixed color and does not take a palette slot
entry.entity_id !== HOME_ZONE_ENTITY_ID
)
.sort((a, b) => a.created_at - b.created_at || a.id.localeCompare(b.id))
.forEach((entry, i) => {
index[entry.entity_id] = i;
});
return index;
}
);
// For entities without a registry entry
const hashIndex = (entityId: string): number => {
let hash = 5381;
for (let i = 0; i < entityId.length; i++) {
hash = (hash * 33 + entityId.charCodeAt(i)) % 2147483647;
}
return hash;
};
/** The palette color of an entity on a map */
export const entityMapColor = (
entityId: string,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string =>
getColorByIndex(
creationIndex(entries)[entityId] ?? hashIndex(entityId),
computedStyles
);
/** A zone's color: primary for home, muted for passive, its entity map color otherwise */
export const zoneColor = (
entityId: string,
passive: boolean,
entries: EntityRegistryEntry[],
computedStyles: CSSStyleDeclaration
): string => {
if (entityId === HOME_ZONE_ENTITY_ID) {
return computedStyles.getPropertyValue("--primary-color");
}
if (passive) {
return computedStyles.getPropertyValue("--secondary-text-color");
}
return entityMapColor(entityId, entries, computedStyles);
};
+1 -5
View File
@@ -16,11 +16,7 @@ export const setMarkerAccessibility = (
): void => {
clearMarkerAccessibility(element);
const owned: string[] = [];
if (
title &&
!element.hasAttribute("aria-label") &&
!element.hasAttribute("aria-labelledby")
) {
if (title && !element.hasAttribute("aria-label")) {
element.setAttribute("aria-label", title);
owned.push("aria-label");
}
-67
View File
@@ -1,67 +0,0 @@
import { getContrastedColorHex } from "../color/rgb";
/** The zone marker: a colored circle with the zone's icon or initials, shared by the map and the zone editor */
export const ZONE_CIRCLE_SIZE = 36;
// Content color contrasting the fill; not every theme color parses
export const contrastingZoneContent = (color: string): string => {
try {
return getContrastedColorHex(color.trim());
} catch {
return "#ffffff";
}
};
export const zoneInitials = (name: string): string =>
name
.split(" ")
.map((part) => part[0])
.join("")
.slice(0, 2);
export const createZoneMarkerElement = (options: {
color: string;
icon?: string;
/** Path for an ha-svg-icon, when there is no icon name */
iconPath?: string;
name: string;
}): HTMLElement => {
const element = document.createElement("div");
element.className = "zone-circle";
element.style.backgroundColor = options.color;
element.style.color = contrastingZoneContent(options.color);
if (options.icon) {
const icon = document.createElement("ha-icon");
icon.setAttribute("icon", options.icon);
element.appendChild(icon);
} else if (options.iconPath) {
const icon = document.createElement("ha-svg-icon");
icon.setAttribute("path", options.iconPath);
element.appendChild(icon);
} else {
const initials = document.createElement("span");
initials.textContent = zoneInitials(options.name);
element.appendChild(initials);
}
return element;
};
/** Styles for the zone marker, included by ha-map */
export const zoneMarkerStyles = `
.zone-circle {
width: ${ZONE_CIRCLE_SIZE}px;
height: ${ZONE_CIRCLE_SIZE}px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--card-background-color, #fff);
box-sizing: border-box;
box-shadow: var(--ha-box-shadow-s);
overflow: hidden;
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
--mdc-icon-size: ${ZONE_CIRCLE_SIZE / 2}px;
}
`;
+1 -1
View File
@@ -33,7 +33,7 @@ export type FormatEntityAttributeNameFunc = (
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;
},
});
@@ -90,7 +90,6 @@ export class HaAutomationConditionSummary extends LitElement {
></ha-automation-row-options>`
: nothing
}
<slot name="references"></slot>
${
note
? html`
@@ -44,8 +44,7 @@ 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.
// left-hand category-label column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
@@ -80,9 +79,7 @@ export class StateHistoryChartTimeline extends LitElement {
.options=${this._chartOptions}
.height=${`${
this.data.length *
(this.insideLabels && (this.chunked || this.showNames)
? ROW_HEIGHT_INSIDE_LABELS
: ROW_HEIGHT) +
(this.insideLabels ? ROW_HEIGHT_INSIDE_LABELS : ROW_HEIGHT) +
GRID_BOTTOM
}px`}
.data=${this._chartData as HaECSeries}
+1 -1
View File
@@ -80,7 +80,7 @@ export class StateHistoryCharts extends LitElement {
@property({ attribute: "show-names", type: Boolean }) public showNames = true;
// Render timeline row names inside the plot (under each bar) instead of in a
// left-hand column. Opt-in; used by the history panel and history-graph card.
// left-hand column. Opt-in; used by the history panel.
@property({ attribute: "inside-labels", type: Boolean })
public insideLabels = false;
+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];
+59 -90
View File
@@ -1,100 +1,69 @@
import { AssistChip } from "@material/web/chips/internal/assist-chip";
import { styles } from "@material/web/chips/internal/assist-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { css, html } from "lit";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-assist-chip")
// @ts-ignore
export class HaAssistChip extends AssistChip {
export class HaAssistChip extends HaChipBase {
@property({ type: Boolean, reflect: true }) filled = false;
@property({ type: Boolean }) active = false;
@property({ type: Boolean, reflect: true }) active = false;
static override styles = [
sharedStyles,
elevatedStyles,
styles,
css`
:host {
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-assist-chip-container-shape: var(
--ha-assist-chip-container-shape,
16px
);
--md-assist-chip-outline-color: var(--outline-color);
--md-assist-chip-label-text-weight: 400;
}
/** Material 3 doesn't have a filled chip, so we have to make our own **/
.filled {
display: flex;
pointer-events: none;
border-radius: inherit;
inset: 0;
position: absolute;
background-color: var(--ha-assist-chip-filled-container-color);
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]),
::slotted([slot="trailing-icon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
font-size: var(--_label-text-size) !important;
}
@property({ type: Boolean, reflect: true }) elevated = false;
.trailing.icon ::slotted(*),
.trailing.icon svg {
margin-inline-end: unset;
margin-inline-start: var(--_icon-label-space);
}
::before {
background: var(--ha-assist-chip-container-color, transparent);
opacity: var(--ha-assist-chip-container-opacity, 1);
}
:where(.active)::before {
background: var(--ha-assist-chip-active-container-color);
opacity: var(--ha-assist-chip-active-container-opacity);
}
.label {
font-family: var(--ha-font-family-body);
}
`,
];
protected override renderOutline() {
if (this.filled) {
return html`<span class="filled"></span>`;
}
return super.renderOutline();
}
protected override getContainerClasses() {
return {
...super.getContainerClasses(),
active: this.active,
};
}
protected override renderPrimaryContent() {
return html`
<span class="leading icon" aria-hidden="true">
${this.renderLeadingIcon()}
</span>
<span class="label">${this.label}</span>
<span class="touch"></span>
<span class="trailing leading icon" aria-hidden="true">
${this.renderTrailingIcon()}
</span>
`;
}
protected renderTrailingIcon() {
return html`<slot name="trailing-icon"></slot>`;
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
--ha-chip-label-color: var(
--md-assist-chip-label-text-color,
var(--md-sys-color-on-surface, var(--primary-text-color))
);
--ha-button-border-radius: var(
--ha-assist-chip-container-shape,
16px
);
--ha-chip-container-color: var(
--ha-assist-chip-container-color,
transparent
);
--ha-chip-container-opacity: var(
--ha-assist-chip-container-opacity,
1
);
--ha-chip-outline-color: var(
--md-assist-chip-outline-color,
var(--outline-color)
);
--ha-chip-icon-label-space: var(
--md-assist-chip-icon-label-space,
var(--ha-space-2)
);
}
:host([filled]) {
--ha-chip-container-color: var(
--ha-assist-chip-filled-container-color
);
--ha-chip-outline-color: transparent;
}
:host([active]) {
--ha-chip-container-color: var(
--ha-assist-chip-active-container-color
);
--ha-chip-container-opacity: var(
--ha-assist-chip-active-container-opacity,
1
);
}
.primary {
padding-inline-end: var(
--md-assist-chip-trailing-space,
var(--ha-space-4)
);
}
`,
];
}
}
+452
View File
@@ -0,0 +1,452 @@
import { LocalizeController } from "@home-assistant/webawesome/dist/utilities/localize.js";
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
import { HaButton } from "../ha-button";
import "../ha-svg-icon";
export interface ChipFocusOptions extends FocusOptions {
trailing?: boolean;
}
export class HaChipBase extends HaButton {
@property() label = "";
@property({ type: Boolean }) removable = false;
@property({ type: Boolean, attribute: "remove-only" }) removeOnly = false;
@property({ type: Boolean, reflect: true, attribute: "soft-disabled" })
softDisabled = false;
@property({ type: Boolean, attribute: "always-focusable" })
alwaysFocusable = false;
@property({ type: Boolean, reflect: true, attribute: "has-icon" })
hasIcon = false;
@property({ type: Number, reflect: true, attribute: "tabindex" })
override tabIndex = 0;
@property({ attribute: "aria-label-remove" }) ariaLabelRemove: string | null =
null;
@query(".primary") private _primary?: HTMLButtonElement | HTMLAnchorElement;
@query(".remove") private _remove?: HTMLButtonElement;
@state() private _trailingFocused = false;
@state() private _hasTrailingIcon = false;
private _chipLocalize = new LocalizeController(this);
override variant: HaButton["variant"] = "neutral";
override appearance: HaButton["appearance"] = "outlined";
override size: HaButton["size"] = "s";
protected get pressed(): boolean | undefined {
return undefined;
}
get focusable() {
return !this.disabled || this.alwaysFocusable;
}
constructor() {
super();
this.addEventListener("click", this._blockDisabledClick);
this.addEventListener("keydown", this._handleKeyDown);
}
override focus(options?: ChipFocusOptions) {
if (!this.focusable) {
return;
}
if (
(options?.trailing || (this.removeOnly && !this.href)) &&
this._remove
) {
this._remove.focus(options);
} else {
this._primary?.focus(options);
}
}
override click() {
if (this.removeOnly && !this.href) {
this._remove?.click();
} else {
this._primary?.click();
}
}
override blur() {
this._primary?.blur();
this._remove?.blur();
}
protected override updated(changed: PropertyValues<this>) {
super.updated(changed);
if (changed.has("disabled") || changed.has("alwaysFocusable")) {
fireEvent(this, "update-focus", undefined, { composed: false });
}
}
override render() {
return html`
<div class=${classMap({ container: true, "has-remove": this.removable })}>
${this._renderPrimaryAction()}
${
this.removable
? html`
<span id="remove-label" hidden>
${this._chipLocalize.term("remove")}
</span>
<span id="accessible-label" hidden>${this.ariaLabel}</span>
<button
class="button remove trailing action"
part="remove-button"
type="button"
?disabled=${this.disabled && !this.alwaysFocusable}
aria-disabled=${this.softDisabled ? "true" : nothing}
aria-label=${this.ariaLabelRemove || nothing}
aria-labelledby=${
this.ariaLabelRemove
? nothing
: this.ariaLabel
? "remove-label accessible-label"
: "remove-label label"
}
tabindex=${this.removeOnly && !this.href ? this.tabIndex : -1}
@click=${this._handleRemove}
@focus=${this._handleRemoveFocus}
@blur=${this._handleRemoveBlur}
>
<span class="icon" aria-hidden="true">
<slot name="remove-trailing-icon">
<ha-svg-icon .path=${mdiClose}></ha-svg-icon>
</slot>
</span>
</button>
`
: nothing
}
</div>
`;
}
private _renderPrimaryAction() {
if (this.href) {
return html`<a
class="button primary action"
part="base"
title=${this.title || nothing}
href=${this.href}
target=${this.target || nothing}
download=${this.download || nothing}
rel=${this.rel || nothing}
aria-label=${this.ariaLabel || nothing}
tabindex=${this._trailingFocused ? -1 : this.tabIndex}
@click=${this.handlePrimaryClick}
>${this.renderPrimaryContent()}</a
>`;
}
if (this.removeOnly) {
return html`<span class="content" title=${this.title || nothing}>
${this.renderPrimaryContent()}
</span>`;
}
return html`<button
class=${classMap({
button: true,
primary: true,
action: true,
disabled: this.disabled || this.softDisabled,
})}
part="base"
type="button"
title=${this.title || nothing}
?disabled=${this.disabled && !this.alwaysFocusable}
aria-disabled=${
this.softDisabled || (this.disabled && this.alwaysFocusable)
? "true"
: nothing
}
aria-label=${this.ariaLabel || nothing}
aria-pressed=${this.pressed ?? nothing}
tabindex=${this._trailingFocused ? -1 : this.tabIndex}
@click=${this.handlePrimaryClick}
>
${this.renderPrimaryContent()}
</button>`;
}
protected renderPrimaryContent() {
return html`
${this.renderLeadingIcon()}
<span class="label" id="label" part="label">
${this.label || html`<slot></slot>`}
</span>
<span class="icon" aria-hidden="true" ?hidden=${!this._hasTrailingIcon}>
<slot name="trailing-icon" @slotchange=${this._iconChanged}></slot>
</span>
`;
}
protected renderLeadingIcon(): TemplateResult | typeof nothing {
return html`<span class="icon leading" aria-hidden="true">
<slot name="icon" @slotchange=${this._iconChanged}></slot>
</span>`;
}
protected handlePrimaryClick(event: MouseEvent) {
this._blockDisabledClick(event);
}
private _iconChanged(event: Event) {
if (!(event.target instanceof HTMLSlotElement)) {
return;
}
const hasIcon = event.target.assignedElements({ flatten: true }).length > 0;
if (event.target.name === "icon") {
this.hasIcon = hasIcon;
} else {
this._hasTrailingIcon = hasIcon;
}
}
private _blockDisabledClick(event: MouseEvent) {
if (
this.softDisabled ||
(this.disabled && (!this.href || this.alwaysFocusable))
) {
event.preventDefault();
event.stopImmediatePropagation();
}
}
private _handleRemove(event: MouseEvent) {
event.stopPropagation();
if (this.disabled || this.softDisabled) {
return;
}
if (
!fireEvent(this, "remove", undefined, {
bubbles: false,
composed: false,
cancelable: true,
}).defaultPrevented
) {
this.remove();
}
}
private _handleRemoveFocus() {
this._trailingFocused = true;
}
private _handleRemoveBlur() {
this._trailingFocused = false;
}
private _handleKeyDown(event: KeyboardEvent) {
if (
(event.key !== "ArrowLeft" && event.key !== "ArrowRight") ||
!this._primary ||
!this._remove
) {
return;
}
const forwards =
(event.key === "ArrowRight") !==
(getComputedStyle(this).direction === "rtl");
if (
(forwards && this.shadowRoot?.activeElement === this._primary) ||
(!forwards && this.shadowRoot?.activeElement === this._remove)
) {
event.preventDefault();
event.stopPropagation();
(forwards ? this._remove : this._primary).focus();
}
}
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
display: inline-flex;
max-width: 100%;
vertical-align: middle;
--ha-button-height: 32px;
--ha-button-border-radius: var(--ha-border-radius-pill);
--wa-font-weight-action: var(--ha-font-weight-normal);
--wa-button-transform-hover: none;
--wa-button-transform-active: none;
}
:host([variant]) {
--wa-color-on-quiet: var(
--ha-chip-label-color,
var(--primary-text-color)
);
--wa-color-fill-quiet: color-mix(
in srgb,
var(--ha-chip-label-color, var(--primary-text-color)) 8%,
transparent
);
}
.container {
display: flex;
align-items: center;
min-width: 0;
max-width: 100%;
height: var(--ha-button-height);
position: relative;
border-radius: var(--ha-button-border-radius);
}
.container::before,
.container::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: inherit;
}
.container::before {
background: var(--ha-chip-container-color, transparent);
opacity: var(--ha-chip-container-opacity, 1);
}
.container::after {
border: var(--ha-chip-outline-width, 1px) solid
var(--ha-chip-outline-color, var(--outline-color));
}
.button,
.content {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--ha-chip-icon-label-space, var(--ha-space-2));
min-width: 0;
border: 0;
border-radius: inherit;
color: var(--ha-chip-label-color, var(--primary-text-color));
background: transparent;
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-normal);
padding-inline: var(--ha-space-4);
position: relative;
box-shadow: none;
}
.button.primary,
.content {
flex: 1;
height: var(--ha-button-height);
min-height: var(--ha-button-height);
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-size: var(--ha-chip-label-size, 0.875rem);
font-weight: var(
--ha-chip-label-weight,
var(--ha-font-weight-normal)
);
line-height: var(--ha-chip-label-line-height, 1.25rem);
color: var(--ha-chip-label-color, var(--primary-text-color));
}
.button.remove {
flex: none;
padding: 0;
width: calc(var(--ha-chip-icon-size, 18px) + 2 * var(--ha-space-2));
height: var(--ha-button-height);
min-height: var(--ha-button-height);
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.has-remove .primary,
.has-remove .content {
border-start-end-radius: 0;
border-end-end-radius: 0;
padding-inline-end: 0;
}
:host([has-icon]) .primary,
:host([has-icon]) .content {
padding-inline-start: var(--ha-space-2);
}
.button::after {
content: "";
position: absolute;
inset: -8px 0;
}
slot:not([name]),
slot[name="icon"],
slot[name="trailing-icon"],
slot[name="selected-icon"],
slot[name="remove-trailing-icon"] {
display: contents;
}
.icon {
display: flex;
flex: none;
color: var(--ha-chip-label-color, var(--primary-text-color));
}
:host(:not([has-icon])) .leading {
display: none;
}
.icon[hidden] {
display: none;
}
ha-svg-icon,
::slotted([slot]) {
flex: none;
display: flex;
--mdc-icon-size: var(--ha-chip-icon-size, 18px);
}
:host([disabled]),
:host([soft-disabled]) {
opacity: 1;
}
:host([disabled]) .label,
:host([disabled]) .icon,
:host([soft-disabled]) .label,
:host([soft-disabled]) .icon {
opacity: 0.38;
}
:host([disabled][selected]) .container::before,
:host([soft-disabled][selected]) .container::before {
background-color: var(--primary-text-color);
opacity: 0.12;
}
:host([elevated]) .container {
box-shadow: var(--ha-box-shadow-s);
}
`,
];
}
}
declare global {
interface HASSDomEvents {
remove: undefined;
"update-focus": undefined;
}
}
+87 -3
View File
@@ -1,8 +1,92 @@
import { MdChipSet } from "@material/web/chips/chip-set";
import { customElement } from "lit/decorators";
import { css, html, LitElement } from "lit";
import { customElement, queryAssignedElements } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-chip-set")
export class HaChipSet extends MdChipSet {}
export class HaChipSet extends LitElement {
@queryAssignedElements({ flatten: true }) private _children!: HTMLElement[];
get chips(): HaChipBase[] {
return this._children.filter(
(child): child is HaChipBase => child instanceof HaChipBase
);
}
override connectedCallback() {
super.connectedCallback();
this.setAttribute("role", "toolbar");
this.addEventListener("keydown", this._handleKeyDown);
this.addEventListener("focusin", this._updateTabIndices);
this.addEventListener("update-focus", this._updateTabIndices);
}
override disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener("keydown", this._handleKeyDown);
this.removeEventListener("focusin", this._updateTabIndices);
this.removeEventListener("update-focus", this._updateTabIndices);
}
protected render() {
return html`<slot @slotchange=${this._updateTabIndices}></slot>`;
}
private _updateTabIndices() {
const chips = this.chips;
const active =
chips.find((chip) => chip.focusable && chip.shadowRoot?.activeElement) ||
chips.find((chip) => chip.focusable);
for (const chip of chips) {
chip.tabIndex = chip === active ? 0 : -1;
}
}
private _handleKeyDown(event: KeyboardEvent) {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
return;
}
const chips = this.chips.filter((chip) => chip.focusable);
if (!chips.length) {
return;
}
event.preventDefault();
const forwards =
(event.key === "ArrowRight") !==
(getComputedStyle(this).direction === "rtl");
const current = chips.findIndex((chip) => chip.shadowRoot?.activeElement);
const index =
event.key === "Home"
? 0
: event.key === "End"
? chips.length - 1
: current === -1
? forwards
? 0
: chips.length - 1
: (current + (forwards ? 1 : -1) + chips.length) % chips.length;
chips[index].focus({
trailing: event.key === "End" || (event.key !== "Home" && !forwards),
});
this._updateTabIndices();
}
static styles = css`
:host {
display: flex;
flex-wrap: wrap;
gap: var(--ha-space-2);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
+68 -41
View File
@@ -1,54 +1,81 @@
import { styles as elevatedStyles } from "@material/web/chips/internal/elevated-styles.cssresult.js";
import { FilterChip } from "@material/web/chips/internal/filter-chip";
import { styles } from "@material/web/chips/internal/filter-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import { css, html } from "lit";
import { mdiCheck } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-filter-chip")
export class HaFilterChip extends FilterChip {
export class HaFilterChip extends HaChipBase {
@property({ type: Boolean, reflect: true }) selected = false;
@property({ type: Boolean, reflect: true }) elevated = false;
@property({ type: Boolean, reflect: true, attribute: "no-leading-icon" })
noLeadingIcon = false;
static override styles = [
sharedStyles,
elevatedStyles,
trailingIconStyles,
selectableStyles,
styles,
css`
:host {
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
--md-filter-chip-container-shape: var(--ha-border-radius-md);
--md-filter-chip-outline-color: var(--outline-color);
--md-filter-chip-selected-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--_label-text-font: var(--ha-font-family-body);
}
`,
];
protected getContainerClasses() {
const classes = super.getContainerClasses();
if (this.noLeadingIcon) {
classes["has-icon"] = false;
}
return classes;
protected override get pressed() {
return this.selected;
}
protected renderLeadingIcon() {
protected override renderLeadingIcon() {
if (this.noLeadingIcon) {
// eslint-disable-next-line lit/prefer-nothing
return html``;
return nothing;
}
return super.renderLeadingIcon();
return this.selected
? html`<span class="icon" aria-hidden="true">
<slot name="selected-icon">
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
</slot>
</span>`
: super.renderLeadingIcon();
}
protected override handlePrimaryClick(event: MouseEvent) {
if (this.disabled || this.softDisabled) {
return;
}
const previous = this.selected;
this.selected = !this.selected;
event.stopPropagation();
// Dispatch from the chip so listeners see the new selection before deciding
// whether to cancel it. Preserve pointer and modifier-key information.
const click =
typeof PointerEvent !== "undefined" && event instanceof PointerEvent
? new PointerEvent("click", event)
: new MouseEvent("click", event);
if (!this.dispatchEvent(click)) {
this.selected = previous;
event.preventDefault();
}
}
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
--ha-button-border-radius: var(--ha-border-radius-md);
--ha-chip-label-weight: var(--ha-font-weight-medium);
}
:host([selected]) {
--ha-chip-outline-width: var(
--md-filter-chip-selected-outline-width,
0px
);
--ha-chip-container-color: var(
--md-filter-chip-selected-container-color,
rgba(var(--rgb-primary-text-color), 0.15)
);
}
:host([selected]:not([no-leading-icon])) .primary {
padding-inline-start: var(--ha-space-2);
}
`,
];
}
}
+40 -39
View File
@@ -1,45 +1,46 @@
import { InputChip } from "@material/web/chips/internal/input-chip";
import { styles } from "@material/web/chips/internal/input-styles.cssresult.js";
import { styles as selectableStyles } from "@material/web/chips/internal/selectable-styles.cssresult.js";
import { styles as sharedStyles } from "@material/web/chips/internal/shared-styles.cssresult.js";
import { styles as trailingIconStyles } from "@material/web/chips/internal/trailing-icon-styles.cssresult.js";
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { HaChipBase } from "./ha-chip-base";
@customElement("ha-input-chip")
export class HaInputChip extends InputChip {
static override styles = [
sharedStyles,
trailingIconStyles,
selectableStyles,
styles,
css`
:host {
max-width: 100%;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
--md-sys-color-on-secondary-container: var(--primary-text-color);
--md-input-chip-container-shape: 16px;
--md-input-chip-outline-color: var(--outline-color);
--md-input-chip-selected-container-color: rgba(
var(--rgb-primary-text-color),
0.15
);
--ha-input-chip-selected-container-opacity: 1;
--md-input-chip-label-text-font: Roboto, sans-serif;
--md-input-chip-label-text-weight: 400;
}
/** Set the size of mdc icons **/
::slotted([slot="icon"]) {
display: flex;
--mdc-icon-size: var(--md-input-chip-icon-size, 18px);
}
.selected::before {
opacity: var(--ha-input-chip-selected-container-opacity);
}
`,
];
export class HaInputChip extends HaChipBase {
@property({ type: Boolean, reflect: true }) selected = false;
@property({ type: Boolean, reflect: true }) avatar = false;
override removable = true;
static override get styles(): CSSResultGroup {
return [
super.styles,
css`
:host {
--ha-button-border-radius: 16px;
--ha-chip-icon-size: var(--md-input-chip-icon-size, 18px);
}
:host([selected]) {
--ha-chip-container-color: var(
--md-input-chip-selected-container-color,
rgba(var(--rgb-primary-text-color), 0.15)
);
--ha-chip-outline-width: var(
--md-input-chip-selected-outline-width,
0px
);
--ha-chip-container-opacity: var(
--ha-input-chip-selected-container-opacity,
1
);
}
:host([avatar]) ::slotted([slot="icon"]) {
width: 24px;
height: 24px;
border-radius: var(--ha-border-radius-circle);
}
`,
];
}
}
declare global {
@@ -136,7 +136,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
}
@@ -329,7 +329,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;
}
+78 -37
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,
@@ -234,6 +233,11 @@ export class HaStatisticPicker extends LitElement {
});
}
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const output: StatisticComboBoxItem[] = [];
statisticIds.forEach((meta) => {
@@ -287,17 +291,31 @@ export class HaStatisticPicker extends LitElement {
}
const id = meta.statistic_id;
const { primary, secondary } = computeEntityPickerDisplay(
hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const primary = entityName || deviceName || id;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
output.push({
@@ -307,12 +325,14 @@ 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,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
},
});
});
@@ -387,18 +407,36 @@ export class HaStatisticPicker extends LitElement {
const stateObj = this.hass.states[statisticId];
if (stateObj) {
const { primary, secondary } = computeEntityPickerDisplay(
this.hass,
stateObj
);
const searchLabels = computeEntitySearchLabels(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const primary = entityName || deviceName || statisticId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
const friendlyName = computeStateName(stateObj); // Keep this for search
const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`;
return {
id: statisticId,
@@ -407,12 +445,15 @@ 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,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
friendlyName,
statisticId,
},
};
}
+13 -16
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
>
@@ -54,7 +51,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="base"
>
${this._localize(
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.base.title`
)}
</ha-switch>
@@ -63,12 +60,12 @@ export class HaAnalytics extends LitElement {
(preference) => html`
<ha-row-item>
<span slot="headline"
>${this._localize(
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}</span
>
<span slot="supporting-text"
>${this._localize(
>${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.description`
)}</span
>
@@ -80,7 +77,7 @@ export class HaAnalytics extends LitElement {
.preference=${preference}
name=${preference}
>
${this._localize(
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.${preference}.title`
)}
</ha-switch>
@@ -91,7 +88,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 +98,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
>
@@ -118,7 +115,7 @@ export class HaAnalytics extends LitElement {
.disabled=${loading}
name="diagnostics"
>
${this._localize(
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.preferences.diagnostics.title`
)}
</ha-switch>
+30 -5
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,10 +197,29 @@ export class HaAreaControlsPicker extends LitElement {
return;
}
const { primary, secondary } = computeEntityPickerDisplay(
this.hass!,
stateObj
);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this.hass!.entities,
this.hass!.devices,
this.hass!.areas,
this.hass!.floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
entityItems.push({
type: "entity",
+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;
}
+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));
}
}
}
+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
+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
}
`;
+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
}
`;
+3 -14
View File
@@ -24,13 +24,11 @@ class HaEntityMarker extends LitElement {
@property({ attribute: "show-icon", type: Boolean }) public showIcon = false;
@property({ type: Boolean, reflect: true }) public selected = false;
protected render() {
return html`
<div
class="marker ${this.entityPicture ? "picture" : ""}"
style=${styleMap({ "--ha-marker-selected-color": this.entityColor })}
style=${styleMap({ "border-color": this.entityColor })}
@click=${this._badgeTap}
>
${
@@ -96,22 +94,13 @@ class HaEntityMarker extends LitElement {
height: var(--ha-marker-size, 48px);
font-size: var(--ha-marker-font-size, var(--ha-font-size-xl));
border-radius: var(--ha-marker-border-radius, 50%);
border: var(--ha-marker-border-width, 3px) solid
var(--ha-marker-color, var(--card-background-color, #fff));
box-shadow: var(--ha-marker-shadow, var(--ha-box-shadow-s));
border: 1px solid var(--ha-marker-color, var(--primary-color));
color: var(--primary-text-color);
background-color: var(
--ha-marker-background,
var(--card-background-color)
);
background-color: var(--card-background-color);
}
.marker.picture {
overflow: hidden;
}
/* A ring in the entity color outside the frame marks the selected marker */
:host([selected]) .marker {
outline: 3px solid var(--ha-marker-selected-color, var(--primary-color));
}
.entity-picture {
background-size: cover;
height: 100%;
+12 -57
View File
@@ -1,17 +1,14 @@
import type { ContextType } from "@lit/context";
import { consume } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { MAP_MAX_ZOOM } from "../../common/map/base-layer";
import type { MapLatLng } from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { internationalizationContext, uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import { internationalizationContext } from "../../data/context";
import type { HomeAssistantInternationalization, ThemeMode } from "../../types";
import "../ha-alert";
import "../ha-input-helper-text";
@@ -19,10 +16,6 @@ import "./ha-map";
import type { HaMap, HaMapEditableLocation } from "./ha-map";
import type { HaIcon } from "../ha-icon";
import type { HaSvgIcon } from "../ha-svg-icon";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
} from "../../common/map/zone-marker";
declare global {
// for fire event
@@ -75,15 +68,6 @@ export class HaLocationsEditor extends LitElement {
@state() private _editingAvailable = true;
// Marker elements bake in theme colors, so they are rebuilt on a theme
// change; narrow the UI context to themes to avoid unrelated rerenders
@state()
@consume({ context: uiContext, subscribe: true })
@transform<ContextType<typeof uiContext>, Themes>({
transformer: ({ themes }) => themes,
})
private _themes?: Themes;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n?: HomeAssistantInternationalization;
@@ -124,10 +108,7 @@ export class HaLocationsEditor extends LitElement {
return html`
<div class="map">
<ha-map
.editableLocations=${this._editableLocations(
this.locations,
this._themes
)}
.editableLocations=${this._editableLocations(this.locations)}
.zoom=${this.zoom}
.autoFit=${this.autoFit}
.themeMode=${this.themeMode}
@@ -159,14 +140,7 @@ export class HaLocationsEditor extends LitElement {
}
private _editableLocations = memoizeOne(
(
locations: MarkerLocation[] | undefined,
themes: Themes | undefined
): HaMapEditableLocation[] => {
if (themes !== this._elementsThemes) {
this._elementsThemes = themes;
this._elements.clear();
}
(locations?: MarkerLocation[]): HaMapEditableLocation[] => {
const ids = new Set((locations ?? []).map((location) => location.id));
for (const id of this._elements.keys()) {
if (!ids.has(id)) {
@@ -178,9 +152,7 @@ export class HaLocationsEditor extends LitElement {
location: [location.latitude, location.longitude],
radius: location.radius,
element: this._elementFor(location),
elementSize: location.radius
? [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE]
: [ICON_SIZE, ICON_SIZE],
elementSize: [ICON_SIZE, ICON_SIZE],
title: location.name,
color: location.radius_color,
locationEditable: location.location_editable,
@@ -193,46 +165,30 @@ export class HaLocationsEditor extends LitElement {
// Reused while unchanged, so ha-map moves markers instead of rebuilding them
private _elements = new Map<string, { key: string; element?: HTMLElement }>();
private _elementsThemes?: Themes;
private _elementFor(location: MarkerLocation): HTMLElement | undefined {
const isZone = !!location.radius;
const key = JSON.stringify([
isZone,
location.icon,
location.iconPath,
location.name,
location.radius_color,
location.location_editable,
]);
const cached = this._elements.get(location.id);
if (cached?.key === key) {
return cached.element;
}
// The zone marker doubles as the circle's draggable center
const element = isZone
? this._createZoneMarker(location)
: this._createIcon(location);
const element = this._createIcon(location);
this._elements.set(location.id, { key, element });
return element;
}
private _createZoneMarker(location: MarkerLocation): HTMLElement {
return createZoneMarkerElement({
color:
location.radius_color ||
getComputedStyle(this).getPropertyValue("--accent-color"),
icon: location.icon,
iconPath: location.iconPath,
name: location.name ?? "",
});
}
private _createIcon(location: MarkerLocation): HTMLElement | undefined {
if (!location.icon && !location.iconPath) {
return undefined;
}
const el = document.createElement("div");
el.className = "named-icon";
el.className = `named-icon ${
location.location_editable ? "draggable" : ""
}`;
if (location.name !== undefined) {
el.innerText = location.name;
}
@@ -340,13 +296,12 @@ export class HaLocationsEditor extends LitElement {
display: block;
height: 100%;
}
/* Over the map, clear of the zoom control, so a fixed-height host shows it.
The control sits at the physical top-left in either direction. */
/* Over the map, clear of the zoom control, so a fixed-height host shows it */
ha-alert {
position: absolute;
top: var(--ha-space-2);
left: 56px;
right: var(--ha-space-2);
inset-inline-start: 56px;
inset-inline-end: var(--ha-space-2);
z-index: 1;
}
`;
+69 -312
View File
@@ -1,4 +1,4 @@
import { consume, ContextConsumer } from "@lit/context";
import { consume } from "@lit/context";
import { isToday } from "date-fns";
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -31,23 +31,15 @@ import type {
} from "../../common/map/map-engine";
import { circleBoundsPoints } from "../../common/map/map-engine";
import { editableCircleStyles } from "../../common/map/editable-circle";
import { entityMapColor, zoneColor } from "../../common/map/entity-map-colors";
import {
createZoneMarkerElement,
ZONE_CIRCLE_SIZE,
zoneMarkerStyles,
} from "../../common/map/zone-marker";
import { filterXSS } from "../../common/util/xss";
import {
configContext,
connectionContext,
formattersContext,
fullEntitiesContext,
internationalizationContext,
statesContext,
uiContext,
} from "../../data/context";
import type { EntityRegistryEntry } from "../../data/entity/entity_registry";
import { ensureMapTilesToken } from "../../data/map_tiles";
import type {
HomeAssistantConfig,
@@ -219,37 +211,9 @@ export interface HaMapEntity {
unit?: string;
name?: string;
focus?: boolean;
hide_accuracy?: boolean;
hide_radius?: boolean;
selected?: boolean;
}
// Data carried by entity markers for rendering cluster bubbles
interface ClusterData {
entityId: string;
picture?: string;
label: string;
showIcon: boolean;
unit: string;
color?: string;
selected: boolean;
zoneId?: string;
}
const CLUSTER_AVATAR_SIZE = 32;
const CLUSTER_BUBBLE_PADDING = 6;
const CLUSTER_BUBBLE_GAP = 4;
const CLUSTER_MAX_AVATARS = 3;
const CLUSTER_MORE_WIDTH = 28;
const CLUSTER_MORE_MAX = 99;
const CLUSTER_TAIL_SIZE = 10;
// The tail is a rotated square on the bubble's bottom edge, reaching half its diagonal below
const CLUSTER_TAIL_HEIGHT = Math.round((CLUSTER_TAIL_SIZE * Math.SQRT2) / 2);
const CLUSTER_ZONE_SPACING = 2;
const CLUSTER_RADIUS = 40;
// Same-zone markers share the zone's bubble while they span at most this many
// pixels; further apart they show their actual positions
const ZONE_GROUP_RADIUS = 160;
@customElement("ha-map")
export class HaMap extends ReactiveElement {
@@ -300,8 +264,6 @@ export class HaMap extends ReactiveElement {
@property({ attribute: "fit-zones", type: Boolean }) public fitZones = false;
private _zonePositions: Record<string, MapLatLng> = {};
@property({ attribute: "theme-mode", type: String })
public themeMode: ThemeMode = "auto";
@@ -334,11 +296,6 @@ export class HaMap extends ReactiveElement {
private _resizeObserver?: ResizeObserver;
// Registry creation order decides the palette colors
@state() private _entityReg: EntityRegistryEntry[] = [];
private _registryConsumer?: ContextConsumer<typeof fullEntitiesContext, this>;
private _entityHandles: MapMarkerHandle[] = [];
private _zoneHandles: MapItemHandle[] = [];
@@ -366,21 +323,6 @@ export class HaMap extends ReactiveElement {
this._attachObserver();
}
// Only maps that draw entities ask for the registry; an editor's map, as in
// onboarding, never does. The context provider shares one subscription.
private _watchRegistry(): void {
if (this._registryConsumer || !this.entities?.length) {
return;
}
this._registryConsumer = new ContextConsumer(this, {
context: fullEntitiesContext,
subscribe: true,
callback: (entries) => {
this._entityReg = entries;
},
});
}
private _handleVisibilityChange = async () => {
if (!document.hidden) {
setTimeout(() => {
@@ -443,7 +385,7 @@ export class HaMap extends ReactiveElement {
}
}
if (changedProps.has("clusterMarkers") || changedProps.has("_entityReg")) {
if (changedProps.has("clusterMarkers")) {
this._drawEntities();
}
@@ -459,11 +401,6 @@ export class HaMap extends ReactiveElement {
if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths();
// Cluster bubbles show entity colors only while trails are visible
const oldPaths = changedProps.get("paths") as HaMapPaths[] | undefined;
if (!!oldPaths?.length !== !!this.paths?.length) {
this._engine?.refreshClusters();
}
}
if (changedProps.has("_loaded") || changedProps.has("editableLocations")) {
@@ -496,19 +433,13 @@ export class HaMap extends ReactiveElement {
const oldUi = changedProps.get("_ui") as HomeAssistantUI | undefined;
if (
!changedProps.has("themeMode") &&
(!changedProps.has("_ui") || (oldUi && oldUi.themes === this._ui.themes))
(!changedProps.has("_ui") ||
(oldUi && oldUi.themes?.darkMode === this._ui.themes?.darkMode))
) {
return;
}
this._updateMapStyle();
// Marker, trail and circle colors were resolved from the theme when drawn
this._drawEntities();
this._drawPaths();
if (this._editableHandles.size) {
this._removeEditableLocations();
this._drawEditableLocations();
}
}
private get _darkMode() {
@@ -1100,36 +1031,16 @@ export class HaMap extends ReactiveElement {
engine.setClustering(null);
return;
}
this._watchRegistry();
const computedStyles = getComputedStyle(this);
// A person's state is "home" for the home zone, the zone name otherwise
const zoneByState: Record<string, string> = {};
this._zonePositions = {};
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
// A zone that is not drawn cannot anchor a bubble either
if (
stateObj &&
computeStateDomain(stateObj) === "zone" &&
(this.renderPassive || !stateObj.attributes.passive)
) {
zoneByState[
stateObj.entity_id === "zone.home"
? "home"
: computeStateName(stateObj)
] = stateObj.entity_id;
if (
typeof stateObj.attributes.latitude === "number" &&
typeof stateObj.attributes.longitude === "number"
) {
this._zonePositions[stateObj.entity_id] = [
stateObj.attributes.latitude,
stateObj.attributes.longitude,
];
}
}
}
const zoneColor = computedStyles.getPropertyValue("--accent-color");
const passiveZoneColor = computedStyles.getPropertyValue(
"--secondary-text-color"
);
const darkPrimaryColor = computedStyles.getPropertyValue(
"--dark-primary-color"
);
for (const entity of this.entities) {
const stateObj = states[getEntityId(entity)];
@@ -1158,29 +1069,26 @@ export class HaMap extends ReactiveElement {
continue;
}
const hideRadius = typeof entity !== "string" && entity.hide_radius;
// A host-set color wins, except passive zones are always muted
const markerColor =
!passive && typeof entity !== "string" && entity.color
? entity.color
: zoneColor(
stateObj.entity_id,
!!passive,
this._entityReg,
computedStyles
);
const zoneMarkerColor = passive ? passiveZoneColor : zoneColor;
if (!hideRadius && radius) {
if (radius) {
this._zoneHandles.push(
engine.addCircle(position, { radius, color: markerColor })
engine.addCircle(position, { radius, color: zoneMarkerColor })
);
}
const circleEl = createZoneMarkerElement({
color: markerColor,
icon,
name: title,
});
// create icon
const iconEl = document.createElement("div");
iconEl.className = `zone-icon ${this._darkMode ? "dark" : "light"}`;
if (icon) {
const el = document.createElement("ha-icon");
el.setAttribute("icon", icon);
iconEl.appendChild(el);
} else {
const el = document.createElement("span");
el.textContent = title;
iconEl.appendChild(el);
}
if (this.interactiveZones) {
const openMoreInfo = (ev: Event) => {
@@ -1189,8 +1097,8 @@ export class HaMap extends ReactiveElement {
entityId: stateObj.entity_id,
});
};
circleEl.addEventListener("click", openMoreInfo);
circleEl.addEventListener("keydown", (ev) => {
iconEl.addEventListener("click", openMoreInfo);
iconEl.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
openMoreInfo(ev);
@@ -1198,9 +1106,10 @@ export class HaMap extends ReactiveElement {
});
}
const zoneIconSize = this._getMarkerSize(computedStyles) / 2;
this._zoneHandles.push(
engine.addMarker(circleEl, position, {
size: [ZONE_CIRCLE_SIZE, ZONE_CIRCLE_SIZE],
engine.addMarker(iconEl, position, {
size: [zoneIconSize, zoneIconSize],
interactive: this.interactiveZones,
title,
})
@@ -1210,7 +1119,7 @@ export class HaMap extends ReactiveElement {
this.fitZones &&
(typeof entity === "string" || entity.focus !== false)
) {
if (!hideRadius && radius) {
if (radius) {
this._focusZonePoints.push(...circleBoundsPoints(position, radius));
} else {
this._focusZonePoints.push(position);
@@ -1254,42 +1163,19 @@ export class HaMap extends ReactiveElement {
entityPicture && (typeof entity === "string" || !entity.label_mode)
? this._connection.hassUrl(entityPicture)
: "";
// A host may leave the color to the map
const entityColor =
(typeof entity !== "string" ? entity.color : undefined) ||
entityMapColor(getEntityId(entity), this._entityReg, computedStyles);
entityMarker.entityColor = entityColor;
if (typeof entity !== "string") {
entityMarker.selected = entity.selected ?? false;
entityMarker.entityColor = entity.color;
}
const clusterData: ClusterData = {
entityId: getEntityId(entity),
picture: entityMarker.entityPicture || undefined,
label: entityName,
showIcon: entityMarker.showIcon,
unit: entityMarker.entityUnit ?? "",
color: entityColor,
selected: typeof entity !== "string" && (entity.selected ?? false),
zoneId: ["person", "device_tracker"].includes(
computeStateDomain(stateObj)
)
? zoneByState[stateObj.state]
: undefined,
};
const showAccuracy =
!!gpsAccuracy && !(typeof entity !== "string" && entity.hide_accuracy);
const markerSize = this._getMarkerSize(computedStyles);
this._entityHandles.push(
engine.addMarker(entityMarker, position, {
size: [markerSize, markerSize],
title,
cluster: true,
clusterData,
decoration: showAccuracy
? { radius: gpsAccuracy!, color: entityColor }
// create circle around if entity has accuracy
decoration: gpsAccuracy
? { radius: gpsAccuracy, color: darkPrimaryColor }
: undefined,
})
);
@@ -1301,97 +1187,22 @@ export class HaMap extends ReactiveElement {
engine.setClustering(
this.clusterMarkers
? {
radius: CLUSTER_RADIUS,
iconBuilder: this._createClusterBubble,
// Everyone in a zone shares its bubble until zooming spreads them
groupKey: (marker) => (marker.clusterData as ClusterData)?.zoneId,
groupRadius: ZONE_GROUP_RADIUS,
}
? { radius: CLUSTER_RADIUS, iconBuilder: this._createClusterIcon }
: null
);
}
// Renders a marker cluster as a bubble of its members' avatars
private _createClusterBubble = (
members: MapMarkerHandle[]
): MapClusterIcon => {
const data = members.map((member) => member.clusterData as ClusterData);
const shown = data.slice(0, CLUSTER_MAX_AVATARS);
const hidden = data.length - shown.length;
// With history trails shown, colored borders match avatars to trails
const showColors = !!this.paths?.length;
const bubble = document.createElement("div");
bubble.className = "cluster-bubble";
for (const member of shown) {
const avatar = document.createElement("ha-entity-marker");
avatar.entityId = member?.entityId;
avatar.entityName = member?.label ?? "";
avatar.entityUnit = member?.unit ?? "";
avatar.showIcon = member?.showIcon ?? false;
avatar.entityPicture = member?.picture ?? "";
avatar.entityColor = member?.color;
if (showColors) {
avatar.style.setProperty(
"--ha-marker-color",
member?.color ?? "var(--primary-color)"
);
avatar.style.setProperty("--ha-marker-border-width", "2px");
}
if (member?.selected) {
avatar.selected = true;
}
bubble.appendChild(avatar);
}
let width =
shown.length * CLUSTER_AVATAR_SIZE +
(shown.length - 1) * CLUSTER_BUBBLE_GAP +
2 * CLUSTER_BUBBLE_PADDING;
if (hidden > 0) {
const more = document.createElement("span");
more.className = "more";
more.textContent =
hidden > CLUSTER_MORE_MAX ? `${CLUSTER_MORE_MAX}+` : `+${hidden}`;
bubble.appendChild(more);
width += CLUSTER_MORE_WIDTH + CLUSTER_BUBBLE_GAP;
}
// A cluster of one zone's occupants attaches to that zone's marker
const zoneId = data[0]?.zoneId;
const zonePosition = zoneId ? this._zonePositions[zoneId] : undefined;
const atZone =
!!zoneId &&
!!zonePosition &&
data.every((member) => member?.zoneId === zoneId);
let height = CLUSTER_AVATAR_SIZE + 2 * CLUSTER_BUBBLE_PADDING;
let root: HTMLElement = bubble;
if (atZone) {
root = document.createElement("div");
root.className = "cluster-marker";
const tail = document.createElement("div");
tail.className = "cluster-bubble-tail";
root.append(bubble, tail);
height += CLUSTER_TAIL_HEIGHT;
}
return {
element: root,
size: [width, height],
// Float above the zone circle, tail pointing at it
...(atZone && zonePosition
? {
location: zonePosition,
anchor: [
width / 2,
height + ZONE_CIRCLE_SIZE / 2 + CLUSTER_ZONE_SPACING,
] as [number, number],
}
: {}),
};
// Renders a marker cluster as a circle with the member count
private _createClusterIcon = (members: MapMarkerHandle[]): MapClusterIcon => {
const size = Math.round(
this._getMarkerSize(getComputedStyle(this)) * (2 / 3)
);
const element = document.createElement("div");
element.className = "marker-cluster";
const count = document.createElement("span");
count.textContent = String(members.length);
element.appendChild(count);
return { element, size: [size, size] };
};
private _drawScaleRuler(): void {
@@ -1426,19 +1237,12 @@ export class HaMap extends ReactiveElement {
}
#map {
height: 100%;
/* A cluster bubble and its tail cast a single shadow around their
combined silhouette (drop-shadow on the wrapper), so no shadow seam
appears between the bubble and its tail. */
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.08))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.12));
}
#map.clickable {
cursor: pointer;
}
#map.dark {
background: #090909;
--ha-cluster-shadow: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.4))
drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
}
#map.forced-dark {
color: #ffffff;
@@ -1459,8 +1263,6 @@ export class HaMap extends ReactiveElement {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
filter: var(--ha-cluster-shadow);
}
.cluster-open-members {
display: flex;
@@ -1472,13 +1274,7 @@ export class HaMap extends ReactiveElement {
max-width: calc(6 * var(--ha-marker-size, 48px) + 5 * 4px + 12px);
background: var(--card-background-color, #fff);
border-radius: 14px;
}
/* Both tails are a rotated square whose upper half sits under the bubble;
drawn behind it, so it never covers a member's frame or selected ring */
.cluster-open-tail,
.cluster-bubble-tail {
position: relative;
z-index: -1;
box-shadow: var(--ha-box-shadow-s);
}
.cluster-open-tail {
width: 10px;
@@ -1531,9 +1327,6 @@ export class HaMap extends ReactiveElement {
.maplibregl-popup-anchor-top .maplibregl-popup-tip {
border-bottom-color: rgba(80, 80, 80, 0.9) !important;
}
.maplibregl-ctrl-bottom-left {
direction: ltr;
}
.dark .leaflet-bar a {
background-color: #1c1c1c;
color: #ffffff;
@@ -1553,65 +1346,17 @@ export class HaMap extends ReactiveElement {
.leaflet-pane {
z-index: 0 !important;
}
.cluster-marker {
display: flex;
flex-direction: column;
align-items: center;
isolation: isolate;
filter: var(--ha-cluster-shadow);
}
/* The wrapper carries the shadow around the bubble-plus-tail outline, so
the bubble itself drops its own to avoid a seam at the tail. */
.cluster-marker .cluster-bubble {
filter: none;
}
.cluster-bubble-tail {
width: ${CLUSTER_TAIL_SIZE}px;
height: ${CLUSTER_TAIL_SIZE}px;
margin-top: ${-CLUSTER_TAIL_SIZE / 2}px;
border-radius: 2px;
background: var(--card-background-color, #fff);
transform: rotate(45deg);
}
.cluster-bubble {
display: flex;
align-items: center;
gap: ${CLUSTER_BUBBLE_GAP}px;
padding: ${CLUSTER_BUBBLE_PADDING}px;
box-sizing: border-box;
background: var(--card-background-color, #fff);
border-radius: 14px;
filter: var(--ha-cluster-shadow);
--ha-marker-size: ${CLUSTER_AVATAR_SIZE}px;
--ha-marker-color: transparent;
--ha-marker-border-width: 1px;
--ha-marker-shadow: none;
--ha-marker-font-size: var(--ha-font-size-s);
/* distinguish letter tiles from the bubble background */
--ha-marker-background: var(--ha-color-fill-neutral-quiet-resting);
}
.cluster-bubble .more {
flex: none;
width: ${CLUSTER_MORE_WIDTH}px;
height: ${CLUSTER_AVATAR_SIZE}px;
/* Zone icons, sized like the Leaflet divIcon they replaced */
.zone-icon {
display: flex;
align-items: center;
justify-content: center;
border-radius: 10px;
background: var(--ha-color-fill-neutral-quiet-resting, #f0f0f0);
color: var(--primary-text-color, #212121);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
text-align: center;
color: var(--primary-text-color);
}
/* Markers are rounded squares to match the cluster bubble avatars */
ha-entity-marker {
--ha-marker-border-radius: var(--ha-border-radius-lg);
.zone-icon.dark {
color: #ffffff;
}
.cluster-bubble ha-entity-marker {
flex: none;
--ha-marker-border-radius: 10px;
}
${unsafeCSS(zoneMarkerStyles)}
.leaflet-control,
.leaflet-top,
.leaflet-bottom {
@@ -1632,7 +1377,6 @@ export class HaMap extends ReactiveElement {
transparent
) !important;
text-shadow: none !important;
direction: ltr;
}
/* the theme tokens follow the page, so forced modes need the opposite values */
#map.forced-light .leaflet-control-scale-line {
@@ -1662,6 +1406,19 @@ export class HaMap extends ReactiveElement {
ha-icon {
--mdc-icon-size: calc(var(--ha-marker-size, 48px) / 2);
}
.marker-cluster {
box-sizing: border-box;
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary-color);
font-size: var(--ha-font-size-m);
}
`;
}
@@ -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,34 @@ export class HaMediaPlayerPicker extends LitElement {
...Object.values(this._states)
.filter(this._filterPlayerEntities)
.map<MediaPlayerComboBoxItem>((stateObj) => {
const friendlyName = computeStateName(stateObj);
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
this._entities,
this._devices,
this._areas,
this._floors
);
const entityId = stateObj.entity_id;
const domainName = domainToName(
this._i18n.localize,
computeDomain(entityId)
);
const { primary, 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,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: entityId,
@@ -152,14 +165,12 @@ 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,
parentDeviceName: parentDeviceName || 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,40 @@ 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(
{
) => {
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
entities,
devices,
areas,
floors,
language: i18n.language,
translationMetadata: i18n.translationMetadata,
},
stateObj
)
floors
);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return { primary, secondary };
}
);
protected render() {
@@ -87,12 +104,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,5 +1,7 @@
import { consume } from "@lit/context";
import {
mdiChevronLeft,
mdiChevronRight,
mdiClose,
mdiDevices,
mdiHome,
@@ -26,11 +28,12 @@ import {
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import {
getDeviceArea,
getDeviceAreaId,
} from "../../common/entity/context/get_device_context";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeRTL } from "../../common/util/compute_rtl";
import type { AreaRegistryEntry } from "../../data/area/area_registry";
import { getConfigEntry } from "../../data/config_entries";
@@ -59,7 +62,6 @@ import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker"
import { floorDefaultIconPath } from "../ha-floor-icon";
import "../ha-button";
import "../ha-icon-button";
import "../ha-icon-next";
import "../ha-state-icon";
import "../ha-svg-icon";
import "../item/ha-list-item-base";
@@ -315,7 +317,19 @@ export class HaTargetPickerItemRow extends LitElement {
></ha-icon-button>
`
: this.subEntry && this.type === "entity"
? html` <ha-icon-next slot="end"></ha-icon-next> `
? html`
<ha-svg-icon
.path=${
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? mdiChevronLeft
: mdiChevronRight
}
slot="end"
></ha-svg-icon>
`
: nothing
}
`;
@@ -671,12 +685,40 @@ export class HaTargetPickerItemRow extends LitElement {
}
if (type === "entity") {
const stateObject: HassEntity | undefined = this.hass.states[item];
const { primary, secondary } = stateObject
? computeEntityPickerDisplay(this.hass, stateObject)
: { primary: item, secondary: undefined };
const entityName = stateObject
? computeEntityName(stateObject, this.hass.entities, this.hass.devices)
: item;
const { area, device, parentDevice } = stateObject
? getEntityContext(
stateObject,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: { area: undefined, device: undefined, parentDevice: undefined };
const deviceName = device ? computeDeviceName(device) : undefined;
const parentDeviceName = parentDevice
? computeDeviceName(parentDevice)
: undefined;
const areaName = area ? computeAreaName(area) : undefined;
const context = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(
computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? " ◂ "
: " ▸ "
);
return {
name: primary,
context: secondary,
name: entityName || deviceName || item,
context,
stateObject,
notFound: !stateObject && item !== "all" && item !== "none",
};
@@ -326,7 +326,7 @@ export class HaTracePathDetails extends LitElement {
this._entityReg,
currentDetail,
undefined,
undefined,
false,
this._manifests
)
: selectedType === "chooseOption"
+1 -1
View File
@@ -301,7 +301,7 @@ export interface TemplateCondition extends BaseCondition {
export interface TriggerCondition extends BaseCondition {
condition: "trigger";
id: string | string[];
id: string;
}
type ShorthandBaseCondition = Omit<BaseCondition, "condition">;
-8
View File
@@ -149,8 +149,6 @@ const formatNumericLimitValue = (
export interface DescribeOptions {
// Skip the user defined alias and describe the underlying config.
ignoreAlias?: boolean;
// Rows with trigger-reference chips render the IDs separately.
hideTriggerIds?: boolean;
}
export const describeTrigger = (
@@ -925,12 +923,6 @@ const tryDescribeCondition = (
return condition.alias;
}
if (condition.condition === "trigger" && options?.hideTriggerIds) {
return hass.localize(
`${conditionsTranslationBaseKey}.trigger.description.summary`
);
}
if (!condition.condition) {
const shorthands: ("and" | "or" | "not")[] = ["and", "or", "not"];
for (const key of shorthands) {
-1
View File
@@ -12,7 +12,6 @@ export const DOMAIN_DEVICE_CLASSES: Record<string, string[]> = {
"door",
"garage_door",
"gas",
"glass_break",
"heat",
"light",
"lock",
+36 -12
View File
@@ -1,12 +1,11 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { getEntityAreaId } from "../../common/entity/context/get_entity_context";
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 { computeStateName } from "../../common/entity/compute_state_name";
import type { RelatedIdSets } from "../../common/search/related-context";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
@@ -124,11 +123,31 @@ export const getEntities = (
// These values are the same for every entity, so compute them once instead
// of inside the map over (potentially thousands of) entities.
const isRTL = computeRTL(
hass.language,
hass.translationMetadata.translations
);
const domainNames = new Map<string, string>();
items = entityIds.map<EntityComboBoxItem>((entityId) => {
const stateObj = hass.states[entityId];
const friendlyName = computeStateName(stateObj); // Keep this for search
const [entityName, deviceName, parentDeviceName, areaName] =
computeEntityNameList(
stateObj,
[
{ type: "entity" },
{ type: "device" },
{ type: "parent_device" },
{ type: "area" },
],
hass.entities,
hass.devices,
hass.areas,
hass.floors
);
const domain = computeDomain(entityId);
let domainName = domainNames.get(domain);
if (domainName === undefined) {
@@ -136,7 +155,14 @@ export const getEntities = (
domainNames.set(domain, domainName);
}
const { primary, secondary } = computeEntityPickerDisplay(hass, stateObj);
const primary = entityName || deviceName || entityId;
const secondary = [
areaName,
parentDeviceName,
entityName ? deviceName : undefined,
]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: `${idPrefix}${entityId}`,
@@ -145,14 +171,12 @@ export const getEntities = (
domain_name: domainName,
sorting_label: [primary, secondary].filter(Boolean).join("_"),
search_labels: {
...computeEntitySearchLabels(
stateObj,
hass.entities,
hass.devices,
hass.areas,
hass.floors
),
entityName: entityName || null,
deviceName: deviceName || null,
parentDeviceName: parentDeviceName || null,
areaName: areaName || null,
domainName: domainName || null,
friendlyName: friendlyName || null,
entityId: entityId,
},
stateObj: stateObj,
+23 -45
View File
@@ -36,51 +36,23 @@ export interface FanEntity extends HassEntityBase {
export type FanDirection = "forward" | "reverse";
// Named speeds are used when there are icons for every step. Fans with more
// steps use numbered speeds ("1", "2", ...) instead.
export type FanSpeed = "off" | "low" | "medium" | "high" | "on" | `${number}`;
export type FanSpeed = "off" | "low" | "medium" | "high" | "on";
const FAN_SPEEDS_NAMED: Partial<Record<number, FanSpeed[]>> = {
export const FAN_SPEEDS: Partial<Record<number, FanSpeed[]>> = {
2: ["off", "on"],
3: ["off", "low", "high"],
4: ["off", "low", "medium", "high"],
};
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 6;
export function computeFanSpeedCount(stateObj: FanEntity): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
return speedCount;
}
export function computeFanSpeeds(stateObj: FanEntity): FanSpeed[] | undefined {
const speedCount = computeFanSpeedCount(stateObj);
if (speedCount > FAN_SPEED_COUNT_MAX_FOR_BUTTONS) {
return undefined;
}
return (
FAN_SPEEDS_NAMED[speedCount] ?? [
"off",
...Array.from(
{ length: speedCount - 1 },
(_, index) => `${index + 1}` as const
),
]
);
}
export const isNumberedFanSpeed = (speed: FanSpeed): speed is `${number}` =>
!isNaN(Number(speed));
export function fanPercentageToSpeed(
stateObj: FanEntity,
value: number
): FanSpeed {
const step = stateObj.attributes.percentage_step ?? 1;
const speedValue = Math.round(value / step);
const speedCount = Math.round(100 / step) + 1;
const speeds = computeFanSpeeds(stateObj);
const speeds = FAN_SPEEDS[speedCount];
return speeds?.[speedValue] ?? "off";
}
@@ -89,8 +61,9 @@ export function fanSpeedToPercentage(
speed: FanSpeed
): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
const speeds = computeFanSpeeds(stateObj);
const speeds = FAN_SPEEDS[speedCount];
if (!speeds) {
return 0;
@@ -103,22 +76,27 @@ export function fanSpeedToPercentage(
return Math.floor(speedValue * step);
}
export function computeFanSpeedCount(stateObj: FanEntity): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
return speedCount;
}
export function computeFanSpeedIcon(
stateObj: FanEntity,
speed: FanSpeed
): string | undefined {
if (speed === "on") {
return mdiFan;
}
if (speed === "off") {
return mdiFanOff;
}
if (isNumberedFanSpeed(speed)) {
return undefined;
}
const index = computeFanSpeeds(stateObj)?.indexOf(speed) ?? 1;
return [mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3][index - 1];
): string {
const speedCount = computeFanSpeedCount(stateObj);
const speeds = FAN_SPEEDS[speedCount];
const index = speeds?.indexOf(speed) ?? 1;
return speed === "on"
? mdiFan
: speed === "off"
? mdiFanOff
: [mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3][index - 1];
}
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 4;
export function computeFanSpeedStateDisplay(
stateObj: FanEntity,
+1 -8
View File
@@ -3,17 +3,10 @@ import type { HomeAssistant } from "../types";
/** Transport with host and port, or with the serial device path. */
export type ModbusEndpoint = [string, string, number] | [string, string];
/** Whether a connection belongs to a config entry or to a YAML hub. */
export type ModbusConnectionSource = "config_entry" | "yaml";
export interface ModbusConnection {
endpoint: ModbusEndpoint;
connected: boolean;
source: ModbusConnectionSource;
/**
* The unit IDs each holder uses, keyed by config entry ID, or by hub name
* when `source` is `"yaml"`.
*/
/** The unit IDs each config entry holds, keyed by config entry ID. */
units: Record<string, number[]>;
}
+6 -11
View File
@@ -6,7 +6,7 @@ import { formatListWithAnds } from "../common/string/format-list";
import { isTemplate } from "../common/string/has-template";
import type { HomeAssistant } from "../types";
import type { Condition } from "./automation";
import { describeCondition, type DescribeOptions } from "./automation_i18n";
import { describeCondition } from "./automation_i18n";
import { localizeDeviceAutomationAction } from "./device/device_automation";
import type { EntityRegistryEntry } from "./entity/entity_registry";
import { domainToName } from "./integration";
@@ -47,7 +47,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
options?: DescribeOptions,
ignoreAlias = false,
manifests?: DomainManifestLookup
): string => {
try {
@@ -56,7 +56,7 @@ export const describeAction = <T extends ActionType>(
entityRegistry,
action,
actionType,
options,
ignoreAlias,
manifests
);
if (typeof description !== "string") {
@@ -79,10 +79,10 @@ const tryDescribeAction = <T extends ActionType>(
entityRegistry: EntityRegistryEntry[],
action: ActionTypes[T],
actionType?: T,
options?: DescribeOptions,
ignoreAlias = false,
manifests?: DomainManifestLookup
): string => {
if (action.alias && !options?.ignoreAlias) {
if (action.alias && !ignoreAlias) {
return action.alias;
}
if (!actionType) {
@@ -322,12 +322,7 @@ const tryDescribeAction = <T extends ActionType>(
return hass.localize(
`${actionTranslationBaseKey}.check_condition.description.full`,
{
condition: describeCondition(
action as Condition,
hass,
entityRegistry,
options
),
condition: describeCondition(action as Condition, hass, entityRegistry),
}
);
}
+1 -3
View File
@@ -38,12 +38,10 @@ export type ItemType =
export const findRelated = (
hass: Pick<HomeAssistant, "callWS">,
itemType: ItemType,
itemId: string,
includeDisabledEntities = false
itemId: string
): Promise<RelatedResult> =>
hass.callWS<RelatedResult>({
type: "search/related",
item_type: itemType,
item_id: itemId,
...(includeDisabledEntities ? { include_disabled_entities: true } : {}),
});
@@ -1,14 +1,16 @@
import { mdiArrowRight } from "@mdi/js";
import { ERR_CONNECTION_LOST } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { formatNumericDuration } from "../../common/datetime/format_duration";
import { fireEvent } from "../../common/dom/fire_event";
import { computeRTL } from "../../common/util/compute_rtl";
import "../../components/ha-alert";
import "../../components/ha-button";
import "../../components/ha-dialog-footer";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import "../../components/ha-icon-arrow-next";
import type { HttpConfig } from "../../data/http";
import {
HTTP_CONFIG_FIELDS,
@@ -156,6 +158,10 @@ export class DialogHttpPendingConfig
const changes = this._changedFields;
const { stable, pending } = this._params.state;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-dialog
@@ -227,7 +233,12 @@ export class DialogHttpPendingConfig
<span class="old"
>${this._formatValue(key, stable[key])}</span
>
<ha-icon-arrow-next></ha-icon-arrow-next>
<ha-svg-icon
.path=${mdiArrowRight}
style=${styleMap({
transform: rtl ? "scaleX(-1)" : "",
})}
></ha-svg-icon>
<span class="new"
>${this._formatValue(key, pending![key])}</span
>
@@ -392,7 +403,7 @@ export class DialogHttpPendingConfig
.values .new {
color: var(--primary-text-color);
}
.values ha-icon-arrow-next {
.values ha-svg-icon {
--mdc-icon-size: 18px;
flex-shrink: 0;
}
@@ -1,318 +0,0 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { relativeTime } from "../../../../common/datetime/relative_time";
import { consumeLocalize } from "../../../../common/decorators/consume-context-entry";
import { transform } from "../../../../common/decorators/transform";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/animation/ha-fade-in";
import "../../../../components/ha-switch";
import "../../../../components/item/ha-row-item";
import type { BackupConfig } from "../../../../data/backup";
import { fetchBackupConfig } from "../../../../data/backup";
import {
apiContext,
configContext,
internationalizationContext,
statesContext,
} from "../../../../data/context";
import type { EntitySources } from "../../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../../data/supervisor/update";
import type { FrontendLocaleData } from "../../../../data/translation";
import type { UpdateEntity, UpdateType } from "../../../../data/update";
import {
getUpdateType,
UpdateEntityFeature,
updateIsInstalling,
} from "../../../../data/update";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../../../../types";
@customElement("ha-more-info-update-backup")
export class HaMoreInfoUpdateBackup extends LitElement {
@property({ attribute: false }) public stateObj?: UpdateEntity;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
public get createBackup(): boolean {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return false;
}
return this._createBackup;
}
protected firstUpdated(): void {
this._setupBackup();
}
protected render() {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
if (!createBackupTexts && !this._backupConfigLoading) {
return nothing;
}
return html`
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${
createBackupTexts ? createBackupTexts.description : undefined
}
>
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in class="skeleton-end" slot="end" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
`;
}
private async _setupBackup(): Promise<void> {
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._createBackupLoading = false;
this._backupConfigLoading = false;
return;
}
let type: UpdateType | undefined = undefined;
try {
this._entitySources = await fetchEntitySourcesWithCache({
callWS: this._api.callWS,
states: this._states,
});
type = getUpdateType(this.stateObj!, this._entitySources!);
} catch (err) {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
}
if (type === undefined) {
this._createBackupLoading = false;
this._backupConfigLoading = false;
return;
}
this._fetchUpdateBackupConfig(type);
this._fetchBackupConfig(type);
}
private async _fetchUpdateBackupConfig(type: UpdateType) {
try {
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
const config = await getSupervisorUpdateConfig(this._api);
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
return;
}
if (type === "addon") {
this._createBackup = config.add_on_backup_before_update;
}
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
private async _fetchBackupConfig(type: UpdateType): Promise<void> {
try {
if (this._isHaOrOsUpdate(type)) {
const { config } = await fetchBackupConfig(this._api);
this._backupConfig = config;
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
private _isHaOrOsUpdate(type: UpdateType): boolean {
return ["home_assistant", "home_assistant_os"].includes(type);
}
private _computeCreateBackupTexts():
{ title: string; description?: string } | undefined {
const updateType = this._entitySources
? getUpdateType(this.stateObj!, this._entitySources)
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
!!this._backupConfig.create_backup.password &&
this._backupConfig.create_backup.agent_ids.length > 0;
if (!isBackupConfigValid) {
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
?.last_completed_automatic_backup
? new Date(this._backupConfig?.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this._locale,
now,
true
),
}
)
: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
}
// App backup
if (updateType === "addon") {
const version = this.stateObj!.attributes.installed_version;
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.app"
),
description: version
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.app_description",
{ version: version }
)
: undefined,
};
}
// Fallback to generic UI
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
}
private _createBackupChanged(ev) {
this._createBackup = ev.target.checked;
}
static styles = css`
:host {
display: block;
width: 100%;
}
ha-row-item {
width: 100%;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-update-backup": HaMoreInfoUpdateBackup;
}
}
+13 -11
View File
@@ -131,10 +131,6 @@ class MoreInfoFan extends LitElement {
return nothing;
}
const supportsOnOff =
supportsFeature(this.stateObj, FanEntityFeature.TURN_ON) ||
supportsFeature(this.stateObj, FanEntityFeature.TURN_OFF);
const supportsSpeed = supportsFeature(
this.stateObj,
FanEntityFeature.SET_SPEED
@@ -178,15 +174,21 @@ class MoreInfoFan extends LitElement {
`
}
${
supportSpeedPercentage && supportsOnOff
supportSpeedPercentage
? html`
<div class="buttons">
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
${
supportSpeedPercentage
? html`
<ha-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing
}
</div>
`
: nothing
@@ -1,32 +1,57 @@
import "@home-assistant/webawesome/dist/components/skeleton/skeleton";
import { consume } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { BINARY_STATE_OFF } from "../../../common/const";
import { relativeTime } from "../../../common/datetime/relative_time";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import { transform } from "../../../common/decorators/transform";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { LocalizeFunc } from "../../../common/translations/localize";
import { sanitizeHttpUrl } from "../../../common/url/sanitize-http-url";
import "../../../components/animation/ha-fade-in";
import "../../../components/buttons/ha-progress-button";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-faded";
import "../../../components/ha-markdown";
import "../../../components/ha-spinner";
import "../../../components/ha-switch";
import "../../../components/item/ha-row-item";
import "../../../components/progress/ha-progress-bar";
import { apiContext, formattersContext } from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { UpdateEntity } from "../../../data/update";
import type { BackupConfig } from "../../../data/backup";
import { fetchBackupConfig } from "../../../data/backup";
import {
apiContext,
configContext,
formattersContext,
internationalizationContext,
statesContext,
} from "../../../data/context";
import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { EntitySources } from "../../../data/entity/entity_sources";
import { fetchEntitySourcesWithCache } from "../../../data/entity/entity_sources";
import { getSupervisorUpdateConfig } from "../../../data/supervisor/update";
import type { FrontendLocaleData } from "../../../data/translation";
import type { UpdateEntity, UpdateType } from "../../../data/update";
import {
getUpdateType,
latestVersionIsSkipped,
updateButtonIsDisabled,
UpdateEntityFeature,
updateIsInstalling,
updateReleaseNotes,
} from "../../../data/update";
import type { HomeAssistantApi, HomeAssistantFormatters } from "../../../types";
import type {
HomeAssistant,
HomeAssistantApi,
HomeAssistantConfig,
HomeAssistantFormatters,
HomeAssistantInternationalization,
} from "../../../types";
import { showAlertDialog } from "../../generic/show-dialog-box";
import "../components/update/ha-more-info-update-backup";
import type { HaMoreInfoUpdateBackup } from "../components/update/ha-more-info-update-backup";
@customElement("more-info-update")
class MoreInfoUpdate extends LitElement {
@@ -36,21 +61,182 @@ class MoreInfoUpdate extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, FrontendLocaleData>({
transformer: ({ locale }) => locale,
})
private _locale!: FrontendLocaleData;
@state()
@consume({ context: formattersContext, subscribe: true })
private _formatters!: HomeAssistantFormatters;
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: HomeAssistantApi;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@state()
@consume({ context: configContext, subscribe: true })
@transform<HomeAssistantConfig, HassConfig>({
transformer: ({ config }) => config,
})
private _config!: HassConfig;
@state() private _releaseNotes?: string | null;
@state() private _error?: string;
@state() private _markdownLoading = true;
@query("ha-more-info-update-backup")
private _backupElement?: HaMoreInfoUpdateBackup;
@state() private _backupConfigLoading = true;
@state() private _backupConfig?: BackupConfig;
@state() private _createBackupLoading = true;
@state() private _createBackup = false;
@state() private _entitySources?: EntitySources;
private async _fetchBackupConfig() {
try {
const { config } = await fetchBackupConfig(this._api);
this._backupConfig = config;
} catch (err) {
// ignore error, because user will get a manual backup option
// eslint-disable-next-line no-console
console.error(err);
} finally {
this._backupConfigLoading = false;
}
}
private async _fetchUpdateBackupConfig(type: UpdateType) {
try {
const config = await getSupervisorUpdateConfig(this._api);
// for home assistant and OS updates
if (this._isHaOrOsUpdate(type)) {
this._createBackup = config.core_backup_before_update;
this._createBackupLoading = false;
return;
}
if (type === "addon") {
this._createBackup = config.add_on_backup_before_update;
}
} catch (err) {
// ignore error, because user can still set the config
// eslint-disable-next-line no-console
console.error(err);
this._createBackup = false;
} finally {
this._createBackupLoading = false;
}
}
private async _fetchEntitySources() {
this._entitySources = await fetchEntitySourcesWithCache({
callWS: this._api.callWS,
states: this._states,
});
}
private _isHaOrOsUpdate(type: UpdateType): boolean {
return ["home_assistant", "home_assistant_os"].includes(type);
}
private _computeCreateBackupTexts():
{ title: string; description?: string } | undefined {
if (
!this.stateObj ||
!supportsFeature(this.stateObj, UpdateEntityFeature.BACKUP)
) {
return undefined;
}
const updateType = this._entitySources
? getUpdateType(this.stateObj, this._entitySources)
: "generic";
if (this._isHaOrOsUpdate(updateType)) {
if (this._backupConfigLoading) {
return undefined;
}
const isBackupConfigValid =
!!this._backupConfig &&
!!this._backupConfig.automatic_backups_configured &&
!!this._backupConfig.create_backup.password &&
this._backupConfig.create_backup.agent_ids.length > 0;
if (!isBackupConfigValid) {
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual"
),
description: this._localize(
"ui.dialogs.more_info_control.update.create_backup.manual_description"
),
};
}
const lastAutomaticBackupDate = this._backupConfig
?.last_completed_automatic_backup
? new Date(this._backupConfig?.last_completed_automatic_backup)
: null;
const now = new Date();
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic"
),
description: lastAutomaticBackupDate
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_last",
{
relative_time: relativeTime(
lastAutomaticBackupDate,
this._locale,
now,
true
),
}
)
: this._localize(
"ui.dialogs.more_info_control.update.create_backup.automatic_description_none"
),
};
}
// App backup
if (updateType === "addon") {
const version = this.stateObj.attributes.installed_version;
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.app"
),
description: version
? this._localize(
"ui.dialogs.more_info_control.update.create_backup.app_description",
{ version: version }
)
: undefined,
};
}
// Fallback to generic UI
return {
title: this._localize(
"ui.dialogs.more_info_control.update.create_backup.generic"
),
};
}
protected render() {
if (
@@ -62,6 +248,7 @@ class MoreInfoUpdate extends LitElement {
return nothing;
}
const createBackupTexts = this._computeCreateBackupTexts();
const releaseUrl = sanitizeHttpUrl(this.stateObj.attributes.release_url);
return html`
@@ -160,9 +347,39 @@ class MoreInfoUpdate extends LitElement {
}
</div>
<div class="footer">
<ha-more-info-update-backup
.stateObj=${this.stateObj}
></ha-more-info-update-backup>
${
createBackupTexts || this._backupConfigLoading
? html`
<ha-row-item
.headline=${createBackupTexts ? createBackupTexts.title : undefined}
.supportingText=${createBackupTexts ? createBackupTexts.description : undefined}
>
${
!createBackupTexts
? html`<ha-fade-in slot="headline" .delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: nothing
}
${
this._createBackupLoading
? html`<ha-fade-in
class="skeleton-end"
slot="end"
.delay=${500}
><wa-skeleton effect="sheen"></wa-skeleton
></ha-fade-in>`
: html`<ha-switch
slot="end"
.checked=${this._createBackup}
@change=${this._createBackupChanged}
.disabled=${updateIsInstalling(this.stateObj)}
></ha-switch>`
}
</ha-row-item>
`
: nothing
}
<div class="actions">
${
this.stateObj.state === BINARY_STATE_OFF &&
@@ -223,6 +440,36 @@ class MoreInfoUpdate extends LitElement {
if (supportsFeature(this.stateObj!, UpdateEntityFeature.RELEASE_NOTES)) {
this._fetchReleaseNotes();
}
if (supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
this._fetchEntitySources()
.then(() => {
const type = getUpdateType(this.stateObj!, this._entitySources!);
if (
isComponentLoaded(this._config, "hassio") &&
["addon", "home_assistant", "home_assistant_os"].includes(type)
) {
this._fetchUpdateBackupConfig(type);
} else {
this._createBackupLoading = false;
}
if (this._isHaOrOsUpdate(type)) {
this._fetchBackupConfig();
} else {
this._backupConfigLoading = false;
}
})
.catch((err) => {
// ignore error, because the generic backup option remains available
// eslint-disable-next-line no-console
console.error(err);
this._createBackupLoading = false;
this._backupConfigLoading = false;
});
} else {
this._createBackupLoading = false;
this._backupConfigLoading = false;
}
}
private async _markdownLoaded() {
@@ -242,12 +489,19 @@ class MoreInfoUpdate extends LitElement {
}
}
get _shouldCreateBackup(): boolean {
if (!supportsFeature(this.stateObj!, UpdateEntityFeature.BACKUP)) {
return false;
}
return this._createBackup;
}
private _handleInstall(): void {
const installData: Record<string, any> = {
entity_id: this.stateObj!.entity_id,
};
if (this._backupElement?.createBackup ?? false) {
if (this._shouldCreateBackup) {
installData.backup = true;
}
@@ -261,6 +515,10 @@ class MoreInfoUpdate extends LitElement {
this._api.callService("update", "install", installData);
}
private _createBackupChanged(ev) {
this._createBackup = ev.target.checked;
}
private _handleSkip(): void {
if (this.stateObj!.attributes.auto_update) {
showAlertDialog(this, {
@@ -331,6 +589,11 @@ class MoreInfoUpdate extends LitElement {
z-index: 10;
}
ha-row-item {
width: 100%;
--ha-row-item-padding-inline: var(--ha-space-6);
}
.actions {
width: 100%;
display: flex;
@@ -364,6 +627,11 @@ class MoreInfoUpdate extends LitElement {
box-sizing: border-box;
padding-bottom: var(--ha-space-4);
}
.skeleton-end {
width: 48px;
height: 24px;
display: block;
}
`;
}
@@ -1,5 +1,6 @@
import { consume } from "@lit/context";
import {
mdiChevronRight,
mdiFan,
mdiHomeImportOutline,
mdiMapMarker,
@@ -24,7 +25,6 @@ import "../../../components/ha-control-select-menu";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-icon";
import "../../../components/ha-svg-icon";
import "../../../components/ha-icon-next";
import {
apiContext,
entitiesContext,
@@ -489,7 +489,7 @@ class MoreInfoVacuum extends LitElement {
</p>
</div>
<div class="icon">
<ha-icon-next></ha-icon-next>
<ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon>
</div>
</button>
`
+42 -53
View File
@@ -29,12 +29,17 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { mainWindow } from "../../common/dom/get_main_window";
import { stopPropagation } from "../../common/dom/stop_propagation";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import {
computeEntityEntryNameList,
computeEntityNameList,
type EntityNameItem,
} from "../../common/entity/compute_entity_name_display";
computeEntityEntryName,
computeEntityName,
} from "../../common/entity/compute_entity_name";
import {
getEntityContext,
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import {
getHistoryState,
@@ -125,13 +130,6 @@ declare global {
const DEFAULT_VIEW: MoreInfoView = "info";
const BREADCRUMB_NAME: EntityNameItem[] = [
{ type: "area" },
{ type: "parent_device" },
{ type: "device" },
{ type: "entity" },
];
@customElement("ha-more-info-dialog")
export class MoreInfoDialog extends DirtyStateProviderMixin<
EntitySettingsState | Helper | Record<string, string[]> | null,
@@ -566,27 +564,44 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
const showCloseIcon =
isDefaultView && this._parentEntityIds.length === 0 && !this._childView;
const breadcrumb = (
stateObj
? computeEntityNameList(
stateObj,
BREADCRUMB_NAME,
const context = stateObj
? getEntityContext(
stateObj,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? getEntityEntryContext(
this._entry,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: this._entry
? computeEntityEntryNameList(
this._entry,
BREADCRUMB_NAME,
this.hass.entities,
this.hass.devices,
this.hass.areas,
this.hass.floors
)
: [entityId]
).filter((v): v is string => Boolean(v));
: undefined;
const entityName = stateObj
? computeEntityName(stateObj, this.hass.entities, this.hass.devices)
: this._entry
? computeEntityEntryName(this._entry, this.hass.devices)
: entityId;
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 breadcrumb = [
areaName,
parentDeviceName,
deviceName,
entityName,
].filter((v): v is string => Boolean(v));
const addToMenuItem = this.hass.localize(
"ui.dialogs.more_info_control.add_to.item"
);
@@ -1016,32 +1031,6 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
this._infoEditMode = false;
this._detailsYamlMode = false;
}
if (changedProps.has("_entityId")) {
this._reportShownEntityToExternalApp(
changedProps.get("_entityId") as string | null | undefined
);
}
}
private _reportShownEntityToExternalApp(
previousEntityId: string | null | undefined
) {
const external = this.hass.auth.external;
if (!external) {
return;
}
if (this._entityId) {
external.fireMessage({
type: "more_info/opened",
payload: { entity_id: this._entityId },
});
} else if (previousEntityId) {
external.fireMessage({
type: "more_info/closed",
payload: { entity_id: previousEntityId },
});
}
}
private _entryUpdated(ev: CustomEvent<ExtEntityRegistryEntry>) {
@@ -24,6 +24,7 @@ export class CloudStepSignin extends LitElement {
check-connection
.hass=${this.hass}
.email=${this.email}
.localize=${this.hass.localize}
@cloud-forgot-password=${this._forgotPassword}
@cloud-logged-in=${this._loggedIn}
></cloud-login>
@@ -1,4 +1,4 @@
import { mdiMenuDown } from "@mdi/js";
import { mdiChevronLeft, mdiMenuDown } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -9,7 +9,6 @@ import { formatLanguageCode } from "../../common/language/format_language";
import "../../components/chips/ha-assist-chip";
import "../../components/ha-dialog";
import "../../components/ha-dropdown";
import "../../components/ha-icon-button-prev";
import type { HaDropdownSelectEvent } from "../../components/ha-dropdown";
import "../../components/ha-dropdown-item";
import { getLanguageOptions } from "../../components/ha-language-picker";
@@ -153,11 +152,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
hideNavigationIcon
? html`<span slot="headerNavigationIcon"></span>`
: this._previousSteps.length
? html`<ha-icon-button-prev
? html`<ha-icon-button
slot="headerNavigationIcon"
.label=${this.hass.localize("ui.common.back") ?? "Back"}
.path=${mdiChevronLeft}
@click=${this._goToPreviousStep}
></ha-icon-button-prev>`
></ha-icon-button>`
: nothing
}
${
-16
View File
@@ -184,20 +184,6 @@ interface EMOutgoingMessageAddEntityTo extends EMMessage {
};
}
interface EMOutgoingMessageMoreInfoOpened extends EMMessage {
type: "more_info/opened";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageMoreInfoClosed extends EMMessage {
type: "more_info/closed";
payload: {
entity_id: string;
};
}
interface EMOutgoingMessageFocusElement extends EMMessage {
type: "focus_element";
payload: {
@@ -230,8 +216,6 @@ type EMOutgoingMessageWithoutAnswer =
| EMOutgoingMessageHaptic
| EMOutgoingMessageImportThreadCredentials
| EMOutgoingMessageMatterCommission
| EMOutgoingMessageMoreInfoOpened
| EMOutgoingMessageMoreInfoClosed
| EMOutgoingMessageSidebarShow
| EMOutgoingMessageTagWrite
| EMOutgoingMessageThemeUpdate
-3
View File
@@ -465,9 +465,6 @@ export const ENTITY_COMPONENT_ICONS: Record<string, ComponentIcons> = {
on: "mdi:alert-circle",
},
},
glass_break: {
default: "mdi:glass-fragile",
},
heat: {
default: "mdi:thermometer",
state: {
+4 -2
View File
@@ -5,7 +5,6 @@ import {
invalidateThemeCache,
} from "../common/dom/apply_themes_on_element";
import { fireEvent } from "../common/dom/fire_event";
import { computeEntityNameDisplayWithoutContext } from "../common/entity/compute_entity_name_display";
import { computeFormatFunctions } from "../common/translations/entity-state";
import { computeLocalize } from "../common/translations/localize";
import {
@@ -605,7 +604,10 @@ export const provideHass = (
value: value !== null ? value : (stateObj.attributes[attribute] ?? ""),
},
],
formatEntityName: computeEntityNameDisplayWithoutContext,
formatEntityName: (stateObj, type) =>
typeof type === "string"
? type
: (stateObj.attributes.friendly_name ?? stateObj.entity_id),
...overrideData,
};
+1 -6
View File
@@ -60,9 +60,7 @@ export class HaInitPage extends LitElement {
: nothing
}
`
: html`<p
class=${classMap({ "loading-text": !this.migration, rtl: document.dir === "rtl" })}
>
: html`<p class=${classMap({ "loading-text": !this.migration })}>
${
this.migration
? html`<span class="migration-text"
@@ -126,9 +124,6 @@ export class HaInitPage extends LitElement {
.loading-text {
opacity: 0.66;
}
.rtl {
direction: rtl;
}
`;
}
-7
View File
@@ -257,13 +257,6 @@ class PartialPanelResolver extends HassRouterPage {
)
) {
await this.rebuild();
// hass.panels can change again while rebuild() is in flight (e.g.
// multiple integrations/resources updating panels around startup), so
// the panel we were about to show may no longer exist. willUpdate will
// re-run _updateRoutes for the newer panels, so just bail out here.
if (!this.hass.panels[this._currentPage]) {
return;
}
const component =
COMPONENTS[this.hass.panels[this._currentPage].component_name];
await promiseTimeout(
-56
View File
@@ -1,56 +0,0 @@
import { ContextProvider } from "@lit/context";
import type { LitElement, PropertyValues } from "lit";
import { buildLiteInternationalization } from "../common/translations/lite-internationalization";
import type { LocalizeFunc } from "../common/translations/localize";
import {
internationalizationContext,
localeContext,
localizeContext,
} from "../data/context";
import type { Constructor } from "../types";
interface LiteLocalizeElement {
localize?: LocalizeFunc<any>;
language?: string;
}
/**
* Provides the i18n contexts from the "lite" localize state, so
* context-consuming components work on pre-login roots that have no `hass`.
*/
export const provideLiteI18nMixin = <
T extends Constructor<LitElement & LiteLocalizeElement>,
>(
superClass: T
) =>
class extends superClass {
private __i18nProvider = new ContextProvider(this, {
context: internationalizationContext,
});
private __localizeProvider = new ContextProvider(this, {
context: localizeContext,
});
private __localeProvider = new ContextProvider(this, {
context: localeContext,
});
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (
this.localize &&
this.language &&
(changedProperties.has("localize") || changedProperties.has("language"))
) {
const value = buildLiteInternationalization(
this.language,
this.localize
);
this.__i18nProvider.setValue(value);
this.__localizeProvider.setValue(value.localize);
this.__localeProvider.setValue(value.locale);
}
}
};
+19 -14
View File
@@ -1,20 +1,21 @@
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators";
import "../../components/ha-dialog";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { AppDialogParams } from "./show-app-dialog";
@customElement("app-dialog")
class DialogApp extends DialogMixin(LitElement) {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
class DialogApp extends DialogMixin<AppDialogParams>(LitElement) {
protected render() {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
open
header-title=${this._localize("ui.panel.page-onboarding.welcome.download_app") || "Click here to download the app"}
header-title=${
this.params.localize("ui.panel.page-onboarding.welcome.download_app") ||
"Click here to download the app"
}
>
<div>
<div class="app-qr">
@@ -26,13 +27,17 @@ class DialogApp extends DialogMixin(LitElement) {
<img
loading="lazy"
src="/static/images/appstore.svg"
alt=${this._localize("ui.panel.page-onboarding.welcome.appstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
class="icon"
/>
<img
loading="lazy"
src="/static/images/qr-appstore.svg"
alt=${this._localize("ui.panel.page-onboarding.welcome.appstore")}
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.appstore"
)}
/>
</a>
<a
@@ -43,7 +48,7 @@ class DialogApp extends DialogMixin(LitElement) {
<img
loading="lazy"
src="/static/images/playstore.svg"
alt=${this._localize(
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
class="icon"
@@ -51,7 +56,7 @@ class DialogApp extends DialogMixin(LitElement) {
<img
loading="lazy"
src="/static/images/qr-playstore.svg"
alt=${this._localize(
alt=${this.params.localize(
"ui.panel.page-onboarding.welcome.playstore"
)}
/>
+14 -14
View File
@@ -1,24 +1,22 @@
import { mdiAccountGroup, mdiOpenInNew } from "@mdi/js";
import { LitElement, css, html } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../common/translations/localize";
import { LitElement, css, html, nothing } from "lit";
import { customElement } from "lit/decorators";
import "../../components/ha-dialog";
import "../../components/ha-svg-icon";
import "../../components/item/ha-list-item-button";
import "../../components/list/ha-list-nav";
import { DialogMixin } from "../../dialogs/dialog-mixin";
import type { CommunityDialogParams } from "./show-community-dialog";
@customElement("community-dialog")
class DialogCommunity extends DialogMixin(LitElement) {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
class DialogCommunity extends DialogMixin<CommunityDialogParams>(LitElement) {
protected render() {
if (!this.params?.localize) {
return nothing;
}
return html`<ha-dialog
open
header-title=${this._localize(
header-title=${this.params.localize(
"ui.panel.page-onboarding.welcome.community"
)}
>
@@ -34,7 +32,7 @@ class DialogCommunity extends DialogMixin(LitElement) {
alt="Home Assistant Logo"
/>
<span slot="headline">
${this._localize("ui.panel.page-onboarding.welcome.forums")}
${this.params.localize("ui.panel.page-onboarding.welcome.forums")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
@@ -49,7 +47,7 @@ class DialogCommunity extends DialogMixin(LitElement) {
alt="Open Home Foundation Logo"
/>
<span slot="headline">
${this._localize(
${this.params.localize(
"ui.panel.page-onboarding.welcome.open_home_newsletter"
)}
</span>
@@ -66,7 +64,7 @@ class DialogCommunity extends DialogMixin(LitElement) {
alt="Discord Logo"
/>
<span slot="headline">
${this._localize("ui.panel.page-onboarding.welcome.discord")}
${this.params.localize("ui.panel.page-onboarding.welcome.discord")}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>
@@ -77,7 +75,9 @@ class DialogCommunity extends DialogMixin(LitElement) {
>
<ha-svg-icon .path=${mdiAccountGroup} slot="start"></ha-svg-icon>
<span slot="headline">
${this._localize("ui.panel.page-onboarding.welcome.social_media")}
${this.params.localize(
"ui.panel.page-onboarding.welcome.social_media"
)}
</span>
<ha-svg-icon slot="end" .path=${mdiOpenInNew}></ha-svg-icon>
</ha-list-item-button>

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