Compare commits

...

27 Commits

Author SHA1 Message Date
Bram Kragten 927c036454 Bumped version to 20260527.2 2026-06-01 19:52:36 +02:00
Paul Bottein 0fefcf809f Fix vacuum and lawn mower features not showing default buttons (#52343) 2026-06-01 19:52:19 +02:00
Bram Kragten a176f3c1ef Allow to set refresh url while dialog is open, use for matter device (#52341)
Allow to set refresh dialog while dialog is open, use for matter device
2026-06-01 19:52:18 +02:00
Wendelin c5152c3472 App-Info: Hide app title on narrow (#52337)
Hide app title on narrow
2026-06-01 19:52:17 +02:00
Wendelin 0150337522 Fix picker default popover-placement (#52336) 2026-06-01 19:52:16 +02:00
Paul Bottein 5d55d543b1 Respect backend order for floors and areas in entity tree (#52329) 2026-06-01 19:52:14 +02:00
George Caliment 4805b22289 Fixed filter flex direction on mobile + removed unused classes (#52327)
* Fixed filter flex direction on mobile + removed unused classes

* Removed hard-coded height to fill all viewport
2026-06-01 19:52:13 +02:00
Simon Lamon 8de411abc3 Show all counter actions if none specified (#52317)
Show all actions if none specified
2026-06-01 19:52:12 +02:00
Jan-Philipp Benecke e455d4384a Use right token for topbar shadow transition (#52306) 2026-06-01 19:52:11 +02:00
karwosts b0dbd825c8 Fix behavior for move view left/right (#52300) 2026-06-01 19:52:10 +02:00
karwosts 69d0fcb666 Fix untracked legend in detail graph card (#52299) 2026-06-01 19:52:09 +02:00
Simon Lamon f7c3ed3b77 Ignore location in description (#52297) 2026-06-01 19:52:08 +02:00
Jan-Philipp Benecke 5ee5b5120e Add box-shadow transition to top app bar (#52292) 2026-06-01 19:52:07 +02:00
karwosts 58fc8160fd Fix missing location data in calendar (#52291) 2026-06-01 19:52:06 +02:00
Bram Kragten 30930e18ab Bumped version to 20260527.1 2026-05-28 16:47:56 +02:00
Paul Bottein 8d0978817d Don't lowercase translated default action label (#52283) 2026-05-28 16:45:20 +02:00
Paul Bottein fc684218ce Preserve PNG transparency on area pictures (#52282) 2026-05-28 16:45:18 +02:00
Paul Bottein 22f29b7561 Fix sun condition Between description showing reversed values (#52279) 2026-05-28 16:45:16 +02:00
Wendelin c7d48aba44 Fix automation add TCA paste (#52276)
Fix automation add paste
2026-05-28 16:45:15 +02:00
Wendelin aeb2285f30 App details improve mobile and icon (#52275)
* icon instead of logo, enable wrap

* Keep logo

* revert test url
2026-05-28 16:45:14 +02:00
Wendelin c692d7cd4e Card visibility-status use ha-alert (#52271) 2026-05-28 16:45:12 +02:00
Wendelin f2d7021a7d Fix automation note keyboard a11y (#52270) 2026-05-28 16:45:11 +02:00
Wendelin 3a649fba22 Fix automation behavior img file names (#52247)
fix behavior img names
2026-05-28 16:45:09 +02:00
Simon Lamon 5362b8f853 Don't redispatch the original event in a checklist item (#52242) 2026-05-28 16:45:08 +02:00
Wendelin d05800bda6 Fix ha-radio-option checked theming (#52237)
Update ha-radio-option theming to use checked-icon-color for text and border
2026-05-28 16:45:07 +02:00
Wendelin d67530ea37 Fix row target count flickering, keyboard nav, type device (#52236)
* Fix row target count flickering

* Add noninteractive for device, fix keyboard nav

* Noninteractive action, conditon

* Remove unsued hass

* invert noninteractive
2026-05-28 16:45:05 +02:00
Petar Petrov bbd7ef676e Render echarts tooltips with Lit templates (#52235)
* Render echarts tooltips with Lit templates

Replace raw HTML string interpolation in echarts tooltip formatters with Lit templates so user-controlled fields (entity friendly_name, device names, node labels) are auto-escaped instead of relying on per-string filterXSS. ha-chart-base now wraps any function tooltip.formatter into a stable per-formatter container and handles Lit TemplateResult / nothing / null returns; the public HaECOption type lets charts express Lit-returning formatters without per-callsite casts.

* Simplify

* Refactor _getSeries

* Small fix

* Fix merge mistake

* Marker component and wrapper test
2026-05-28 16:45:04 +02:00
65 changed files with 1194 additions and 818 deletions

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.0"
version = "20260527.2"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+13
View File
@@ -17,6 +17,19 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
* on load instead of cleaning up the stale dialog state by going back.
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
};
/**
* Ensures all dialogs are closed before navigation.
* Returns true if navigation can proceed, false if a dialog refused to close.
+82 -29
View File
@@ -14,6 +14,7 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
@@ -29,22 +30,59 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type { ECOption } from "../../resources/echarts/echarts";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -66,9 +104,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public options?: ECOption;
@property({ attribute: false }) public options?: HaECOption;
@property({ type: String }) public height?: string;
@@ -614,7 +652,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: ECOption | undefined,
options: HaECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -634,7 +672,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -757,22 +795,34 @@ export class HaChartBase extends LitElement {
xAxis,
};
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
}
return options;
return options as ECOption;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -960,8 +1010,12 @@ export class HaChartBase extends LitElement {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if (s.sampling === "minmax") {
if ((s as LineSeriesOption).sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
? xAxis.min.getTime()
@@ -976,8 +1030,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
return {
...s,
result = {
...result,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -985,11 +1039,10 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
};
} as HaECSeriesItem;
}
}
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
return processSeriesTooltipFormatter(result);
});
return series as ECOption["series"];
}
@@ -1326,8 +1379,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -0,0 +1,41 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => string;
) => TemplateResult | typeof nothing | null;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): ECOption => ({
(categories?: NetworkData["categories"]): HaECOption => ({
tooltip: {
trigger: "item",
confine: true,
+10 -6
View File
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options = {
const options: HaECOption = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.hass=${this.hass}
@@ -103,12 +103,16 @@ export class HaSankeyChart extends LitElement {
: data.value;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${value}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${value}`;
}
return null;
};
+8 -5
View File
@@ -5,10 +5,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -50,13 +50,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options = {
const options: HaECOption = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
} as ECOption;
};
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -71,7 +71,10 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
};
private _createData = memoizeOne(
@@ -0,0 +1,41 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -12,8 +12,9 @@ import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -24,7 +25,6 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +108,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
private _hiddenStats = new Set<string>();
@@ -141,12 +141,11 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -177,52 +176,44 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
color: dataset.color,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
};
private _datasetHidden(ev: CustomEvent) {
@@ -1,11 +1,10 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -15,8 +14,9 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _yWidth = 0;
@@ -69,7 +69,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as ECOption["series"]}
.data=${this._chartData as HaECSeries}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,42 +132,35 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
public willUpdate(changedProps: PropertyValues) {
if (
+93 -82
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -34,12 +34,13 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
@@ -126,7 +127,7 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: ECOption;
@state() private _chartOptions?: HaECOption;
@state() private _hiddenStats = new Set<string>();
@@ -251,91 +252,101 @@ export class StatisticsChart extends LitElement {
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
startTime = new Date(param.value[0]);
}
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(endTime, this.hass.locale, this.hass.config)}`
: "");
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "");
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
options
)}${unit}`;
this.hass.config
);
}
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
};
private _createOptions() {
+1
View File
@@ -20,6 +20,7 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+1 -1
View File
@@ -77,7 +77,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
| "left-end" = "bottom";
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
-1
View File
@@ -121,7 +121,6 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
<slot name="start"></slot>
-1
View File
@@ -152,7 +152,6 @@ export class HaLanguagePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
-1
View File
@@ -82,7 +82,6 @@ export class HaThemePicker extends LitElement {
.disabled=${this.disabled}
.required=${this.required}
@value-changed=${this._changed}
popover-placement="bottom"
></ha-generic-picker>
`;
}
+1
View File
@@ -22,6 +22,7 @@ export const haTopAppBarFixedStyles = css`
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
box-shadow var(--ha-animation-duration-normal) ease,
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
+2 -2
View File
@@ -99,8 +99,8 @@ export class HaRadioOption extends Radio {
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
color: var(--ha-color-fill-primary-loud-resting);
border-color: var(--ha-color-fill-primary-loud-resting);
color: var(--checked-icon-color);
border-color: var(--checked-icon-color);
}
[part~="label"] {
+1
View File
@@ -256,6 +256,7 @@ export const normalizeSubscriptionEventData = (
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
location: eventData.location ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
@@ -185,23 +185,25 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private _renderInfoCard() {
const systemManaged = this._isSystemManaged(this._currentAddon);
return html`<ha-card outlined>
return html` <ha-card outlined>
<div class="card-content">
<div class="addon-header">
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
<div class="title">
${getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)}
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
${!this.narrow
? getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)
: nothing}
<div class="description">
${this._currentAddon.version
? html`
@@ -239,17 +241,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<supervisor-apps-state
.state=${this._currentAddon.state}
></supervisor-apps-state>`
: html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`}
: nothing}
</div>
<ha-chip-set class="capabilities">
@@ -513,7 +505,8 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</div>
${(this._currentAddon.update_available && this._updateEntityId) ||
this._computeShowWebUI ||
this._computeShowIngressUI
this._computeShowIngressUI ||
!this._currentAddon.version
? html`
<div class="card-actions">
${this._currentAddon.update_available && this._updateEntityId
@@ -549,6 +542,19 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</ha-button>
`
: nothing}
${!this._currentAddon.version
? html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`
: nothing}
</div>
`
: nothing}
@@ -1497,16 +1503,17 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
}
.addon-header {
display: flex;
padding-inline-start: var(--ha-space-2);
padding-inline-end: initial;
font-size: var(--ha-font-size-2xl);
color: var(--ha-card-header-color, var(--primary-text-color));
align-items: center;
gap: var(--ha-space-2);
flex-wrap: wrap;
margin-bottom: var(--ha-space-4);
}
.addon-header .title {
flex: 1;
margin-inline-end: var(--ha-space-4);
}
.addon-header .title .description {
@@ -1525,17 +1532,15 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--error-color);
margin-bottom: var(--ha-space-4);
}
.description {
margin-bottom: var(--ha-space-4);
}
.description a {
color: var(--primary-color);
}
img.logo {
max-width: 100%;
max-height: 60px;
max-height: 40px;
display: block;
margin-bottom: var(--ha-space-2);
}
ha-assist-chip {
--md-sys-color-primary: var(--text-primary-color);
@@ -48,8 +48,6 @@ import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry
const cropOptions: CropOptions = {
round: false,
type: "image/jpeg",
quality: 0.75,
};
const SENSOR_DOMAINS = ["sensor"];
@@ -334,13 +334,15 @@ export default class HaAutomationActionRow extends LitElement {
? this._renderTargets(
target,
actionHasTarget && !this._isNew,
serviceTargetSpec
serviceTargetSpec,
type !== "device_id"
)
: nothing}
${noteTooltipText
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -721,13 +723,14 @@ export default class HaAutomationActionRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -30,10 +30,10 @@ import type {
LocalizeFunc,
LocalizeKeys,
} from "../../../common/translations/localize";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import { computeRTL } from "../../../common/util/compute_rtl";
import { debounce } from "../../../common/util/debounce";
import { deepEqual } from "../../../common/util/deep-equal";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import "../../../components/entity/state-badge";
import "../../../components/ha-bottom-sheet";
import "../../../components/ha-button";
@@ -134,8 +134,8 @@ import {
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
getAddAutomationElementTargetFromQuery,
PASTE_VALUE,
getAddAutomationElementTargetFromQuery,
} from "./show-add-automation-element-dialog";
import { getTargetText } from "./target/get_target_text";
@@ -795,37 +795,33 @@ class DialogAddAutomationElement
class="paste"
@click=${this._paste}
>
<div class="shortcut-label">
<div class="label">
<div>
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
</div>
<div class="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
</div>
${!this._narrow
? html`<span class="shortcut">
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing}
<div slot="headline" class="label">
${this.hass.localize(
`ui.panel.config.automation.editor.${automationElementType}s.paste`
)}
</div>
<div slot="supporting-text">
${this.hass.localize(
// @ts-ignore
`ui.panel.config.automation.editor.${automationElementType}s.type.${this._params.clipboardItem}.label`
)}
</div>
${!this._narrow
? html`<span slot="end" class="shortcut">
<span
>${isMac
? html`<ha-svg-icon
slot="start"
.path=${mdiAppleKeyboardCommand}
></ha-svg-icon>`
: this.hass.localize(
"ui.panel.config.automation.editor.ctrl"
)}</span
>
<span>+</span>
<span>V</span>
</span>`
: nothing}
<ha-svg-icon
slot="start"
.path=${mdiContentPaste}
@@ -2546,23 +2542,16 @@ class DialogAddAutomationElement
ha-svg-icon.plus {
color: var(--primary-color);
}
.shortcut-label {
display: flex;
gap: var(--ha-space-3);
justify-content: space-between;
}
.shortcut-label .supporting-text {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
.shortcut-label .shortcut {
.shortcut {
--mdc-icon-size: var(--ha-space-3);
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 2px;
margin-right: var(--ha-space-4);
}
.shortcut-label .shortcut span {
.shortcut span {
font-size: var(--ha-font-size-s);
font-family: var(--ha-font-family-code);
color: var(--ha-color-text-secondary);
@@ -224,13 +224,15 @@ export default class HaAutomationConditionRow extends LitElement {
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
conditionTargetSpec
conditionTargetSpec,
this.condition.condition !== "device"
)
: nothing}
${this.condition.note?.trim()
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -573,13 +575,14 @@ export default class HaAutomationConditionRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -40,37 +40,6 @@ export class HaSunCondition extends LitElement implements ConditionElement {
private _schema = memoizeOne(
(localize: LocalizeFunc, formType: FormType) =>
[
...(["between", "before"].includes(formType)
? [
{
name: "before",
type: "select",
default: BEFORE_DEFAULT,
options: [
[
"sunrise",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunrise"
),
],
[
"sunset",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunset"
),
],
],
},
{
name: "before_offset",
selector: {
duration: {
allow_negative: true,
},
},
},
]
: []),
...(["between", "after"].includes(formType)
? [
{
@@ -102,6 +71,37 @@ export class HaSunCondition extends LitElement implements ConditionElement {
},
]
: []),
...(["between", "before"].includes(formType)
? [
{
name: "before",
type: "select",
default: BEFORE_DEFAULT,
options: [
[
"sunrise",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunrise"
),
],
[
"sunset",
localize(
"ui.panel.config.automation.editor.conditions.type.sun.sunset"
),
],
],
},
{
name: "before_offset",
selector: {
duration: {
allow_negative: true,
},
},
},
]
: []),
] as const
);
@@ -161,6 +161,7 @@ export default class HaAutomationOptionRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -60,6 +60,9 @@ export class HaAutomationRowTargets extends LitElement {
@property({ attribute: false })
public selector?: TargetSelector;
@property({ type: Boolean })
public interactive = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@@ -89,7 +92,12 @@ export class HaAutomationRowTargets extends LitElement {
@consume({ context: statesContext, subscribe: true })
private _states!: ContextType<typeof statesContext>;
private _countCache = new Map<string, Promise<number | undefined>>();
private _countCache = new Map<
string,
Promise<number | undefined> | number | undefined
>();
private _rerenderCount = true;
protected willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps);
@@ -98,10 +106,15 @@ export class HaAutomationRowTargets extends LitElement {
changedProps.has("selector") ||
changedProps.has("_registries")
) {
this._countCache.clear();
this._rerenderCount = true;
}
}
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
this._rerenderCount = false;
}
private _countMatchingEntities(referencedEntities: string[]): number {
const targetSelector = this.selector;
const hasEntityFilter = !!targetSelector?.target?.entity;
@@ -148,7 +161,11 @@ export class HaAutomationRowTargets extends LitElement {
targetId: string
) {
const key = `${targetType}:${targetId}`;
if (!this._countCache.has(key)) {
let fallback = " (-)";
if (!this._countCache.has(key) || this._rerenderCount) {
if (typeof this._countCache.get(key) === "number") {
fallback = ` (${this._countCache.get(key)})`;
}
this._countCache.set(
key,
extractFromTarget(
@@ -162,15 +179,30 @@ export class HaAutomationRowTargets extends LitElement {
.then((result) =>
this._countMatchingEntities(result.referenced_entities)
)
.catch(() => undefined)
.catch((err) => {
// eslint-disable-next-line no-console
console.error("Error counting target entities", err);
return undefined;
})
);
}
return until(
this._countCache
.get(key)!
.then((count) => (count === undefined ? nothing : html` (${count})`)),
"(-)"
);
if (this._countCache.get(key) instanceof Promise) {
return until(
(this._countCache.get(key) as Promise<number | undefined>)!.then(
(count) => {
this._countCache.set(key, count);
return count === undefined ? nothing : html` (${count})`;
}
),
fallback
);
}
if (typeof this._countCache.get(key) === "number") {
return ` (${this._countCache.get(key)})`;
}
return nothing;
}
protected render() {
@@ -249,8 +281,9 @@ export class HaAutomationRowTargets extends LitElement {
<ha-dropdown
@wa-select=${this._handleTargetSelect}
@click=${stopPropagation}
@keydown=${stopPropagation}
>
<span slot="trigger" class="target interactive">
<button slot="trigger" class="target">
<ha-svg-icon .path=${mdiFormatListBulleted}></ha-svg-icon>
<div class="label">
${this._i18n.localize(
@@ -261,7 +294,7 @@ export class HaAutomationRowTargets extends LitElement {
)}
</div>
<ha-svg-icon .path=${mdiMenuDown}></ha-svg-icon>
</span>
</button>
${rows.map(([targetType, targetId]) => {
const content = html`${lastTargetType !== null &&
lastTargetType !== targetType
@@ -316,21 +349,37 @@ export class HaAutomationRowTargets extends LitElement {
targetType?: string,
countTemplate: unknown = nothing
) {
return html`<div
if (!this.interactive || !targetId || !targetType) {
return html`<div
class=${classMap({
target: true,
warning,
error,
})}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
>
${icon}
<div class="label">${label}${countTemplate}</div>
</div>`;
}
return html`<button
class=${classMap({
target: true,
warning,
error,
interactive: targetId && targetType,
})}
.targetId=${targetId}
.targetType=${targetType}
.label=${label}
@click=${this._handleTargetClick}
@keydown=${this._handleTargetKeydown}
>
${icon}
<div class="label">${label}${countTemplate}</div>
</div>`;
</button>`;
}
private _renderTarget(
@@ -384,7 +433,7 @@ export class HaAutomationRowTargets extends LitElement {
targetId,
this._getLabel
);
if (targetType !== "entity") {
if (targetType !== "entity" && this.interactive) {
countTemplate = this._renderCount(targetType, targetId);
}
}
@@ -444,6 +493,13 @@ export class HaAutomationRowTargets extends LitElement {
this._showTargetInfo(target.targetId, target.targetType, target.label, ev);
}
private _handleTargetKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleTargetClick(ev);
}
}
private _handleTargetSelect(
ev: HaDropdownSelectEvent<{
targetId?: string;
@@ -533,10 +589,10 @@ export class HaAutomationRowTargets extends LitElement {
align-items: center;
}
.target.interactive {
button.target {
cursor: pointer;
}
.target.interactive:hover {
button.target:hover {
background: var(--ha-color-fill-neutral-normal-hover);
}
@@ -249,13 +249,15 @@ export default class HaAutomationTriggerRow extends LitElement {
? this._renderTargets(
target,
descriptionHasTarget && !this._isNew,
triggerTargetSpec
triggerTargetSpec,
type !== "device"
)
: nothing}
${type !== "list" &&
(this.trigger as Exclude<Trigger, TriggerList>).note?.trim()
? html`
<ha-svg-icon
tabindex="0"
id="note-icon"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
@@ -557,13 +559,14 @@ export default class HaAutomationTriggerRow extends LitElement {
(
target?: HassServiceTarget,
targetRequired = false,
targetSpec?: TargetSelector["target"]
targetSpec?: TargetSelector["target"],
interactive = false
) =>
html`<ha-automation-row-targets
.hass=${this.hass}
.target=${target}
.targetRequired=${targetRequired}
.selector=${targetSpec ? { target: targetSpec } : undefined}
.interactive=${interactive}
></ha-automation-row-targets>`
);
@@ -299,9 +299,10 @@ class DialogNewDashboard extends LitElement implements HassDialog {
const options: IFuseOptions<CustomStrategyEntry> = {
keys: ["type", "name", "description"],
isCaseSensitive: false,
threshold: 0.3,
ignoreLocation: true,
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.3,
ignoreDiacritics: true,
ignoreLocation: true,
};
const fuse = new Fuse(strategies, options);
return fuse.search(filter).map((result) => result.item);
@@ -330,42 +330,48 @@ export class BluetoothNetworkVisualization extends LitElement {
return rssi > -33 ? 3 : rssi > -66 ? 2 : 1;
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
let tooltipText = "";
if (dataType === "edge") {
const { source, target, value } = data as any;
const sourceName = this._getBluetoothDeviceName(source);
const targetName = this._getBluetoothDeviceName(target);
tooltipText = `${sourceName} ${targetName}`;
if (source !== CORE_SOURCE_ID) {
tooltipText += ` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${value}`;
}
} else {
const { id: address } = data as any;
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
if (btDevice) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}<br><b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${btDevice.rssi}<br><b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b> ${btDevice.source}<br><b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b> ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`;
const device = this._sourceDevices[address];
if (device) {
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
} else {
const device = this._sourceDevices[address];
if (device) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}`;
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${area.name}`;
}
}
}
return html`${sourceName}
${targetName}${source !== CORE_SOURCE_ID
? html` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b>
${value}`
: nothing}`;
}
return tooltipText;
const { id: address } = data as any;
const name = this._getBluetoothDeviceName(address);
const btDevice = this._data.find((d) => d.address === address);
const device = this._sourceDevices[address];
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
const areaLine = area
? html`<br /><b
>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b
>${area.name}`
: nothing;
if (btDevice) {
return html`<b>${name}</b><br />
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b>
${address}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b>
${btDevice.rssi}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b>
${btDevice.source}<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b>
${relativeTime(
new Date(btDevice.time * 1000),
this.hass.locale
)}${areaLine}`;
}
if (device) {
return html`<b>${name}</b><br />
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b>
${address}${areaLine}`;
}
return nothing;
};
private _handleChartClick(e: CustomEvent): void {
@@ -5,7 +5,7 @@ import { dynamicElement } from "../../../../../common/dom/dynamic-element-direct
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import { navigate } from "../../../../../common/navigate";
import { navigate, setRefreshUrl } from "../../../../../common/navigate";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button-arrow-prev";
import "../../../../../components/ha-button";
@@ -100,6 +100,8 @@ class DialogMatterAddDevice extends LitElement {
public showDialog(): void {
this._open = true;
this._unsub = watchForNewMatterDevice(this.hass, (device) => {
// make sure a refresh of the page will navigate to the device page, old iOS apps will refresh the webview when commissioning is done
setRefreshUrl(`/config/devices/device/${device.id}`);
this._newDevice = device;
this._step = "device_added";
this._fetchMainEntity();
@@ -131,7 +131,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data, name } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target, value } = data as any;
@@ -141,40 +141,45 @@ export class ZHANetworkVisualizationPage extends LitElement {
const sourceName = this._networkData.nodes.find(
(node) => node.id === source
)!.name;
const tooltipText = `${sourceName}${targetName}${value ? ` <b>LQI:</b> ${value}` : ""}`;
const reverseValue = this._networkData.links.find(
(link) => link.source === source && link.target === target
)?.reverseValue;
if (reverseValue) {
return `${tooltipText}<br>${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`;
}
return tooltipText;
return html`${sourceName}
${targetName}${value
? html` <b>LQI:</b> ${value}`
: nothing}${reverseValue
? html`<br />${targetName}${sourceName} <b>LQI:</b> ${reverseValue}`
: nothing}`;
}
const device = this._devices.find((d) => d.ieee === (data as any).id);
if (!device) {
return name;
}
let label = `<b>IEEE: </b>${device.ieee}`;
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b>${device.device_type.replace("_", " ")}`;
if (device.nwk != null) {
label += `<br><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`;
}
if (device.manufacturer != null && device.model != null) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device")}: </b>${device.manufacturer} ${device.model}`;
} else {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.device_not_in_db")}</b>`;
return html`${name}`;
}
const haDevice = this.hass.devices[device.device_reg_id] as
| DeviceRegistryEntry
| undefined;
if (haDevice) {
const area = getDeviceArea(haDevice, this.hass.areas);
if (area) {
label += `<br><b>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b>${area.name}`;
}
}
return label;
const area = haDevice
? getDeviceArea(haDevice, this.hass.areas)
: undefined;
return html`<b>IEEE: </b>${device.ieee}<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.device_type")}: </b
>${device.device_type.replace("_", " ")}${device.nwk != null
? html`<br /><b>NWK: </b>${formatAsPaddedHex(device.nwk)}`
: nothing}${device.manufacturer != null && device.model != null
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zha.visualization.device"
)}: </b
>${device.manufacturer} ${device.model}`
: html`<br /><b
>${this.hass.localize(
"ui.panel.config.zha.visualization.device_not_in_db"
)}</b
>`}${area
? html`<br /><b
>${this.hass.localize("ui.panel.config.zha.visualization.area")}: </b
>${area.name}`
: nothing}`;
};
private async _refreshTopology(): Promise<void> {
@@ -9,6 +9,7 @@ import { getDeviceArea } from "../../../../../common/entity/context/get_device_c
import { navigate } from "../../../../../common/navigate";
import { debounce } from "../../../../../common/util/debounce";
import "../../../../../components/chart/ha-network-graph";
import "../../../../../components/chart/ha-chart-tooltip-marker";
import type {
NetworkData,
NetworkLink,
@@ -150,7 +151,7 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
this._searchFilter = (ev.target as HaInputSearch).value ?? "";
}
private _tooltipFormatter = (params: TopLevelFormatterParams): string => {
private _tooltipFormatter = (params: TopLevelFormatterParams) => {
const { dataType, data } = params as CallbackDataParams;
if (dataType === "edge") {
const { source, target, value } = data as any;
@@ -160,39 +161,66 @@ export class ZWaveJSNetworkVisualization extends SubscribeMixin(LitElement) {
sourceDevice?.name_by_user ?? sourceDevice?.name ?? source;
const targetName =
targetDevice?.name_by_user ?? targetDevice?.name ?? target;
let tip = `${sourceName}${targetName}`;
const route =
this._nodeStatistics[source]?.lwr || this._nodeStatistics[source]?.nlwr;
if (route?.protocol_data_rate) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.data_rate")}:</b> ${this.hass.localize(`ui.panel.config.zwave_js.protocol_data_rate.${route.protocol_data_rate}`)}`;
}
if (value) {
tip += `<br><b>RSSI:</b> ${value}`;
}
return tip;
return html`${sourceName}
${targetName}${route?.protocol_data_rate
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.data_rate"
)}:</b
>
${this.hass.localize(
`ui.panel.config.zwave_js.protocol_data_rate.${route.protocol_data_rate}` as any
)}`
: nothing}${value ? html`<br /><b>RSSI:</b> ${value}` : nothing}`;
}
const { id, name } = data as any;
const device = this._devices[id] as DeviceRegistryEntry | undefined;
const nodeStatus = this._nodeStatuses[id];
let tip = `${(params as any).marker} ${name}`;
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.node_id")}:</b> ${id}`;
if (device) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.manufacturer")}:</b> ${device.manufacturer || "-"}`;
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.model")}:</b> ${device.model || "-"}`;
}
if (nodeStatus) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.status")}:</b> ${this.hass.localize(`ui.panel.config.zwave_js.node_status.${nodeStatus.status}`)}`;
if (nodeStatus.zwave_plus_version) {
tip += `<br><b>Z-Wave Plus:</b> ${this.hass.localize("ui.panel.config.zwave_js.visualization.version")} ${nodeStatus.zwave_plus_version}`;
}
}
if (device) {
const area = getDeviceArea(device, this.hass.areas);
if (area) {
tip += `<br><b>${this.hass.localize("ui.panel.config.zwave_js.visualization.area")}:</b> ${area.name}`;
}
}
return tip;
const area = device ? getDeviceArea(device, this.hass.areas) : undefined;
return html`<ha-chart-tooltip-marker
.color=${String((params as CallbackDataParams).color ?? "")}
></ha-chart-tooltip-marker>
${name}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.node_id"
)}:</b
>
${id}${device
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.manufacturer"
)}:</b
>
${device.manufacturer || "-"}<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.model"
)}:</b
>
${device.model || "-"}`
: nothing}${nodeStatus
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.status"
)}:</b
>
${this.hass.localize(
`ui.panel.config.zwave_js.node_status.${nodeStatus.status}` as any
)}${nodeStatus.zwave_plus_version
? html`<br /><b>Z-Wave Plus:</b> ${this.hass.localize(
"ui.panel.config.zwave_js.visualization.version"
)}
${nodeStatus.zwave_plus_version}`
: nothing}`
: nothing}${area
? html`<br /><b
>${this.hass.localize(
"ui.panel.config.zwave_js.visualization.area"
)}:</b
>
${area.name}`
: nothing}`;
};
private _getNetworkData = memoizeOne(
@@ -158,6 +158,7 @@ export default class HaScriptFieldRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
+16 -25
View File
@@ -14,7 +14,6 @@ import {
removeSearchParam,
} from "../../common/url/search-params";
import "../../components/date-picker/ha-date-range-picker";
import "../../components/entity/ha-entity-picker";
import "../../components/ha-icon-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-menu-button";
@@ -305,24 +304,23 @@ export class HaPanelLogbook extends LitElement {
:host {
--ha-generic-picker-max-width: 400px;
}
ha-logbook {
.content {
display: flex;
flex-direction: column;
height: calc(
100vh -
168px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
100vh - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
)
) - var(--safe-area-inset-bottom, 0px)
);
overflow-x: hidden;
padding: 0 0 16px;
}
:host([narrow]) ha-logbook {
height: calc(
100vh -
250px - var(--safe-area-inset-top, 0px) - var(
--safe-area-inset-bottom,
0px
)
);
ha-logbook {
flex: 1;
min-height: 0;
}
ha-date-range-picker {
@@ -337,6 +335,10 @@ export class HaPanelLogbook extends LitElement {
ha-date-range-picker {
width: 100%;
}
.filters {
flex-direction: column;
}
}
:host([narrow]) ha-date-range-picker {
@@ -360,22 +362,11 @@ export class HaPanelLogbook extends LitElement {
flex-wrap: wrap;
}
ha-entity-picker {
display: inline-block;
flex-grow: 1;
max-width: 400px;
}
ha-target-picker {
flex: 1;
max-width: 100%;
min-width: 0;
}
:host([narrow]) ha-entity-picker {
max-width: none;
width: 100%;
}
`,
];
}
@@ -86,7 +86,6 @@ class HuiCounterActionsCardFeature
static getStubConfig(): CounterActionsCardFeatureConfig {
return {
type: "counter-actions",
actions: COUNTER_ACTIONS.map((action) => action),
};
}
@@ -108,10 +107,12 @@ class HuiCounterActionsCardFeature
return null;
}
const actions = this._config?.actions ?? COUNTER_ACTIONS;
return html`
<ha-control-button-group>
${this._config?.actions
?.filter((action) => COUNTER_ACTIONS.includes(action))
${actions
.filter((action) => COUNTER_ACTIONS.includes(action))
.map((action) => {
const button = COUNTER_ACTIONS_BUTTON[action](this._stateObj!);
return html`
@@ -110,20 +110,9 @@ class HuiLawnMowerCommandCardFeature
| undefined;
}
static getStubConfig(
hass: HomeAssistant,
context: LovelaceCardFeatureContext
): LawnMowerCommandsCardFeatureConfig {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
static getStubConfig(): LawnMowerCommandsCardFeatureConfig {
return {
type: "lawn-mower-commands",
commands: stateObj
? LAWN_MOWER_COMMANDS.filter((c) =>
supportsLawnMowerCommand(stateObj, c)
).slice(0, 3)
: [],
};
}
@@ -162,28 +151,28 @@ class HuiLawnMowerCommandCardFeature
const stateObj = this._stateObj as LawnMowerEntity;
const commands = this._config.commands ?? LAWN_MOWER_COMMANDS;
return html`
<ha-control-button-group>
${LAWN_MOWER_COMMANDS.filter(
(command) =>
supportsLawnMowerCommand(stateObj, command) &&
this._config?.commands?.includes(command)
).map((command) => {
const button = LAWN_MOWER_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.lawn_mower.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
${commands
.filter((command) => supportsLawnMowerCommand(stateObj, command))
.map((command) => {
const button = LAWN_MOWER_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.lawn_mower.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
@@ -52,6 +52,12 @@ export const VACUUM_COMMANDS_FEATURES: Record<
return_home: [VacuumEntityFeature.RETURN_HOME],
};
export const VACUUM_DEFAULT_COMMANDS: VacuumCommand[] = [
"start_pause",
"stop",
"return_home",
];
export const supportsVacuumCommand = (
stateObj: HassEntity,
command: VacuumCommand
@@ -154,20 +160,9 @@ class HuiVacuumCommandCardFeature
| undefined;
}
static getStubConfig(
hass: HomeAssistant,
context: LovelaceCardFeatureContext
): VacuumCommandsCardFeatureConfig {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
static getStubConfig(): VacuumCommandsCardFeatureConfig {
return {
type: "vacuum-commands",
commands: stateObj
? VACUUM_COMMANDS.filter((c) =>
supportsVacuumCommand(stateObj, c)
).slice(0, 3)
: [],
};
}
@@ -204,28 +199,28 @@ class HuiVacuumCommandCardFeature
const stateObj = this._stateObj as VacuumEntity;
const commands = this._config.commands ?? VACUUM_DEFAULT_COMMANDS;
return html`
<ha-control-button-group>
${VACUUM_COMMANDS.filter(
(command) =>
supportsVacuumCommand(stateObj, command) &&
this._config?.commands?.includes(command)
).map((command) => {
const button = VACUUM_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.vacuum.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
${commands
.filter((command) => supportsVacuumCommand(stateObj, command))
.map((command) => {
const button = VACUUM_COMMANDS_BUTTONS[command](stateObj);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.dialogs.more_info_control.vacuum.${button.translationKey}`
)}
@click=${this._onCommandTap}
.disabled=${button.disabled || stateObj.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
@@ -1,4 +1,6 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { html, nothing } from "lit";
import {
subHours,
differenceInDays,
@@ -31,10 +33,10 @@ import {
formatDateWeekdayVeryShortDate,
} from "../../../../../common/datetime/format_date";
import { formatTime } from "../../../../../common/datetime/format_time";
import type { ECOption } from "../../../../../resources/echarts/echarts";
import { filterXSS } from "../../../../../common/util/xss";
import type { HaECOption } from "../../../../../resources/echarts/echarts";
import type { StatisticPeriod } from "../../../../../data/recorder";
import { getPeriodicAxisLabelConfig } from "../../../../../components/chart/axis-label";
import "../../../../../components/chart/ha-chart-tooltip-marker";
import { getSuggestedPeriod } from "../../../../../data/energy";
export { fillDataGapsAndRoundCaps } from "../../../../../components/chart/round-caps";
@@ -110,7 +112,7 @@ export function getCommonOptions(
formatTotal?: (total: number) => string,
detailedDailyData = false,
yAxisFractionDigits = 1
): ECOption {
): HaECOption {
const suggestedPeriod = getSuggestedPeriod(start, end, detailedDailyData);
let suggestedMax = getSuggestedMax(suggestedPeriod, end, detailedDailyData);
@@ -134,7 +136,7 @@ export function getCommonOptions(
}
}
const monthTimeAxis: ECOption = {
const monthTimeAxis: HaECOption = {
xAxis: {
type: "time",
min: subDays(start, MONTH_TIME_AXIS_PADDING),
@@ -146,7 +148,7 @@ export function getCommonOptions(
splitNumber: Math.min(differenceInCalendarMonths(end, start), 5),
},
};
const normalTimeAxis: ECOption = {
const normalTimeAxis: HaECOption = {
xAxis: {
type: "time",
min: start,
@@ -154,7 +156,7 @@ export function getCommonOptions(
},
};
const options: ECOption = {
const options: HaECOption = {
...(suggestedPeriod === "month" ? monthTimeAxis : normalTimeAxis),
yAxis: {
type: "value",
@@ -179,7 +181,7 @@ export function getCommonOptions(
},
tooltip: {
trigger: "axis",
formatter: (params: TopLevelFormatterParams): string => {
formatter: (params: TopLevelFormatterParams) => {
// trigger: "axis" gives an array of params, but "item" gives a single param
if (Array.isArray(params)) {
const mainItems: CallbackDataParams[] = [];
@@ -191,7 +193,7 @@ export function getCommonOptions(
mainItems.push(param);
}
});
return [mainItems, compareItems]
const sections = [mainItems, compareItems]
.map((items) =>
formatTooltip(
items,
@@ -204,8 +206,12 @@ export function getCommonOptions(
formatTotal
)
)
.filter(Boolean)
.join("<br><br>");
.filter((s): s is TemplateResult => s !== nothing);
if (sections.length === 0) return nothing;
return html`${sections.map(
(section, i) =>
html`${i > 0 ? html`<br /><br />` : nothing}${section}`
)}`;
}
return formatTooltip(
[params],
@@ -232,9 +238,9 @@ function formatTooltip(
showCompareYear: boolean,
unit?: string,
formatTotal?: (total: number) => string
) {
): TemplateResult | typeof nothing {
if (!params[0]?.value) {
return "";
return nothing;
}
// displayX may be shifted from the period start (see EnergyDataPoint);
// originalStart has the real date for display. Gap-filled entries lack it.
@@ -258,43 +264,50 @@ function formatTooltip(
period += ` ${formatTime(addHours(date, 1), locale, config)}`;
}
}
const title = `<h4 style="text-align: center; margin: 0;">${period}</h4>`;
let sumPositive = 0;
let countPositive = 0;
let sumNegative = 0;
let countNegative = 0;
const values = params
.map((param) => {
const y = param.value?.[1] as number;
const value = formatNumber(
y,
locale,
y < 0.1 ? { maximumFractionDigits: 3 } : undefined
);
if (value === "0") {
return false;
const rows: TemplateResult[] = [];
for (const param of params) {
const y = param.value?.[1] as number;
const value = formatNumber(
y,
locale,
y < 0.1 ? { maximumFractionDigits: 3 } : undefined
);
if (value === "0") {
continue;
}
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
if (param.componentSubType === "bar") {
if (y > 0) {
sumPositive += y;
countPositive++;
} else {
sumNegative += y;
countNegative++;
}
}
return `${param.marker} ${filterXSS(param.seriesName!)}: <div style="direction:ltr; display: inline;">${value} ${unit}</div>`;
})
.filter(Boolean);
let footer = "";
if (sumPositive !== 0 && countPositive > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumPositive)}</b>`;
}
rows.push(
html`<ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName}:
<div style="direction:ltr; display: inline;">${value} ${unit}</div>`
);
}
if (sumNegative !== 0 && countNegative > 1 && formatTotal) {
footer += `<br><b>${formatTotal(sumNegative)}</b>`;
if (rows.length === 0) {
return nothing;
}
return values.length > 0 ? `${title}${values.join("<br>")}${footer}` : "";
return html`<h4 style="text-align: center; margin: 0;">${period}</h4>
${rows.map(
(row, i) => html`${i > 0 ? html`<br />` : nothing}${row}`
)}${sumPositive !== 0 && countPositive > 1 && formatTotal
? html`<br /><b>${formatTotal(sumPositive)}</b>`
: nothing}${sumNegative !== 0 && countNegative > 1 && formatTotal
? html`<br /><b>${formatTotal(sumNegative)}</b>`
: nothing}`;
}
function getDatapointX(datapoint: NonNullable<LineSeriesOption["data"]>[0]) {
@@ -44,7 +44,7 @@ import {
getCompareTransform,
} from "./common/energy-chart-options";
import { storage } from "../../../../common/decorators/storage";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@@ -161,7 +161,8 @@ export class HuiEnergyDevicesDetailGraphCard
UNIT,
this._compareStart,
this._compareEnd,
this._yAxisFractionDigits
this._yAxisFractionDigits,
this._legendData
)}
click-label-for-more-info
@dataset-hidden=${this._datasetHidden}
@@ -215,8 +216,9 @@ export class HuiEnergyDevicesDetailGraphCard
unit: string | undefined,
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption => {
yAxisFractionDigits: number,
legendData: CustomLegendOption["data"]
): HaECOption => {
const commonOptions = getCommonOptions(
start,
end,
@@ -230,8 +232,8 @@ export class HuiEnergyDevicesDetailGraphCard
yAxisFractionDigits
);
const selected = this._legendData
? this._legendData
const selected = legendData
? legendData
.filter(
(d) =>
d.id && this._hiddenStats.includes(this._getStatIdFromId(d.id))
@@ -247,7 +249,7 @@ export class HuiEnergyDevicesDetailGraphCard
legend: {
show: true,
type: "custom",
data: this._legendData,
data: legendData,
selected,
},
grid: {
@@ -9,10 +9,10 @@ import type { BarSeriesOption, PieSeriesOption } from "echarts/charts";
import { PieChart } from "echarts/charts";
import type { ECElementEvent } from "echarts/types/dist/shared";
import type { PieDataItemOption } from "echarts/types/src/chart/pie/PieSeries";
import { filterXSS } from "../../../../common/util/xss";
import { getGraphColorByIndex } from "../../../../common/color/colors";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
@@ -30,7 +30,7 @@ import type { HomeAssistant } from "../../../../types";
import type { LovelaceCard } from "../../types";
import type { EnergyDevicesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "../../../../components/ha-card";
import { fireEvent } from "../../../../common/dom/fire_event";
import { measureTextWidth } from "../../../../util/text";
@@ -198,24 +198,28 @@ export class HuiEnergyDevicesGraphCard
`;
}
private _renderTooltip(params: any) {
const deviceName = filterXSS(this._getDeviceName(params.name));
const title = `<h4 style="text-align: center; margin: 0;">${deviceName}</h4>`;
private _renderTooltip = (params: any) => {
const deviceName = this._getDeviceName(params.name);
const value = `${formatNumber(
params.value[0] as number,
this.hass.locale,
params.value < 0.1 ? { maximumFractionDigits: 3 } : undefined
)} kWh ${params.percent ? `(${params.percent} %)` : ""}`;
return `${title}${params.marker} ${params.seriesName}: <div style="direction:ltr; display: inline;">${value}</div>`;
}
return html`<h4 style="text-align: center; margin: 0;">${deviceName}</h4>
<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${params.seriesName}:
<div style="direction:ltr; display: inline;">${value}</div>`;
};
private _createOptions = memoizeOne(
(
data: (BarSeriesOption | PieSeriesOption)[],
chartType: "bar" | "pie",
legendData: typeof this._legendData
): ECOption => {
const options: ECOption = {
): HaECOption => {
const options: HaECOption = {
grid: {
top: 5,
left: 5,
@@ -225,7 +229,7 @@ export class HuiEnergyDevicesGraphCard
},
tooltip: {
show: true,
formatter: this._renderTooltip.bind(this),
formatter: this._renderTooltip,
},
xAxis: { show: false },
yAxis: { show: false },
@@ -35,7 +35,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -177,7 +177,7 @@ export class HuiEnergyGasGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption =>
): HaECOption =>
getCommonOptions(
start,
end,
@@ -37,7 +37,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -65,7 +65,7 @@ export class HuiEnergySolarGraphCard
};
}
@state() private _chartData: ECOption["series"][] = [];
@state() private _chartData: (BarSeriesOption | LineSeriesOption)[] = [];
@state() private _yAxisFractionDigits = 1;
@@ -175,7 +175,7 @@ export class HuiEnergySolarGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption =>
): HaECOption =>
getCommonOptions(
start,
end,
@@ -213,7 +213,7 @@ export class HuiEnergySolarGraphCard
}
}
const datasets: ECOption["series"] = [];
const datasets: (BarSeriesOption | LineSeriesOption)[] = [];
const computedStyles = getComputedStyle(this);
@@ -6,10 +6,7 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import type { BarSeriesOption } from "echarts/charts";
import type {
TooltipOption,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import type { TopLevelFormatterParams } from "echarts/types/dist/shared";
import { getEnergyColor } from "./common/color";
import { formatNumber } from "../../../../common/number/format_number";
import "../../../../components/chart/ha-chart-base";
@@ -43,7 +40,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
const colorPropertyMap = {
to_grid: "--energy-grid-return-color",
@@ -196,7 +193,7 @@ export class HuiEnergyUsageGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption => {
): HaECOption => {
const commonOptions = getCommonOptions(
start,
end,
@@ -209,15 +206,22 @@ export class HuiEnergyUsageGraphCard
false,
yAxisFractionDigits
);
const options: ECOption = {
const tooltip = commonOptions.tooltip;
const baseFormatter =
tooltip &&
!Array.isArray(tooltip) &&
typeof tooltip.formatter === "function"
? tooltip.formatter
: undefined;
const options: HaECOption = {
...commonOptions,
tooltip: {
...commonOptions.tooltip,
formatter: (params: TopLevelFormatterParams): string => {
formatter: (params: TopLevelFormatterParams) => {
if (!Array.isArray(params)) {
return "";
return nothing;
}
params.sort((a, b) => {
const sorted = [...params].sort((a, b) => {
const aValue = (a.value as number[])?.[1];
const bValue = (b.value as number[])?.[1];
if (aValue > 0 && bValue < 0) {
@@ -231,9 +235,7 @@ export class HuiEnergyUsageGraphCard
}
return a.componentIndex - b.componentIndex;
});
return (
(commonOptions.tooltip as TooltipOption)?.formatter as any
)?.(params);
return baseFormatter ? baseFormatter(sorted) : nothing;
},
},
};
@@ -34,7 +34,7 @@ import {
getCommonOptions,
getCompareTransform,
} from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { formatNumber } from "../../../../common/number/format_number";
import "./common/hui-energy-graph-chip";
import "../../../../components/ha-tooltip";
@@ -177,7 +177,7 @@ export class HuiEnergyWaterGraphCard
compareStart: Date | undefined,
compareEnd: Date | undefined,
yAxisFractionDigits: number
): ECOption =>
): HaECOption =>
getCommonOptions(
start,
end,
@@ -24,7 +24,7 @@ import type { LovelaceCard } from "../../types";
import type { PowerSourcesGraphCardConfig } from "../types";
import { hasConfigChanged } from "../../common/has-changed";
import { getCommonOptions, fillLineGaps } from "./common/energy-chart-options";
import type { ECOption } from "../../../../resources/echarts/echarts";
import type { HaECOption } from "../../../../resources/echarts/echarts";
import { hex2rgb } from "../../../../common/color/convert-color";
import type { CustomLegendOption } from "../../../../components/chart/ha-chart-base";
@@ -148,7 +148,7 @@ export class HuiPowerSourcesGraphCard
compareEnd: Date | undefined,
legendData: CustomLegendOption["data"] | undefined,
yAxisFractionDigits: number
): ECOption => ({
): HaECOption => ({
...getCommonOptions(
start,
end,
@@ -155,7 +155,7 @@ export class HuiActionEditor extends LitElement {
this.defaultAction
? ` (${this.hass!.localize(
`ui.panel.lovelace.editor.action-editor.actions.${this.defaultAction}`
).toLowerCase()})`
)})`
: ""
}`,
};
@@ -84,6 +84,7 @@ export class HuiBadgePicker extends LitElement {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
ignoreLocation: true,
};
const fuse = new Fuse(badges, options);
badges = fuse.search(filter).map((result) => result.item);
@@ -6,6 +6,7 @@ import {
} from "@mdi/js";
import type { FuseIndex } from "fuse.js";
import Fuse from "fuse.js";
import { getAreasFloorHierarchy } from "../../../../common/areas/areas-floor-hierarchy";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeDomain } from "../../../../common/entity/compute_domain";
@@ -13,7 +14,6 @@ import { computeEntityName } from "../../../../common/entity/compute_entity_name
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { stringCompare } from "../../../../common/string/compare";
import { entityComboBoxKeys } from "../../../../data/entity/entity_picker";
import { getFloorAreaLookup } from "../../../../data/floor_registry";
import { domainToName } from "../../../../data/integration";
import { multiTermSortedSearch } from "../../../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../../../types";
@@ -206,23 +206,24 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
return stringCompare(an, bn, language);
};
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] =>
[...source.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language));
const buildAreaNode = (areaId: string): AreaNode | undefined => {
const area = areaReg[areaId];
if (!area) return undefined;
const directIds = (areaDirectEntities.get(areaId) ?? []).sort(sortByName);
const byDevice = areaDeviceEntities.get(areaId);
const devices: DeviceNode[] = byDevice
? [...byDevice.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language))
: [];
const devices = byDevice ? buildDeviceNodes(byDevice) : [];
if (!directIds.length && !devices.length) return undefined;
return {
id: area.area_id,
@@ -235,14 +236,14 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
const areas = Object.values(areaReg);
const floors = Object.values(floorReg);
const floorAreaLookup = getFloorAreaLookup(areas);
const hierarchy = getAreasFloorHierarchy(floors, areas);
const floorNodes: FloorNode[] = floors
.map((floor) => {
const areaList = (floorAreaLookup[floor.floor_id] ?? [])
.map((a) => buildAreaNode(a.area_id))
.filter((a): a is AreaNode => !!a)
.sort((a, b) => stringCompare(a.name, b.name, language));
const floorNodes: FloorNode[] = hierarchy.floors
.map(({ id, areas: areaIds }) => {
const floor = floorReg[id];
const areaList = areaIds
.map((areaId) => buildAreaNode(areaId))
.filter((a): a is AreaNode => !!a);
if (!areaList.length) return undefined;
return {
id: floor.floor_id,
@@ -252,26 +253,11 @@ export function buildEntityTree(input: BuildEntityTreeInput): EntityTree {
areas: areaList,
};
})
.filter((f): f is FloorNode => !!f)
.sort((a, b) => stringCompare(a.name, b.name, language));
.filter((f): f is FloorNode => !!f);
const otherAreas = areas
.filter((a) => !a.floor_id || !floorReg[a.floor_id])
.map((a) => buildAreaNode(a.area_id))
.filter((a): a is AreaNode => !!a)
.sort((a, b) => stringCompare(a.name, b.name, language));
const buildDeviceNodes = (source: Map<string, string[]>): DeviceNode[] =>
[...source.entries()]
.map(([id, ids]) => {
const device = deviceReg[id];
return {
id,
name: (device ? computeDeviceName(device) : undefined) ?? id,
entityIds: ids.sort(sortByName),
};
})
.sort((a, b) => stringCompare(a.name, b.name, language));
const otherAreas = hierarchy.areas
.map((areaId) => buildAreaNode(areaId))
.filter((a): a is AreaNode => !!a);
const buildDomainGroups = (source: Map<string, string[]>): DomainGroup[] =>
[...source.entries()]
@@ -91,6 +91,7 @@ export class HuiCardPicker extends LitElement {
minMatchCharLength: Math.min(filter.length, 2),
threshold: 0.2,
ignoreDiacritics: true,
ignoreLocation: true,
};
const fuse = new Fuse(cards, options);
cards = fuse.search(filter).map((result) => result.item);
@@ -1,9 +1,10 @@
import { consume } from "@lit/context";
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
import "../../../../components/ha-alert";
import "../../../../components/ha-svg-icon";
import { HaRowItem } from "../../../../components/item/ha-row-item";
import type { HomeAssistant } from "../../../../types";
@@ -28,17 +29,15 @@ const STATE_ICONS: Record<VisibilityState, string> = {
/**
* @element ha-visibility-status
* @extends {HaRowItem}
*
* @summary
* Row-style banner that surfaces the live visibility result for a set of
* lovelace conditions. Replaces the static explanation alert at the top of
* card / section / badge / conditional-card visibility editors.
* Alert banner that surfaces the live visibility result for a set of
* lovelace conditions.
*
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state (reflected for styling).
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
*/
@customElement("ha-visibility-status")
export class HaVisibilityStatus extends HaRowItem {
export class HaVisibilityStatus extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
@@ -48,7 +47,7 @@ export class HaVisibilityStatus extends HaRowItem {
@consume({ context: conditionsEntityContext, subscribe: true })
private _entityContext?: ConditionsEntityContext;
@property({ reflect: true })
@property()
public state: VisibilityState = "visible";
private _listeners = new ConditionListenersController(this);
@@ -71,23 +70,27 @@ export class HaVisibilityStatus extends HaRowItem {
}
}
protected override _renderInner(): TemplateResult {
public render() {
return html`
<div part="start" class="start">
<ha-svg-icon .path=${STATE_ICONS[this.state]}></ha-svg-icon>
</div>
<div part="content" class="content">
<div part="headline" class="headline">
<ha-alert
.alertType=${this.state === "visible"
? "success"
: this.state === "hidden"
? "warning"
: "error"}
>
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
<div class="headline">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.headline`
)}
</div>
<div part="supporting-text" class="supporting">
<div class="supporting">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.supporting${(this.conditions?.length ?? 0) === 0 ? "_empty" : ""}`
)}
</div>
</div>
</ha-alert>
`;
}
@@ -117,37 +120,13 @@ export class HaVisibilityStatus extends HaRowItem {
static styles: CSSResultGroup = [
HaRowItem.styles,
css`
:host {
ha-alert {
display: block;
border-radius: var(--ha-border-radius-xl);
transition: background-color var(--ha-animation-duration-normal)
ease-in-out;
}
.base {
padding: var(--ha-space-4);
}
:host([state="visible"]) {
background-color: var(--ha-color-fill-success-quiet-resting);
--visibility-status-color: var(--ha-color-on-success-normal);
}
:host([state="hidden"]) {
background-color: var(--ha-color-fill-warning-quiet-resting);
--visibility-status-color: var(--ha-color-on-warning-normal);
}
:host([state="invalid"]) {
background-color: var(--ha-color-fill-danger-quiet-resting);
--visibility-status-color: var(--ha-color-on-danger-normal);
}
.start {
align-self: start;
}
.start ha-svg-icon {
color: var(--visibility-status-color);
--mdc-icon-size: 24px;
}
.headline {
font-weight: var(--ha-font-weight-medium);
white-space: normal;
margin-bottom: var(--ha-space-1);
}
`,
];
@@ -0,0 +1,59 @@
import type { HaFormSchema } from "../../../../components/ha-form/types";
interface CustomizableListSchemaParams {
field: string;
customize: boolean;
options: { value: string; label: string }[];
}
export const customizableListSchema = ({
field,
customize,
options,
}: CustomizableListSchemaParams) =>
[
{
name: "customize",
selector: { boolean: {} },
},
...(customize
? ([
{
name: field,
selector: {
select: {
mode: "list",
reorder: true,
multiple: true,
options,
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
] as const satisfies readonly HaFormSchema[];
// `customize` is form-only and never stored in the config.
export const customizableListData = <T extends object>(
config: T,
field: string
): T & { customize: boolean } => ({
...config,
customize: (config as Record<string, unknown>)[field] !== undefined,
});
// Dropping the field lets the feature fall back to its own default.
export const processCustomizableListValue = <T extends object>(
value: T & { customize?: boolean },
field: string,
defaults: readonly string[]
): T => {
const { customize, ...rest } = value;
const config = rest as Record<string, unknown>;
if (customize && !config[field]) {
config[field] = [...defaults];
} else if (!customize) {
delete config[field];
}
return config as unknown as T;
};
@@ -2,16 +2,20 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
import {
COUNTER_ACTIONS,
type LovelaceCardFeatureContext,
type CounterActionsCardFeatureConfig,
import type {
CounterActionsCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import { COUNTER_ACTIONS } from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-counter-actions-card-feature-editor")
export class HuiCounterActionsCardFeatureEditor
@@ -28,26 +32,17 @@ export class HuiCounterActionsCardFeatureEditor
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "actions",
selector: {
select: {
multiple: true,
mode: "list",
reorder: true,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: `${localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions.${action}`
)}`,
})),
},
},
},
] as const
private _schema = memoizeOne((customize: boolean) =>
customizableListSchema({
field: "actions",
customize,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions_list.${action}`
),
})),
})
);
protected render() {
@@ -55,12 +50,13 @@ export class HuiCounterActionsCardFeatureEditor
return nothing;
}
const schema = this._schema(this.hass.localize);
const data = customizableListData(this._config, "actions");
const schema = this._schema(data.customize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@@ -69,19 +65,21 @@ export class HuiCounterActionsCardFeatureEditor
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const config =
processCustomizableListValue<CounterActionsCardFeatureConfig>(
ev.detail.value,
"actions",
COUNTER_ACTIONS
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.counter-actions.${schema.name}`
);
}
declare global {
@@ -3,7 +3,6 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
@@ -14,6 +13,11 @@ import type {
} from "../../card-features/types";
import { LAWN_MOWER_COMMANDS } from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-lawn-mower-commands-card-feature-editor")
export class HuiLawnMowerCommandsCardFeatureEditor
@@ -31,27 +35,19 @@ export class HuiLawnMowerCommandsCardFeatureEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc, stateObj?: HassEntity) =>
[
{
name: "commands",
selector: {
select: {
multiple: true,
mode: "list",
options: LAWN_MOWER_COMMANDS.filter(
(command) =>
stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: `${localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
)}`,
})),
},
},
},
] as const
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -60,15 +56,16 @@ export class HuiLawnMowerCommandsCardFeatureEditor
}
const stateObj = this.context?.entity_id
? this.hass.states[this.context?.entity_id]
? this.hass.states[this.context.entity_id]
: undefined;
const schema = this._schema(this.hass.localize, stateObj);
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@@ -77,23 +74,27 @@ export class HuiLawnMowerCommandsCardFeatureEditor
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const stateObj = this.context?.entity_id
? this.hass!.states[this.context.entity_id]
: undefined;
const defaults = LAWN_MOWER_COMMANDS.filter(
(command) => stateObj && supportsLawnMowerCommand(stateObj, command)
);
const config =
processCustomizableListValue<LawnMowerCommandsCardFeatureConfig>(
ev.detail.value,
"commands",
defaults
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "commands":
return this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.lawn-mower-commands.${schema.name}`
);
}
declare global {
@@ -3,17 +3,24 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
import { supportsVacuumCommand } from "../../card-features/hui-vacuum-commands-card-feature";
import {
supportsVacuumCommand,
VACUUM_DEFAULT_COMMANDS,
} from "../../card-features/hui-vacuum-commands-card-feature";
import type {
LovelaceCardFeatureContext,
VacuumCommandsCardFeatureConfig,
} from "../../card-features/types";
import { VACUUM_COMMANDS } from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
import {
customizableListData,
customizableListSchema,
processCustomizableListValue,
} from "./customizable-list-feature";
@customElement("hui-vacuum-commands-card-feature-editor")
export class HuiVacuumCommandsCardFeatureEditor
@@ -31,27 +38,19 @@ export class HuiVacuumCommandsCardFeatureEditor
}
private _schema = memoizeOne(
(localize: LocalizeFunc, stateObj?: HassEntity) =>
[
{
name: "commands",
selector: {
select: {
multiple: true,
mode: "list",
options: VACUUM_COMMANDS.filter(
(command) =>
stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: `${localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
)}`,
})),
},
},
},
] as const
(stateObj: HassEntity | undefined, customize: boolean) =>
customizableListSchema({
field: "commands",
customize,
options: VACUUM_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
).map((command) => ({
value: command,
label: this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.commands_list.${command}`
),
})),
})
);
protected render() {
@@ -60,15 +59,16 @@ export class HuiVacuumCommandsCardFeatureEditor
}
const stateObj = this.context?.entity_id
? this.hass.states[this.context?.entity_id]
? this.hass.states[this.context.entity_id]
: undefined;
const schema = this._schema(this.hass.localize, stateObj);
const data = customizableListData(this._config, "commands");
const schema = this._schema(stateObj, data.customize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
@@ -77,23 +77,27 @@ export class HuiVacuumCommandsCardFeatureEditor
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
const stateObj = this.context?.entity_id
? this.hass!.states[this.context.entity_id]
: undefined;
const defaults = VACUUM_DEFAULT_COMMANDS.filter(
(command) => stateObj && supportsVacuumCommand(stateObj, command)
);
const config =
processCustomizableListValue<VacuumCommandsCardFeatureConfig>(
ev.detail.value,
"commands",
defaults
);
fireEvent(this, "config-changed", { config });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
case "commands":
return this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.${schema.name}`
);
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
) =>
this.hass!.localize(
`ui.panel.lovelace.editor.features.types.vacuum-commands.${schema.name}`
);
}
declare global {
+2 -2
View File
@@ -1105,11 +1105,11 @@ class HUIRoot extends LitElement {
const lovelace = this.lovelace!;
const oldIndex = this._curView as number;
const newIndex = (this._curView as number) - 1;
this._curView = newIndex;
if (!this.config.views[oldIndex].path) {
this._navigateToView(newIndex, true);
}
lovelace.saveConfig(swapView(lovelace.config, oldIndex, newIndex));
this._selectView(newIndex);
}
private _moveViewRight(ev) {
@@ -1120,11 +1120,11 @@ class HUIRoot extends LitElement {
const lovelace = this.lovelace!;
const oldIndex = this._curView as number;
const newIndex = (this._curView as number) + 1;
this._curView = newIndex;
if (!this.config.views[oldIndex].path) {
this._navigateToView(newIndex, true);
}
lovelace.saveConfig(swapView(lovelace.config, oldIndex, newIndex));
this._selectView(newIndex);
}
private _addView() {
+8
View File
@@ -67,6 +67,14 @@ export type ECOption = ComposeOption<
| SunburstSeriesOption
>;
export type {
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
LitTooltipFormatter,
} from "./ha-ec-option";
// Register the required components
echarts.use([
BarChart,
+33
View File
@@ -0,0 +1,33 @@
import type { TemplateResult, nothing } from "lit";
import type { TooltipOption } from "echarts/types/dist/shared";
import type { ECOption } from "./echarts";
export type LitTooltipFormatter<P = any> = (
params: P,
ticket?: string
) => TemplateResult | typeof nothing | null | undefined;
export type HaTooltipOption = Omit<TooltipOption, "formatter"> & {
formatter?: string | LitTooltipFormatter;
};
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
/** Single series item with optional Lit tooltip formatter */
export type HaECSeriesItem = Omit<RawSeriesOption, "tooltip"> & {
tooltip?: HaTooltipOption;
};
/** Series array passed to ha-chart-base `.data` */
export type HaECSeries = HaECSeriesItem[];
export type HaECOption = {
[K in keyof ECOption]: K extends "tooltip"
? HaTooltipOption | HaTooltipOption[] | undefined
: K extends "series"
? HaECSeriesItem | HaECSeriesItem[] | undefined
: ECOption[K];
};
+16
View File
@@ -1,5 +1,6 @@
/* eslint-disable no-console */
import type { ReactiveElement, PropertyValues } from "lit";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { closeLastDialog } from "../dialogs/make-dialog-manager";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
@@ -38,6 +39,21 @@ export const urlSyncMixin = <
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
if (mainWindow.history.state?.dialog) {
const refreshUrl = mainWindow.history.state.refreshUrl;
if (typeof refreshUrl === "string") {
// Page was refreshed while a dialog had stashed an intended
// destination URL. Clean up the stale dialog state and route
// to the intended URL. We bypass navigate() because its
// ensureDialogsClosed loop would spin until timeout on the
// dangling state.dialog with no actual dialog open.
mainWindow.history.replaceState(null, "", refreshUrl);
// Defer: the host element's firstUpdated registers the
// location-changed listener after super.firstUpdated() returns.
setTimeout(() => {
fireEvent(mainWindow, "location-changed", { replace: true });
});
return;
}
// this is a page refresh with a dialog open
// the dialog stack must be empty in this case so this state should be cleaned up
mainWindow.history.back();
+6 -2
View File
@@ -5575,7 +5575,7 @@
"sunset": "Sunset",
"description": {
"picker": "Tests if the sun is above or below the horizon.",
"between": "Between {beforeChoice, select, \n sunrise { sunrise}\n sunset { sunset}\n other {}\n}{beforeOffsetChoice, select, \n offset { {beforeOffset}}\n other {}\n} and {afterChoice, select, \n sunrise { sunrise}\n sunset { sunset}\n other {}\n}{afterOffsetChoice, select, \n offset { {afterOffset}}\n other {}\n}",
"between": "Between {afterChoice, select, \n sunrise { sunrise}\n sunset { sunset}\n other {}\n}{afterOffsetChoice, select, \n offset { {afterOffset}}\n other {}\n} and {beforeChoice, select, \n sunrise { sunrise}\n sunset { sunset}\n other {}\n}{beforeOffsetChoice, select, \n offset { {beforeOffset}}\n other {}\n}",
"before": "Before {beforeChoice, select, \n sunrise { sunrise}\n sunset { sunset}\n other {}\n}{beforeOffsetChoice, select, \n offset { {beforeOffset}}\n other {}\n}",
"after": "After {afterChoice, select, \n sunrise { sunrise}\n sunset { sunset}\n other {}\n}{afterOffsetChoice, select, \n offset { {afterOffset}}\n other {}\n}"
}
@@ -10150,6 +10150,7 @@
},
"vacuum-commands": {
"label": "Vacuum commands",
"customize": "Customize commands",
"commands": "Commands",
"commands_list": {
"start_pause": "[%key:ui::dialogs::more_info_control::vacuum::start_pause%]",
@@ -10221,7 +10222,9 @@
},
"counter-actions": {
"label": "Counter actions",
"actions": {
"customize": "Customize actions",
"actions": "Actions",
"actions_list": {
"increment": "Increment",
"decrement": "Decrement",
"reset": "Reset"
@@ -10284,6 +10287,7 @@
},
"lawn-mower-commands": {
"label": "Lawn mower commands",
"customize": "Customize commands",
"commands": "Commands",
"commands_list": {
"start_pause": "Start Pause",
@@ -0,0 +1,38 @@
import { html, nothing } from "lit";
import { describe, expect, it } from "vitest";
import type { LitTooltipFormatter } from "../../../src/resources/echarts/echarts";
import { wrapLitTooltipFormatter } from "../../../src/components/chart/lit-tooltip-formatter";
describe("wrapLitTooltipFormatter", () => {
it("renders TemplateResult into a stable container", () => {
const formatter = () => html`<b>Hello</b>`;
const wrapped = wrapLitTooltipFormatter(formatter);
const first = wrapped({});
const second = wrapped({});
expect(first).toBe(second);
expect(first?.tagName).toBe("DIV");
expect(first?.style.display).toBe("contents");
expect(first?.textContent).toContain("Hello");
});
it("returns null for nothing, null, and undefined", () => {
const returnNothing: LitTooltipFormatter = () => nothing;
expect(wrapLitTooltipFormatter(returnNothing)({})).toBeNull();
expect(wrapLitTooltipFormatter(() => null)({})).toBeNull();
expect(wrapLitTooltipFormatter(() => undefined)({})).toBeNull();
});
it("returns the same wrapped function for the same formatter", () => {
const formatter = () => html`x`;
expect(wrapLitTooltipFormatter(formatter)).toBe(
wrapLitTooltipFormatter(formatter)
);
});
it("does not double-wrap an already wrapped formatter", () => {
const formatter = () => html`x`;
const wrapped = wrapLitTooltipFormatter(formatter);
expect(wrapLitTooltipFormatter(wrapped)).toBe(wrapped);
});
});