Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30930e18ab | |||
| 8d0978817d | |||
| fc684218ce | |||
| 22f29b7561 | |||
| c7d48aba44 | |||
| aeb2285f30 | |||
| c692d7cd4e | |||
| f2d7021a7d | |||
| 3a649fba22 | |||
| 5362b8f853 | |||
| d05800bda6 | |||
| d67530ea37 | |||
| bbd7ef676e |
@@ -62,11 +62,10 @@ host reflects `aria-multiselectable`.
|
||||
|
||||
**Events**
|
||||
|
||||
- `ha-list-item-selected` — an option was selected. Detail is the option's
|
||||
index (`number`). In single mode this is the only selection event; in multi
|
||||
mode it fires for each option added to the selection.
|
||||
- `ha-list-item-deselected` — an option was deselected (multi mode only). Detail
|
||||
is the option's index (`number`).
|
||||
- `ha-list-selected` — selection changed. Detail
|
||||
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
|
||||
`index` is a `number` in single mode (`-1` when nothing selected) and a
|
||||
`Set<number>` in multi mode.
|
||||
|
||||
**Methods / getters**
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import "../../../../src/components/item/ha-list-item-option";
|
||||
import "../../../../src/components/list/ha-list-base";
|
||||
import "../../../../src/components/list/ha-list-nav";
|
||||
import "../../../../src/components/list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
|
||||
|
||||
type Appearance = "line" | "checkbox";
|
||||
type Position = "start" | "end";
|
||||
@@ -184,7 +185,7 @@ export class DemoHaList extends LitElement {
|
||||
<ha-card header="Single select, appearance=line">
|
||||
<ha-list-selectable
|
||||
aria-label="Single select"
|
||||
@ha-list-item-selected=${this._onSingle}
|
||||
@ha-list-selected=${this._onSingle}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -204,8 +205,7 @@ export class DemoHaList extends LitElement {
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi select line"
|
||||
@ha-list-item-selected=${this._onMultiLineSelected}
|
||||
@ha-list-item-deselected=${this._onMultiLineDeselected}
|
||||
@ha-list-selected=${this._onMultiLine}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -227,8 +227,7 @@ export class DemoHaList extends LitElement {
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox start"
|
||||
@ha-list-item-selected=${this._onMultiCheckStartSelected}
|
||||
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
|
||||
@ha-list-selected=${this._onMultiCheckStart}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -254,8 +253,7 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
|
||||
<ha-list-selectable
|
||||
multi
|
||||
aria-label="Multi checkbox end"
|
||||
@ha-list-item-selected=${this._onMultiCheckEndSelected}
|
||||
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
|
||||
@ha-list-selected=${this._onMultiCheckEnd}
|
||||
>
|
||||
${this._options.map(
|
||||
(o, i) => html`
|
||||
@@ -349,58 +347,20 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
|
||||
this._buttonClicks++;
|
||||
};
|
||||
|
||||
private _withIndex(
|
||||
value: number | Set<number>,
|
||||
index: number,
|
||||
selected: boolean
|
||||
): Set<number> {
|
||||
const next = new Set(value instanceof Set ? value : []);
|
||||
if (selected) {
|
||||
next.add(index);
|
||||
} else {
|
||||
next.delete(index);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
private _onSingle = (ev: CustomEvent<number>) => {
|
||||
this._single = ev.detail;
|
||||
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._single = ev.detail.index;
|
||||
};
|
||||
|
||||
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
|
||||
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiLine = ev.detail.index;
|
||||
};
|
||||
|
||||
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
|
||||
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiCheckStart = ev.detail.index;
|
||||
};
|
||||
|
||||
private _onMultiCheckStartSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckStart = this._withIndex(
|
||||
this._multiCheckStart,
|
||||
ev.detail,
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
private _onMultiCheckStartDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckStart = this._withIndex(
|
||||
this._multiCheckStart,
|
||||
ev.detail,
|
||||
false
|
||||
);
|
||||
};
|
||||
|
||||
private _onMultiCheckEndSelected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckEnd = this._withIndex(this._multiCheckEnd, ev.detail, true);
|
||||
};
|
||||
|
||||
private _onMultiCheckEndDeselected = (ev: CustomEvent<number>) => {
|
||||
this._multiCheckEnd = this._withIndex(
|
||||
this._multiCheckEnd,
|
||||
ev.detail,
|
||||
false
|
||||
);
|
||||
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
|
||||
this._multiCheckEnd = ev.detail.index;
|
||||
};
|
||||
|
||||
static styles = css`
|
||||
|
||||
|
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 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "home-assistant-frontend"
|
||||
version = "20260527.0"
|
||||
version = "20260527.1"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*"]
|
||||
description = "The Home Assistant frontend"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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> ";
|
||||
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 (
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -107,7 +107,6 @@ export class HaExpansionPanel extends LitElement {
|
||||
}
|
||||
const newExpanded = !this.expanded;
|
||||
fireEvent(this, "expanded-will-change", { expanded: newExpanded });
|
||||
|
||||
this._container.style.overflow = "hidden";
|
||||
|
||||
if (newExpanded) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { mdiFilterVariantRemove } from "@mdi/js";
|
||||
import type { PropertyValues } from "lit";
|
||||
import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -9,18 +9,14 @@ import { stringCompare } from "../common/string/compare";
|
||||
import { deepEqual } from "../common/util/deep-equal";
|
||||
import type { RelatedResult } from "../data/search";
|
||||
import { findRelated } from "../data/search";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import { loadVirtualizer } from "../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import "./ha-check-list-item";
|
||||
import "./ha-expansion-panel";
|
||||
import "./ha-list";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
import "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable-virtualized";
|
||||
import type { HaListSelectableVirtualized } from "./list/ha-list-selectable-virtualized";
|
||||
import type { HaListVirtualizedItem } from "./list/ha-list-virtualized";
|
||||
|
||||
interface HaFilterDevicesItem extends HaListVirtualizedItem {
|
||||
name: string;
|
||||
}
|
||||
|
||||
@customElement("ha-filter-devices")
|
||||
export class HaFilterDevices extends LitElement {
|
||||
@@ -38,12 +34,15 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
@state() private _filter?: string;
|
||||
|
||||
@query("ha-list-selectable-virtualized")
|
||||
private _listElement?: HaListSelectableVirtualized;
|
||||
@query("ha-list") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
}
|
||||
|
||||
if (
|
||||
properties.has("value") &&
|
||||
!deepEqual(this.value, properties.get("value"))
|
||||
@@ -52,20 +51,6 @@ export class HaFilterDevices extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded || !this._listElement) {
|
||||
return;
|
||||
}
|
||||
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 38px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-expansion-panel
|
||||
@@ -81,7 +66,6 @@ export class HaFilterDevices extends LitElement {
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
@keydown=${this._handleClearFilterKeydown}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -90,46 +74,75 @@ export class HaFilterDevices extends LitElement {
|
||||
appearance="outlined"
|
||||
.value=${this._filter}
|
||||
@input=${this._handleSearchChange}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
>
|
||||
</ha-input-search>
|
||||
<ha-list-selectable-virtualized
|
||||
multi
|
||||
.rows=${this._devices(this.hass.devices, this._filter || "")}
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this._renderItem}
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
></ha-list-selectable-virtualized>`
|
||||
<ha-list class="ha-scrollbar" multi>
|
||||
<lit-virtualizer
|
||||
.items=${this._devices(
|
||||
this.hass.devices,
|
||||
this._filter || "",
|
||||
this.value
|
||||
)}
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${this._renderItem}
|
||||
@click=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>`
|
||||
: nothing}
|
||||
</ha-expansion-panel>
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderItem = (item?: HaFilterDevicesItem) =>
|
||||
!item
|
||||
? nothing
|
||||
: html`<ha-list-item-option
|
||||
style="width: 100%;"
|
||||
appearance="checkbox"
|
||||
selection-position="end"
|
||||
.value=${item.id}
|
||||
.selected=${this.value?.includes(item.id) ?? false}
|
||||
>
|
||||
<span slot="headline">${item.name}</span>
|
||||
</ha-list-item-option>`;
|
||||
private _keyFunction = (device) => device?.id;
|
||||
|
||||
private _handleAdded(ev: CustomEvent<number>) {
|
||||
this.value = [
|
||||
...(this.value ?? []),
|
||||
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
|
||||
];
|
||||
private _renderItem = (device) =>
|
||||
!device
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
tabindex="0"
|
||||
.value=${device.id}
|
||||
.selected=${this.value?.includes(device.id) ?? false}
|
||||
>
|
||||
${computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)}
|
||||
</ha-check-list-item>`;
|
||||
|
||||
private _handleItemKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._handleItemClick(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleRemoved(ev: CustomEvent<number>) {
|
||||
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
|
||||
.id;
|
||||
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
|
||||
private _handleItemClick(ev) {
|
||||
const listItem = ev.target.closest("ha-check-list-item");
|
||||
const value = listItem?.value;
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (this.value?.includes(value)) {
|
||||
this.value = this.value?.filter((val) => val !== value);
|
||||
} else {
|
||||
this.value = [...(this.value || []), value];
|
||||
}
|
||||
listItem.selected = this.value?.includes(value);
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("expanded") && this.expanded) {
|
||||
setTimeout(() => {
|
||||
if (!this.expanded) return;
|
||||
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
|
||||
// 49px - height of a header + 1px
|
||||
// 4px - padding-top of the search-input
|
||||
// 32px - height of the search input
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
private _expandedWillChange(ev) {
|
||||
@@ -142,38 +155,30 @@ export class HaFilterDevices extends LitElement {
|
||||
|
||||
private _handleSearchChange(ev: InputEvent) {
|
||||
const target = ev.target as HaInputSearch;
|
||||
this._filter = target.value ?? "";
|
||||
}
|
||||
|
||||
private _handleSearchKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "ArrowDown" && this._listElement) {
|
||||
ev.preventDefault();
|
||||
this._listElement.focus();
|
||||
}
|
||||
this._filter = (target.value ?? "").toLowerCase();
|
||||
}
|
||||
|
||||
private _devices = memoizeOne(
|
||||
(
|
||||
devices: HomeAssistant["devices"],
|
||||
filter: string
|
||||
): HaFilterDevicesItem[] => {
|
||||
(devices: HomeAssistant["devices"], filter: string, _value) => {
|
||||
const values = Object.values(devices);
|
||||
return values
|
||||
.map((device) => ({
|
||||
id: device.id,
|
||||
interactive: true,
|
||||
name: computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
),
|
||||
}))
|
||||
.filter(
|
||||
({ name }) =>
|
||||
!filter || name.toLowerCase().includes(filter.toLowerCase())
|
||||
(device) =>
|
||||
!filter ||
|
||||
computeDeviceNameDisplay(
|
||||
device,
|
||||
this.hass.localize,
|
||||
this.hass.states
|
||||
)
|
||||
.toLowerCase()
|
||||
.includes(filter)
|
||||
)
|
||||
.sort((a, b) =>
|
||||
stringCompare(a.name, b.name, this.hass.locale.language)
|
||||
stringCompare(
|
||||
computeDeviceNameDisplay(a, this.hass.localize, this.hass.states),
|
||||
computeDeviceNameDisplay(b, this.hass.localize, this.hass.states),
|
||||
this.hass.locale.language
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -212,13 +217,6 @@ export class HaFilterDevices extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleClearFilterKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._clearFilter(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearFilter(ev) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
@@ -226,61 +224,58 @@ export class HaFilterDevices extends LitElement {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
this._listElement?.clearSelection();
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
:host([expanded]) {
|
||||
flex: 1;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
:host([expanded]) ha-expansion-panel {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
ha-list-selectable-virtualized {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`;
|
||||
ha-expansion-panel {
|
||||
--ha-card-border-radius: var(--ha-border-radius-square);
|
||||
--expansion-panel-content-padding: 0;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header ha-icon-button {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
margin-inline-start: 8px;
|
||||
margin-inline-end: 0;
|
||||
min-width: 16px;
|
||||
box-sizing: border-box;
|
||||
border-radius: var(--ha-border-radius-circle);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
background-color: var(--primary-color);
|
||||
line-height: var(--ha-line-height-normal);
|
||||
text-align: center;
|
||||
padding: 0px 2px;
|
||||
color: var(--text-primary-color);
|
||||
}
|
||||
ha-check-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
ha-input-search {
|
||||
display: block;
|
||||
padding: var(--ha-space-1) var(--ha-space-2) 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -23,6 +23,7 @@ import "./item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "./item/ha-list-item-option";
|
||||
import "./list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "./list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "./list/types";
|
||||
|
||||
@customElement("ha-filter-floor-areas")
|
||||
export class HaFilterFloorAreas extends LitElement {
|
||||
@@ -41,7 +42,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
|
||||
@state() private _shouldRender = false;
|
||||
|
||||
@query("ha-list-selectable") private _list?: HaListSelectable;
|
||||
@query("ha-list-selectable") private _list?: HTMLElement;
|
||||
|
||||
public willUpdate(properties: PropertyValues<this>) {
|
||||
super.willUpdate(properties);
|
||||
@@ -74,7 +75,6 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<ha-icon-button
|
||||
.path=${mdiFilterVariantRemove}
|
||||
@click=${this._clearFilter}
|
||||
@keydown=${this._handleClearFilterKeydown}
|
||||
></ha-icon-button>`
|
||||
: nothing}
|
||||
</div>
|
||||
@@ -83,8 +83,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
<ha-list-selectable
|
||||
class="ha-scrollbar"
|
||||
multi
|
||||
@ha-list-item-selected=${this._handleAdded}
|
||||
@ha-list-item-deselected=${this._handleRemoved}
|
||||
@ha-list-selected=${this._handleListChanged}
|
||||
aria-label=${this.hass.localize(
|
||||
"ui.panel.config.areas.caption"
|
||||
)}
|
||||
@@ -164,47 +163,46 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleAdded(ev: CustomEvent<number>) {
|
||||
if (!this.value) {
|
||||
this.value = {};
|
||||
}
|
||||
|
||||
const addedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
ev.detail
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
if (!addedItem) {
|
||||
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
|
||||
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[addedItem.type]: [
|
||||
...(this.value[addedItem.type] || []),
|
||||
addedItem.value,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (ev.detail.diff?.added.size) {
|
||||
const addedIndex = ev.detail.diff.added.values().next().value;
|
||||
if (addedIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const addedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
addedIndex
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
private _handleRemoved(ev: CustomEvent<number>) {
|
||||
if (!this.value) {
|
||||
return;
|
||||
if (!this.value) {
|
||||
this.value = {};
|
||||
}
|
||||
this.value = {
|
||||
...this.value,
|
||||
[addedItem.type]: [
|
||||
...(this.value[addedItem.type] || []),
|
||||
addedItem.value,
|
||||
],
|
||||
};
|
||||
} else {
|
||||
const removedIndex = ev.detail.diff?.removed.values().next().value;
|
||||
if (removedIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
const removedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
removedIndex
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[removedItem.type]: this.value![removedItem.type].filter(
|
||||
(val) => val !== removedItem.value
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const removedItem = (ev.currentTarget as HaListSelectable).items[
|
||||
ev.detail
|
||||
] as HaListItemOption & { type: string; value: string };
|
||||
|
||||
if (!removedItem) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.value = {
|
||||
...this.value,
|
||||
[removedItem.type]: this.value![removedItem.type].filter(
|
||||
(val) => val !== removedItem.value
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
protected updated(changed: PropertyValues<this>) {
|
||||
@@ -288,13 +286,6 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _handleClearFilterKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.stopPropagation();
|
||||
this._clearFilter(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _clearFilter(ev) {
|
||||
ev.preventDefault();
|
||||
this.value = undefined;
|
||||
@@ -302,7 +293,6 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
value: undefined,
|
||||
items: undefined,
|
||||
});
|
||||
this._list?.clearSelection();
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
|
||||
@@ -38,9 +38,6 @@ export class HaListItemBase extends HaRowItem {
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (!this.hasAttribute("ha-list-item")) {
|
||||
this.setAttribute("ha-list-item", "");
|
||||
}
|
||||
if (!this.hasAttribute("role")) {
|
||||
this.setAttribute("role", this.defaultRole);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import { compareNodeOrder } from "../../common/dom/compare-node-order";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { haStyleScrollbar } from "../../resources/styles";
|
||||
import type { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import "./types";
|
||||
import type { HaListItemRegistrationDetail } from "./types";
|
||||
@@ -45,13 +45,13 @@ export class HaListBase extends LitElement {
|
||||
/** Host `role` attribute. Empty string means no role is set. */
|
||||
protected readonly hostRole: string = "list";
|
||||
|
||||
protected activeItemIndex = -1;
|
||||
private _activeItemIndex = -1;
|
||||
|
||||
protected firstFocusableIndex = -1;
|
||||
private _firstFocusableIndex = -1;
|
||||
|
||||
protected lastFocusableIndex = -1;
|
||||
private _lastFocusableIndex = -1;
|
||||
|
||||
protected hasFocusableItem = false;
|
||||
private _hasFocusableItem = false;
|
||||
|
||||
private _unbindKeys?: () => void;
|
||||
|
||||
@@ -63,28 +63,22 @@ export class HaListBase extends LitElement {
|
||||
if (!this.hasAttribute("role") && this.hostRole) {
|
||||
this.setAttribute("role", this.hostRole);
|
||||
}
|
||||
this._unbindKeys = tinykeys(
|
||||
this,
|
||||
{
|
||||
ArrowDown: this._onForward,
|
||||
ArrowUp: this._onBack,
|
||||
Home: this._onHome,
|
||||
End: this._onEnd,
|
||||
PageDown: this._onPageDown,
|
||||
PageUp: this._onPageUp,
|
||||
Enter: this.onActivate,
|
||||
Space: this.onActivate,
|
||||
},
|
||||
{ ignore: this._ignoreKeyEvent }
|
||||
);
|
||||
this.addEventListener("focusin", this.onFocusIn);
|
||||
this._unbindKeys = tinykeys(this, {
|
||||
ArrowDown: this._onForward,
|
||||
ArrowUp: this._onBack,
|
||||
Home: this._onHome,
|
||||
End: this._onEnd,
|
||||
Enter: this._onActivate,
|
||||
Space: this._onActivate,
|
||||
});
|
||||
this.addEventListener("focusin", this._onFocusIn);
|
||||
this.addEventListener(
|
||||
"ha-list-item-register",
|
||||
this.onItemRegister as EventListener
|
||||
this._onItemRegister as EventListener
|
||||
);
|
||||
this.addEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this.onItemUnregister as EventListener
|
||||
this._onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,23 +86,25 @@ export class HaListBase extends LitElement {
|
||||
super.disconnectedCallback();
|
||||
this._unbindKeys?.();
|
||||
this._unbindKeys = undefined;
|
||||
this.removeEventListener("focusin", this.onFocusIn);
|
||||
this.removeEventListener("focusin", this._onFocusIn);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-register",
|
||||
this.onItemRegister as EventListener
|
||||
this._onItemRegister as EventListener
|
||||
);
|
||||
this.removeEventListener(
|
||||
"ha-list-item-unregister",
|
||||
this.onItemUnregister as EventListener
|
||||
this._onItemUnregister as EventListener
|
||||
);
|
||||
}
|
||||
|
||||
public focus(options?: FocusOptions) {
|
||||
if (!this.itemCount) {
|
||||
if (!this.items.length) {
|
||||
super.focus(options);
|
||||
return;
|
||||
}
|
||||
this.focusItemAtIndex(this.activeItemIndex >= 0 ? this.activeItemIndex : 0);
|
||||
this.focusItemAtIndex(
|
||||
this._activeItemIndex >= 0 ? this._activeItemIndex : 0
|
||||
);
|
||||
}
|
||||
|
||||
public focusItemAtIndex(index: number) {
|
||||
@@ -119,19 +115,19 @@ export class HaListBase extends LitElement {
|
||||
}
|
||||
|
||||
public getActiveItemIndex(): number {
|
||||
return this.activeItemIndex;
|
||||
return this._activeItemIndex;
|
||||
}
|
||||
|
||||
public setActiveItemIndex(index: number, focusItem = false) {
|
||||
if (!this.hasFocusableItem) {
|
||||
this.activeItemIndex = -1;
|
||||
if (!this._hasFocusableItem) {
|
||||
this._activeItemIndex = -1;
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = Math.max(0, Math.min(this.itemCount - 1, index));
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
this._activeItemIndex = Math.max(0, Math.min(this.items.length - 1, index));
|
||||
if (!this._isFocusable(this._activeItemIndex)) {
|
||||
this._activeItemIndex = this._firstFocusableIndex;
|
||||
}
|
||||
this.applyActive(focusItem);
|
||||
this._applyActive(focusItem);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,18 +135,18 @@ export class HaListBase extends LitElement {
|
||||
* to layer in extra bookkeeping (e.g. selection state sync).
|
||||
*/
|
||||
public updateListItems() {
|
||||
this.recomputeFocusableIndexes();
|
||||
this._recomputeFocusableIndexes();
|
||||
if (
|
||||
this.activeItemIndex >= this.itemCount ||
|
||||
!this.hasFocusableItem ||
|
||||
this.activeItemIndex < 0
|
||||
this._activeItemIndex >= this.items.length ||
|
||||
!this._hasFocusableItem ||
|
||||
this._activeItemIndex < 0
|
||||
) {
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
this._activeItemIndex = this._firstFocusableIndex;
|
||||
}
|
||||
this.applyActive(false);
|
||||
this._applyActive(false);
|
||||
}
|
||||
|
||||
protected onItemRegister = (
|
||||
private _onItemRegister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
@@ -164,7 +160,7 @@ export class HaListBase extends LitElement {
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
protected onItemUnregister = (
|
||||
private _onItemUnregister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
@@ -176,190 +172,136 @@ export class HaListBase extends LitElement {
|
||||
this.updateListItems();
|
||||
};
|
||||
|
||||
protected recomputeFocusableIndexes() {
|
||||
private _recomputeFocusableIndexes() {
|
||||
let first = -1;
|
||||
let last = -1;
|
||||
for (let i = 0; i < this.itemCount; i++) {
|
||||
if (this.isFocusable(i)) {
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (this._isFocusable(i)) {
|
||||
if (first === -1) {
|
||||
first = i;
|
||||
}
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
this.firstFocusableIndex = first;
|
||||
this.lastFocusableIndex = last;
|
||||
this.hasFocusableItem = first !== -1;
|
||||
this._firstFocusableIndex = first;
|
||||
this._lastFocusableIndex = last;
|
||||
this._hasFocusableItem = first !== -1;
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | typeof nothing {
|
||||
return html`<div part="base" class="base ha-scrollbar">
|
||||
protected render(): TemplateResult {
|
||||
return html`<div part="base" class="base">
|
||||
<slot></slot>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
protected isFocusable(index: number): boolean {
|
||||
private _isFocusable(index: number): boolean {
|
||||
const item = this.items[index];
|
||||
return !!item && item.interactive && !item.disabled;
|
||||
}
|
||||
|
||||
protected applyActive(focusItem: boolean) {
|
||||
private _applyActive(focusItem: boolean) {
|
||||
this.items.forEach((item, i) => {
|
||||
if (!item.interactive || item.disabled) {
|
||||
item.removeAttribute("tabindex");
|
||||
return;
|
||||
}
|
||||
item.tabIndex = i === this.activeItemIndex ? 0 : -1;
|
||||
item.tabIndex = i === this._activeItemIndex ? 0 : -1;
|
||||
});
|
||||
if (focusItem && this.activeItemIndex >= 0) {
|
||||
this.items[this.activeItemIndex]?.focus();
|
||||
if (focusItem && this._activeItemIndex >= 0) {
|
||||
this.items[this._activeItemIndex]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected onFocusIn = (ev: FocusEvent) => {
|
||||
private _onFocusIn = (ev: FocusEvent) => {
|
||||
const path = ev.composedPath();
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
if (path.includes(this.items[i])) {
|
||||
if (i !== this.activeItemIndex) {
|
||||
this.activeItemIndex = i;
|
||||
this.applyActive(false);
|
||||
if (i !== this._activeItemIndex) {
|
||||
this._activeItemIndex = i;
|
||||
this._applyActive(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private _ignoreKeyEvent = (ev: KeyboardEvent): boolean => {
|
||||
if (ev.repeat && (ev.key === "Enter" || ev.key === " ")) {
|
||||
return true;
|
||||
}
|
||||
if (ev.isComposing) {
|
||||
return true;
|
||||
}
|
||||
const target = ev.target as HTMLElement | null;
|
||||
// Allow held arrow/Home/End to repeat for continuous navigation
|
||||
return (
|
||||
!!target &&
|
||||
target !== ev.currentTarget &&
|
||||
target.matches("[contenteditable],input,select,textarea")
|
||||
);
|
||||
};
|
||||
|
||||
private _onForward = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, 1));
|
||||
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, 1));
|
||||
};
|
||||
|
||||
private _onBack = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, -1));
|
||||
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, -1));
|
||||
};
|
||||
|
||||
private _onHome = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(ev, this.firstFocusableIndex);
|
||||
this._moveFocus(ev, this._firstFocusableIndex);
|
||||
};
|
||||
|
||||
private _onEnd = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(ev, this.lastFocusableIndex);
|
||||
this._moveFocus(ev, this._lastFocusableIndex);
|
||||
};
|
||||
|
||||
private _onPageDown = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(
|
||||
ev,
|
||||
this._stepIndex(this.activeItemIndex, 1, this.getPageSize())
|
||||
);
|
||||
};
|
||||
|
||||
private _onPageUp = (ev: KeyboardEvent) => {
|
||||
this.moveFocus(
|
||||
ev,
|
||||
this._stepIndex(this.activeItemIndex, -1, this.getPageSize())
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Number of items to jump for PageUp/PageDown. Defaults to 10 (per WAI-ARIA
|
||||
* Authoring Practices: "moves focus a manageable number of nodes,
|
||||
* typically 10"). Subclasses with a known viewport (e.g. virtualized lists)
|
||||
* can override to use the visible page size.
|
||||
*/
|
||||
protected getPageSize(): number {
|
||||
return 10;
|
||||
}
|
||||
|
||||
protected onActivate = (ev: KeyboardEvent) => {
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
private _onActivate = (ev: KeyboardEvent) => {
|
||||
if (!this._isFocusable(this._activeItemIndex)) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
const active = this.items[this.activeItemIndex];
|
||||
const active = this.items[this._activeItemIndex];
|
||||
active.activate();
|
||||
fireEvent(this, "ha-list-activated", {
|
||||
index: this.activeItemIndex,
|
||||
index: this._activeItemIndex,
|
||||
item: active,
|
||||
});
|
||||
};
|
||||
|
||||
protected moveFocus(ev: KeyboardEvent, next: number) {
|
||||
if (!this.hasFocusableItem) {
|
||||
private _moveFocus(ev: KeyboardEvent, next: number) {
|
||||
if (!this._hasFocusableItem || next < 0 || next === this._activeItemIndex) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
if (next < 0 || next === this.activeItemIndex) {
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = next;
|
||||
this.applyActive(true);
|
||||
}
|
||||
|
||||
protected get itemCount(): number {
|
||||
return this.items.length;
|
||||
this._activeItemIndex = next;
|
||||
this._applyActive(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Step from `from` by `delta`, skipping non-interactive and disabled items.
|
||||
* Pass `count` > 1 to advance by multiple focusable items (PageUp/Down).
|
||||
* Returns the last focusable index reached, or `from` when none is.
|
||||
* Returns `from` when no other focusable item can be reached (honouring
|
||||
* `wrapFocus`).
|
||||
*/
|
||||
private _stepIndex(from: number, delta: 1 | -1, count = 1): number {
|
||||
const n = this.itemCount;
|
||||
if (!n || !this.hasFocusableItem) {
|
||||
private _stepIndex(from: number, delta: 1 | -1): number {
|
||||
const n = this.items.length;
|
||||
if (!n || !this._hasFocusableItem) {
|
||||
return from;
|
||||
}
|
||||
let last = from;
|
||||
let i = from;
|
||||
let landed = 0;
|
||||
for (let step = 0; step < n && landed < count; step++) {
|
||||
for (let step = 0; step < n; step++) {
|
||||
i += delta;
|
||||
if (i < 0 || i >= n) {
|
||||
if (!this.wrapFocus) {
|
||||
return last;
|
||||
return from;
|
||||
}
|
||||
i = (i + n) % n;
|
||||
}
|
||||
if (this.isFocusable(i)) {
|
||||
last = i;
|
||||
landed++;
|
||||
if (this._isFocusable(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
return from;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleScrollbar,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-list-gap, 0);
|
||||
padding: var(--ha-list-padding, 0);
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
`,
|
||||
];
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.base {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-list-gap, 0);
|
||||
padding: var(--ha-list-padding, 0);
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -30,7 +30,7 @@ export class HaListNav extends HaListBase {
|
||||
part="nav"
|
||||
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
|
||||
>
|
||||
<div part="base" class="base ha-scrollbar" role="list">
|
||||
<div part="base" class="base" role="list">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</nav>`;
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import { property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { Constructor } from "../../types";
|
||||
import { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import type { HaListBase } from "./ha-list-base";
|
||||
|
||||
export const SelectableMixin = <T extends Constructor<HaListBase>>(
|
||||
superClass: T
|
||||
) => {
|
||||
class SelectableClass extends superClass {
|
||||
@property({ type: Boolean, reflect: true }) public multi = false;
|
||||
|
||||
protected override readonly hostRole = "listbox";
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("click", this._onOptionClick);
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("click", this._onOptionClick);
|
||||
}
|
||||
|
||||
public updated(changed: Map<string, unknown>) {
|
||||
super.updated(changed);
|
||||
if (changed.has("multi")) {
|
||||
this.setAttribute(
|
||||
"aria-multiselectable",
|
||||
this.multi ? "true" : "false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook: index of a clicked option element, or `-1` if it's not ours. */
|
||||
protected optionIndexOf(opt: HaListItemOption): number {
|
||||
return this.items.indexOf(opt);
|
||||
}
|
||||
|
||||
public clearSelection() {
|
||||
(this.items as HaListItemOption[]).forEach((opt) => {
|
||||
if (opt.selected) {
|
||||
opt.toggleAttribute("selected", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _onOptionClick = (ev: Event) => {
|
||||
const path = ev.composedPath();
|
||||
for (const el of path) {
|
||||
if (el === this) {
|
||||
return;
|
||||
}
|
||||
if (el instanceof HaListItemOption) {
|
||||
if (el.disabled) {
|
||||
return;
|
||||
}
|
||||
const index = this.optionIndexOf(el);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.multi) {
|
||||
fireEvent(
|
||||
this,
|
||||
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
|
||||
index
|
||||
);
|
||||
el.toggleAttribute("selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!el.selected) {
|
||||
fireEvent(this, "ha-list-item-selected", index);
|
||||
// deselect the other optional selected item
|
||||
this.clearSelection();
|
||||
el.toggleAttribute("selected", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
return SelectableClass;
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import { SelectableMixin } from "./ha-list-selectable-mixin";
|
||||
import { HaListVirtualized } from "./ha-list-virtualized";
|
||||
|
||||
/**
|
||||
* @element ha-list-selectable-virtualized
|
||||
* @extends {HaListVirtualized}
|
||||
*
|
||||
* @summary
|
||||
* Virtualized selection list (role `listbox`). Rows must render
|
||||
* `<ha-list-item-option>` as their top-level element. Selection is driven by
|
||||
* the id-based `value` property; the component handles index/id translation
|
||||
* and fires `ha-list-value-changed` when the user changes the selection.
|
||||
*
|
||||
* Pass an externally-filtered subset of rows and the full `value`: ids that
|
||||
* aren't in `rows` are preserved untouched, so filtering the visible list
|
||||
* doesn't deselect items outside the current view.
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once.
|
||||
*
|
||||
* @fires ha-list-value-changed - Fires on user-driven selection changes.
|
||||
* `detail: { value, added, removed }` (all id-arrays).
|
||||
* @fires ha-list-item-selected - Lower-level index-based event from the base mixin.
|
||||
* @fires ha-list-item-deselected - Lower-level index-based event from the base mixin.
|
||||
*/
|
||||
@customElement("ha-list-selectable-virtualized")
|
||||
export class HaListSelectableVirtualized extends SelectableMixin(
|
||||
HaListVirtualized
|
||||
) {
|
||||
protected optionIndexOf(opt: HaListItemOption): number {
|
||||
if (!this.virtualizerElement || this.rangeStart === -1) {
|
||||
return -1;
|
||||
}
|
||||
const index = Array.from(this.virtualizerElement.children).indexOf(opt);
|
||||
if (index === -1) {
|
||||
return -1;
|
||||
}
|
||||
return this.rangeStart + index;
|
||||
}
|
||||
|
||||
public clearSelection() {
|
||||
if (!this.virtualizerElement || this.rangeStart === -1) {
|
||||
return;
|
||||
}
|
||||
Array.from(this.virtualizerElement.children).forEach((opt) => {
|
||||
if (opt instanceof HaListItemOption && opt.selected) {
|
||||
opt.toggleAttribute("selected", false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-selectable-virtualized": HaListSelectableVirtualized;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { customElement } from "lit/decorators";
|
||||
import type { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { HaListItemOption } from "../item/ha-list-item-option";
|
||||
import { HaListBase } from "./ha-list-base";
|
||||
import { SelectableMixin } from "./ha-list-selectable-mixin";
|
||||
import type { HaListSelectedDetail } from "./types";
|
||||
|
||||
/**
|
||||
* @element ha-list-selectable
|
||||
@@ -13,16 +14,195 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
|
||||
*
|
||||
* @attr {boolean} multi - Whether multiple options can be selected at once.
|
||||
*
|
||||
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
|
||||
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
|
||||
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
|
||||
*/
|
||||
@customElement("ha-list-selectable")
|
||||
export class HaListSelectable extends SelectableMixin(HaListBase) {
|
||||
export class HaListSelectable extends HaListBase {
|
||||
@property({ type: Boolean, reflect: true }) public multi = false;
|
||||
|
||||
protected override readonly hostRole = "listbox";
|
||||
|
||||
private _selectedIndices?: Set<number>;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("click", this._onOptionClick);
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
}
|
||||
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("click", this._onOptionClick);
|
||||
}
|
||||
|
||||
public updated(changed: Map<string, unknown>) {
|
||||
super.updated(changed);
|
||||
if (changed.has("multi")) {
|
||||
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
|
||||
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
|
||||
const first = Math.min(...this._selectedIndices!);
|
||||
this._setSelection(new Set([first]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current selection. `number` (or `-1` if nothing) when single,
|
||||
* `Set<number>` when multi.
|
||||
*/
|
||||
public get selected(): number | Set<number> {
|
||||
if (this.multi) {
|
||||
return new Set(this._selectedIndices);
|
||||
}
|
||||
return (this._selectedIndices?.size ?? 0) === 0
|
||||
? -1
|
||||
: this._selectedIndices!.values().next().value!;
|
||||
}
|
||||
|
||||
public get selectedItems(): HaListItemOption[] {
|
||||
return this.sortedSelectedIndices()
|
||||
return this._sortedSelectedIndices()
|
||||
.map((i) => this.items[i] as HaListItemOption | undefined)
|
||||
.filter((it): it is HaListItemOption => !!it);
|
||||
}
|
||||
|
||||
/** Replace the entire selection. */
|
||||
public setSelected(indices: number | number[] | Set<number>): void {
|
||||
const next =
|
||||
typeof indices === "number"
|
||||
? indices < 0
|
||||
? new Set<number>()
|
||||
: new Set([indices])
|
||||
: new Set(indices);
|
||||
if (!this.multi && next.size > 1) {
|
||||
const first = Math.min(...next);
|
||||
this._setSelection(new Set([first]));
|
||||
return;
|
||||
}
|
||||
this._setSelection(next);
|
||||
}
|
||||
|
||||
public select(index: number): void {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
const next = new Set(this._selectedIndices);
|
||||
next.add(index);
|
||||
this._setSelection(next);
|
||||
} else {
|
||||
this._setSelection(new Set([index]));
|
||||
}
|
||||
}
|
||||
|
||||
public toggle(index: number, force?: boolean): void {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
const next = new Set(this._selectedIndices);
|
||||
const isSelected = next.has(index);
|
||||
const shouldSelect = force !== undefined ? force : !isSelected;
|
||||
if (shouldSelect) {
|
||||
next.add(index);
|
||||
} else {
|
||||
next.delete(index);
|
||||
}
|
||||
this._setSelection(next);
|
||||
} else {
|
||||
const isSelected = this._selectedIndices!.has(index);
|
||||
const shouldSelect = force !== undefined ? force : !isSelected;
|
||||
this._setSelection(shouldSelect ? new Set([index]) : new Set());
|
||||
}
|
||||
}
|
||||
|
||||
public clearSelection(): void {
|
||||
this._setSelection(new Set());
|
||||
}
|
||||
|
||||
public updateListItems() {
|
||||
super.updateListItems();
|
||||
this._syncItemSelectedState(true);
|
||||
}
|
||||
|
||||
private _sortedSelectedIndices(): number[] {
|
||||
return [...this._selectedIndices!].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
private _syncItemSelectedState(reset = false): void {
|
||||
if (!this._selectedIndices || reset) {
|
||||
this._selectedIndices = new Set<number>();
|
||||
this.items.forEach((item, i) => {
|
||||
const opt = item as HaListItemOption;
|
||||
if (opt.selected) {
|
||||
this._selectedIndices!.add(i);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.items.forEach((item, i) => {
|
||||
const opt = item as HaListItemOption;
|
||||
const shouldBe = this._selectedIndices!.has(i);
|
||||
if (opt.selected !== shouldBe) {
|
||||
opt.selected = shouldBe;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _setSelection(next: Set<number>): void {
|
||||
const prev = this._selectedIndices!;
|
||||
const added = new Set<number>();
|
||||
const removed = new Set<number>();
|
||||
next.forEach((i) => {
|
||||
if (!prev.has(i)) {
|
||||
added.add(i);
|
||||
}
|
||||
});
|
||||
prev.forEach((i) => {
|
||||
if (!next.has(i)) {
|
||||
removed.add(i);
|
||||
}
|
||||
});
|
||||
if (!added.size && !removed.size) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._selectedIndices = next;
|
||||
this._syncItemSelectedState();
|
||||
|
||||
const detail: HaListSelectedDetail = this.multi
|
||||
? { index: new Set(next), diff: { added, removed } }
|
||||
: {
|
||||
index: next.size === 0 ? -1 : next.values().next().value!,
|
||||
diff: { added, removed },
|
||||
};
|
||||
fireEvent(this, "ha-list-selected", detail);
|
||||
}
|
||||
|
||||
private _onOptionClick = (ev: Event) => {
|
||||
const path = ev.composedPath();
|
||||
for (const el of path) {
|
||||
if (el === this) {
|
||||
return;
|
||||
}
|
||||
if (el instanceof HaListItemOption) {
|
||||
const index = this.items.indexOf(el);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
const item = this.items[index];
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
if (this.multi) {
|
||||
this.toggle(index);
|
||||
} else {
|
||||
this.select(index);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,333 +0,0 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize.js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
nothing,
|
||||
type PropertyValues,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
query,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||||
import { HaListItemBase } from "../item/ha-list-item-base";
|
||||
import { HaListBase } from "./ha-list-base";
|
||||
import type { HaListItemRegistrationDetail } from "./types";
|
||||
|
||||
export interface HaListVirtualizedItem {
|
||||
id: string;
|
||||
interactive?: boolean;
|
||||
disabled?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @element ha-list-virtualized
|
||||
* @extends {HaListBase}
|
||||
*
|
||||
* @summary
|
||||
* Virtualized list. Renders only the items currently in view to keep large
|
||||
* lists performant.
|
||||
*/
|
||||
@customElement("ha-list-virtualized")
|
||||
export class HaListVirtualized extends HaListBase {
|
||||
@state() private _virtualizerReady = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
public rows!: HaListVirtualizedItem[];
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<HaListVirtualizedItem>;
|
||||
|
||||
@property({ attribute: "pin-index", type: Number }) public pinIndex?: number;
|
||||
|
||||
@property({ attribute: "pin-block" }) public pinBlock:
|
||||
| "start"
|
||||
| "center"
|
||||
| "end"
|
||||
| "nearest" = "center";
|
||||
|
||||
@state() private _unpinned = false;
|
||||
|
||||
@query("lit-virtualizer")
|
||||
protected virtualizerElement?: LitVirtualizer<HaListVirtualizedItem>;
|
||||
|
||||
protected rangeStart = -1;
|
||||
protected rangeEnd = -1;
|
||||
private _activeItemFocus = false;
|
||||
private _scrollToActiveItem = false;
|
||||
|
||||
public willUpdate(changedProps: PropertyValues) {
|
||||
if (!this.hasUpdated) {
|
||||
this._loadVirtualizer();
|
||||
}
|
||||
|
||||
if (changedProps.has("rows")) {
|
||||
this.recomputeFocusableIndexes();
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadVirtualizer() {
|
||||
await loadVirtualizer();
|
||||
this._virtualizerReady = true;
|
||||
}
|
||||
|
||||
protected override render(): TemplateResult | typeof nothing {
|
||||
if (!this._virtualizerReady) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<div part="base" class="base ha-scrollbar">
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="-1"
|
||||
scroller
|
||||
.items=${this.rows}
|
||||
.renderItem=${this.rowRenderer}
|
||||
style="min-height: 36px; height: 100%;"
|
||||
.layout=${!this._unpinned && this.pinIndex !== undefined
|
||||
? {
|
||||
pin: {
|
||||
index: this.pinIndex,
|
||||
block: this.pinBlock,
|
||||
},
|
||||
}
|
||||
: undefined}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@rangeChanged=${this._handleRangeChanged}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
public setActiveItemIndex(index: number, focusItem = false) {
|
||||
if (!this.hasFocusableItem) {
|
||||
this.activeItemIndex = -1;
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = Math.max(0, Math.min(this.rows.length - 1, index));
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
this.activeItemIndex = this.firstFocusableIndex;
|
||||
}
|
||||
if (
|
||||
this.activeItemIndex >= this.rangeStart &&
|
||||
this.activeItemIndex <= this.rangeEnd
|
||||
) {
|
||||
this.applyActive(focusItem);
|
||||
} else {
|
||||
this._activeItemFocus = focusItem;
|
||||
this._scrollToActiveItem = true;
|
||||
this.virtualizerElement
|
||||
?.element(index)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
public override focusItemAtIndex(index: number) {
|
||||
if (!this._virtualizerReady || index < 0) {
|
||||
return;
|
||||
}
|
||||
this.setActiveItemIndex(index, true);
|
||||
}
|
||||
|
||||
protected override applyActive(focusItem: boolean) {
|
||||
if (this.virtualizerElement && this.rangeStart > -1) {
|
||||
Array.from(this.virtualizerElement.children).forEach((child, index) => {
|
||||
const el = child as HTMLElement;
|
||||
if (index + this.rangeStart === this.activeItemIndex) {
|
||||
el.tabIndex = 0;
|
||||
if (focusItem) {
|
||||
el.focus();
|
||||
}
|
||||
} else {
|
||||
el.removeAttribute("tabindex");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private async _handleRangeChanged(ev: { first: number; last: number }) {
|
||||
this.rangeStart = ev.first;
|
||||
this.rangeEnd = ev.last;
|
||||
this.onRangeChanged(ev.first, ev.last);
|
||||
|
||||
// rangeChanged fires before the virtualizer renders the new children,
|
||||
// so wait for layout to settle before reading/focusing them.
|
||||
await this.virtualizerElement?.layoutComplete;
|
||||
this._applySetSize();
|
||||
|
||||
if (!this.virtualizerElement) {
|
||||
return;
|
||||
}
|
||||
const inRange =
|
||||
this.activeItemIndex >= this.rangeStart &&
|
||||
this.activeItemIndex <= this.rangeEnd;
|
||||
const focus = this._scrollToActiveItem && inRange && this._activeItemFocus;
|
||||
// Always keep roving tabindex in sync with the rendered range so the
|
||||
// active item is the tab target — otherwise nothing in the list is
|
||||
// tabbable and focus falls through to the scroller container.
|
||||
this.applyActive(focus);
|
||||
if (this._scrollToActiveItem && inRange) {
|
||||
this._activeItemFocus = false;
|
||||
this._scrollToActiveItem = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose total count + position to assistive tech, since only a slice of
|
||||
// items is in the DOM at any time.
|
||||
private _applySetSize() {
|
||||
if (!this.virtualizerElement || this.rangeStart < 0) {
|
||||
return;
|
||||
}
|
||||
const total = this.rows?.length ?? 0;
|
||||
Array.from(this.virtualizerElement.children).forEach((child, index) => {
|
||||
const el = child as HTMLElement;
|
||||
el.setAttribute("aria-setsize", String(total));
|
||||
el.setAttribute("aria-posinset", String(this.rangeStart + index + 1));
|
||||
});
|
||||
}
|
||||
|
||||
/** Hook fired whenever the visible row range changes. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
protected onRangeChanged(_first: number, _last: number) {}
|
||||
|
||||
protected onFocusIn = (ev: FocusEvent) => {
|
||||
if (
|
||||
!this.virtualizerElement ||
|
||||
this.rangeStart === -1 ||
|
||||
this.rangeEnd === -1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const path = ev.composedPath();
|
||||
const children = Array.from(this.virtualizerElement.children);
|
||||
for (let i = this.rangeStart; i <= this.rangeEnd; i++) {
|
||||
if (path.includes(children[i - this.rangeStart])) {
|
||||
if (i !== this.activeItemIndex) {
|
||||
this.activeItemIndex = i;
|
||||
if (i < this.rangeStart || i > this.rangeEnd) {
|
||||
this._activeItemFocus = true;
|
||||
this._scrollToActiveItem = true;
|
||||
this.virtualizerElement
|
||||
?.element(this.activeItemIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
} else {
|
||||
this.applyActive(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
protected override onActivate = (ev: KeyboardEvent) => {
|
||||
if (!this.isFocusable(this.activeItemIndex)) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.virtualizerElement &&
|
||||
this.activeItemIndex >= this.rangeStart &&
|
||||
this.activeItemIndex <= this.rangeEnd
|
||||
) {
|
||||
const active = this.virtualizerElement?.children[
|
||||
this.activeItemIndex - this.rangeStart
|
||||
] as HaListItemBase | undefined;
|
||||
if (active && active instanceof HaListItemBase) {
|
||||
ev.preventDefault();
|
||||
active.activate();
|
||||
fireEvent(this, "ha-list-activated", {
|
||||
index: this.activeItemIndex,
|
||||
item: active,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
protected isFocusable(index: number): boolean {
|
||||
const item = this.rows[index];
|
||||
if (!item) {
|
||||
return false;
|
||||
}
|
||||
const { disabled = false, interactive = false } = this.rows[index];
|
||||
return interactive && !disabled;
|
||||
}
|
||||
|
||||
protected override get itemCount(): number {
|
||||
return this.rows?.length ?? 0;
|
||||
}
|
||||
|
||||
protected override moveFocus(ev: KeyboardEvent, next: number) {
|
||||
if (!this.hasFocusableItem) {
|
||||
return;
|
||||
}
|
||||
ev.preventDefault();
|
||||
if (next < 0 || next === this.activeItemIndex) {
|
||||
return;
|
||||
}
|
||||
this.activeItemIndex = next;
|
||||
if (next < this.rangeStart || next > this.rangeEnd) {
|
||||
this._activeItemFocus = true;
|
||||
this._scrollToActiveItem = true;
|
||||
this.virtualizerElement?.element(this.activeItemIndex)?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
} else {
|
||||
this.applyActive(true);
|
||||
}
|
||||
}
|
||||
|
||||
protected override getPageSize(): number {
|
||||
// Number of rendered (visible) rows in the current range. Fall back to
|
||||
// the base default when the range isn't known yet.
|
||||
if (this.rangeStart < 0 || this.rangeEnd < 0) {
|
||||
return super.getPageSize();
|
||||
}
|
||||
return Math.max(1, this.rangeEnd - this.rangeStart + 1);
|
||||
}
|
||||
|
||||
private _keyFunction = (item: HaListVirtualizedItem) => item.id;
|
||||
|
||||
@eventOptions({ passive: true })
|
||||
private _handleUnpinned() {
|
||||
this._unpinned = true;
|
||||
}
|
||||
|
||||
protected override onItemRegister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
protected override onItemUnregister = (
|
||||
ev: HASSDomEvent<HaListItemRegistrationDetail>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
// ignore
|
||||
};
|
||||
|
||||
static styles = [
|
||||
...HaListBase.styles,
|
||||
css`
|
||||
.base {
|
||||
height: 100%;
|
||||
}
|
||||
[ha-list-item] {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-list-virtualized": HaListVirtualized;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { HaListItemBase } from "../item/ha-list-item-base";
|
||||
|
||||
export interface HaListSelectedDetail {
|
||||
index: number | Set<number>;
|
||||
diff?: { added: Set<number>; removed: Set<number> };
|
||||
value?: string | string[];
|
||||
}
|
||||
|
||||
export interface HaListActivatedDetail {
|
||||
index: number;
|
||||
item: HaListItemBase;
|
||||
@@ -11,8 +17,7 @@ export interface HaListItemRegistrationDetail {
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"ha-list-item-selected": number;
|
||||
"ha-list-item-deselected": number;
|
||||
"ha-list-selected": HaListSelectedDetail;
|
||||
"ha-list-activated": HaListActivatedDetail;
|
||||
"ha-list-item-register": HaListItemRegistrationDetail;
|
||||
"ha-list-item-unregister": HaListItemRegistrationDetail;
|
||||
|
||||
@@ -185,19 +185,19 @@ 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">
|
||||
${this._currentAddon.logo
|
||||
? html`
|
||||
<img
|
||||
class="logo"
|
||||
alt=""
|
||||
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
|
||||
/>
|
||||
`
|
||||
: nothing}
|
||||
${getAppDisplayName(
|
||||
this._currentAddon.name,
|
||||
this._currentAddon.stage
|
||||
@@ -239,17 +239,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 +503,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 +540,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 +1501,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 +1530,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,7 +334,8 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
? this._renderTargets(
|
||||
target,
|
||||
actionHasTarget && !this._isNew,
|
||||
serviceTargetSpec
|
||||
serviceTargetSpec,
|
||||
type !== "device_id"
|
||||
)
|
||||
: nothing}
|
||||
${noteTooltipText
|
||||
@@ -722,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,7 +224,8 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
? this._renderTargets(
|
||||
target,
|
||||
descriptionHasTarget && !this._isNew,
|
||||
conditionTargetSpec
|
||||
conditionTargetSpec,
|
||||
this.condition.condition !== "device"
|
||||
)
|
||||
: nothing}
|
||||
${this.condition.note?.trim()
|
||||
@@ -574,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
|
||||
);
|
||||
|
||||
|
||||
@@ -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,7 +249,8 @@ export default class HaAutomationTriggerRow extends LitElement {
|
||||
? this._renderTargets(
|
||||
target,
|
||||
descriptionHasTarget && !this._isNew,
|
||||
triggerTargetSpec
|
||||
triggerTargetSpec,
|
||||
type !== "device"
|
||||
)
|
||||
: nothing}
|
||||
${type !== "list" &&
|
||||
@@ -558,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>`
|
||||
);
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export class CloudRegister extends LitElement {
|
||||
|
||||
try {
|
||||
await cloudRegister(this.hass, email, password);
|
||||
this._verificationEmailSent(email, "account_created");
|
||||
this._verificationEmailSent(email);
|
||||
} catch (err: any) {
|
||||
this._password = "";
|
||||
this._requestInProgress = false;
|
||||
@@ -238,18 +238,15 @@ export class CloudRegister extends LitElement {
|
||||
|
||||
const email = emailField.value || "";
|
||||
|
||||
this._requestInProgress = true;
|
||||
|
||||
const doResend = async (username: string) => {
|
||||
try {
|
||||
await cloudResendVerification(this.hass, username);
|
||||
this._verificationEmailSent(username, "verification_email_sent");
|
||||
this._verificationEmailSent(username);
|
||||
} catch (err: any) {
|
||||
const errCode = err && err.body && err.body.code;
|
||||
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
|
||||
await doResend(username.toLowerCase());
|
||||
} else {
|
||||
this._requestInProgress = false;
|
||||
this._error =
|
||||
err && err.body && err.body.message
|
||||
? err.body.message
|
||||
@@ -261,16 +258,13 @@ export class CloudRegister extends LitElement {
|
||||
await doResend(email);
|
||||
}
|
||||
|
||||
private _verificationEmailSent(
|
||||
email: string,
|
||||
messageKey: "account_created" | "verification_email_sent"
|
||||
) {
|
||||
private _verificationEmailSent(email: string) {
|
||||
this._requestInProgress = false;
|
||||
this._password = "";
|
||||
fireEvent(this, "cloud-email-changed", { value: email });
|
||||
fireEvent(this, "cloud-done", {
|
||||
flashMessage: this.hass.localize(
|
||||
`ui.panel.config.cloud.register.${messageKey}`
|
||||
"ui.panel.config.cloud.register.account_created"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import Fuse from "fuse.js";
|
||||
import type { HassConfig } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues, TemplateResult } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, query, state } from "lit/decorators";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
|
||||
@@ -16,18 +16,11 @@ import { navigate } from "../../../common/navigate";
|
||||
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-dialog";
|
||||
import "../../../components/ha-domain-icon";
|
||||
import "../../../components/ha-icon-button-prev";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-list";
|
||||
import "../../../components/ha-spinner";
|
||||
import "../../../components/input/ha-input-search";
|
||||
import type { HaInputSearch } from "../../../components/input/ha-input-search";
|
||||
import "../../../components/item/ha-list-item-button";
|
||||
import "../../../components/list/ha-list-virtualized";
|
||||
import type {
|
||||
HaListVirtualized,
|
||||
HaListVirtualizedItem,
|
||||
} from "../../../components/list/ha-list-virtualized";
|
||||
import { getConfigEntries } from "../../../data/config_entries";
|
||||
import {
|
||||
DISCOVERY_SOURCES,
|
||||
@@ -53,17 +46,16 @@ import {
|
||||
showAlertDialog,
|
||||
showConfirmationDialog,
|
||||
} from "../../../dialogs/generic/show-dialog-box";
|
||||
import { haStyleDialog } from "../../../resources/styles";
|
||||
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
|
||||
import { loadVirtualizer } from "../../../resources/virtualizer";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import "./ha-domain-integrations";
|
||||
import "./ha-integration-list-item";
|
||||
import type { HaIntegrationListItem } from "./ha-integration-list-item";
|
||||
import type { AddIntegrationDialogParams } from "./show-add-integration-dialog";
|
||||
import { showYamlIntegrationDialog } from "./show-add-integration-dialog";
|
||||
import { showSingleConfigEntryWarning } from "./show-single-config-entry-warning";
|
||||
|
||||
export interface IntegrationListItem extends HaListVirtualizedItem {
|
||||
export interface IntegrationListItem {
|
||||
name: string;
|
||||
domain: string;
|
||||
config_flow?: boolean;
|
||||
@@ -108,8 +100,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
@query("ha-list-virtualized") private _listElement?: HaListVirtualized;
|
||||
|
||||
private _width?: number;
|
||||
|
||||
private _height?: number;
|
||||
@@ -195,9 +185,8 @@ class AddIntegrationDialog extends LitElement {
|
||||
(!this._width || !this._height)
|
||||
) {
|
||||
// Store the width and height so that when we search, box doesn't jump
|
||||
const boundingRect = this.shadowRoot!.querySelector(
|
||||
"ha-list-virtualized"
|
||||
)?.getBoundingClientRect();
|
||||
const boundingRect =
|
||||
this.shadowRoot!.querySelector("ha-list")?.getBoundingClientRect();
|
||||
this._width = boundingRect?.width;
|
||||
this._height = boundingRect?.height;
|
||||
}
|
||||
@@ -217,8 +206,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
discoveredFlowsCount > 0
|
||||
? [
|
||||
{
|
||||
id: "_discovered",
|
||||
interactive: true,
|
||||
name: localize(
|
||||
"ui.panel.config.integrations.discovered_devices",
|
||||
{ count: discoveredFlowsCount }
|
||||
@@ -235,8 +222,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
(domain) => components.includes(domain)
|
||||
)
|
||||
.map((domain) => ({
|
||||
id: `device_${domain}`,
|
||||
interactive: true,
|
||||
name: localize(`ui.panel.config.integrations.add_${domain}_device`),
|
||||
domain,
|
||||
config_flow: true,
|
||||
@@ -277,8 +262,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
return;
|
||||
}
|
||||
integrations.push({
|
||||
id: domain,
|
||||
interactive: true,
|
||||
domain,
|
||||
name: integration.name || domainToName(localize, domain),
|
||||
config_flow: supportedIntegration.config_flow,
|
||||
@@ -295,8 +278,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
) {
|
||||
// Brand
|
||||
integrations.push({
|
||||
id: domain,
|
||||
interactive: true,
|
||||
domain,
|
||||
name: integration.name || domainToName(localize, domain),
|
||||
iot_standards: integration.iot_standards,
|
||||
@@ -314,8 +295,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
} else if (filter && "integration_type" in integration) {
|
||||
// Integration without a config flow
|
||||
yamlIntegrations.push({
|
||||
id: domain,
|
||||
interactive: true,
|
||||
domain,
|
||||
name: integration.name || domainToName(localize, domain),
|
||||
config_flow: integration.config_flow,
|
||||
@@ -341,8 +320,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
ignoreDiacritics: true,
|
||||
};
|
||||
const helpers = Object.entries(h).map(([domain, integration]) => ({
|
||||
id: domain,
|
||||
interactive: true,
|
||||
domain,
|
||||
name: integration.name || domainToName(localize, domain),
|
||||
config_flow: integration.config_flow,
|
||||
@@ -540,7 +517,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
}
|
||||
if (supportIntegration) {
|
||||
this._handleIntegrationPicked({
|
||||
id: integration.supported_by,
|
||||
domain: integration.supported_by,
|
||||
name:
|
||||
supportIntegration.name ||
|
||||
@@ -567,33 +543,45 @@ class AddIntegrationDialog extends LitElement {
|
||||
.placeholder=${this.hass.localize(
|
||||
"ui.panel.config.integrations.search_brand"
|
||||
)}
|
||||
@keydown=${this._maybeSubmit}
|
||||
@keypress=${this._maybeSubmit}
|
||||
></ha-input-search>
|
||||
${integrations
|
||||
? html`<ha-list-virtualized
|
||||
.rows=${integrations}
|
||||
.rowRenderer=${this._renderRow}
|
||||
style=${styleMap({
|
||||
width: `${this._width}px`,
|
||||
height: this._narrow
|
||||
? "calc(100vh - 184px - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))"
|
||||
: "500px",
|
||||
})}
|
||||
>
|
||||
</ha-list-virtualized>`
|
||||
? html`<ha-list ?autofocus=${this._narrow}>
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
tabindex="-1"
|
||||
class="ha-scrollbar"
|
||||
style=${styleMap({
|
||||
width: `${this._width}px`,
|
||||
height: this._narrow
|
||||
? "calc(100vh - 184px - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))"
|
||||
: "500px",
|
||||
})}
|
||||
@click=${this._integrationPicked}
|
||||
@keypress=${this._handleKeyPress}
|
||||
.items=${integrations}
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${this._renderRow}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>`
|
||||
: html`<div class="flex center">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>`}`;
|
||||
</div>`} `;
|
||||
}
|
||||
|
||||
private _keyFunction = (integration: IntegrationListItem) =>
|
||||
integration.domain;
|
||||
|
||||
private _renderRow = (integration: IntegrationListItem) => {
|
||||
if (!integration) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-integration-list-item
|
||||
@click=${this._integrationPicked}
|
||||
.hass=${this.hass}
|
||||
.integration=${integration}
|
||||
tabindex="0"
|
||||
>
|
||||
</ha-integration-list-item>
|
||||
`;
|
||||
@@ -659,13 +647,19 @@ class AddIntegrationDialog extends LitElement {
|
||||
this._filter = (ev.target as HaInputSearch).value ?? "";
|
||||
}
|
||||
|
||||
private _integrationPicked = (ev: Event) => {
|
||||
const listItem = ev.currentTarget as HaIntegrationListItem;
|
||||
if (!listItem?.integration) {
|
||||
private _integrationPicked(ev) {
|
||||
const listItem = ev.target.closest("ha-integration-list-item");
|
||||
if (!listItem) {
|
||||
return;
|
||||
}
|
||||
this._handleIntegrationPicked(listItem.integration);
|
||||
};
|
||||
}
|
||||
|
||||
private _handleKeyPress(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this._integrationPicked(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private async _handleIntegrationPicked(integration: IntegrationListItem) {
|
||||
if (integration.supported_by) {
|
||||
@@ -787,11 +781,6 @@ class AddIntegrationDialog extends LitElement {
|
||||
}
|
||||
|
||||
private _maybeSubmit(ev: KeyboardEvent) {
|
||||
if (ev.key === "ArrowDown" && this._listElement) {
|
||||
ev.preventDefault();
|
||||
this._listElement.focus();
|
||||
return;
|
||||
}
|
||||
if (ev.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
@@ -813,6 +802,7 @@ class AddIntegrationDialog extends LitElement {
|
||||
}
|
||||
|
||||
static styles = [
|
||||
haStyleScrollbar,
|
||||
haStyleDialog,
|
||||
css`
|
||||
ha-dialog {
|
||||
@@ -840,9 +830,15 @@ class AddIntegrationDialog extends LitElement {
|
||||
ha-spinner {
|
||||
margin: 24px 0;
|
||||
}
|
||||
ha-list-virtualized {
|
||||
ha-list {
|
||||
position: relative;
|
||||
}
|
||||
lit-virtualizer {
|
||||
contain: size layout !important;
|
||||
}
|
||||
ha-integration-list-item {
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,127 +1,196 @@
|
||||
import type { GraphicType } from "@material/mwc-list/mwc-list-item-base";
|
||||
import { ListItemBase } from "@material/mwc-list/mwc-list-item-base";
|
||||
import { styles } from "@material/mwc-list/mwc-list-item.css";
|
||||
import {
|
||||
mdiDevices,
|
||||
mdiFileCodeOutline,
|
||||
mdiPackageVariant,
|
||||
mdiWeb,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
|
||||
import type { LocalizeFunc } from "../../../common/translations/localize";
|
||||
import "../../../components/ha-domain-icon";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-tooltip";
|
||||
import { HaListItemButton } from "../../../components/item/ha-list-item-button";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { domainToName } from "../../../data/integration";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import { brandsUrl } from "../../../util/brands-url";
|
||||
import type { IntegrationListItem } from "./dialog-add-integration";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import "../../../components/ha-icon-next";
|
||||
import "../../../components/ha-tooltip";
|
||||
|
||||
@customElement("ha-integration-list-item")
|
||||
export class HaIntegrationListItem extends HaListItemButton {
|
||||
@property({ attribute: false }) public integration!: IntegrationListItem;
|
||||
export class HaIntegrationListItem extends ListItemBase {
|
||||
public hass!: HomeAssistant;
|
||||
|
||||
@state()
|
||||
@consumeLocalize()
|
||||
private _localize!: LocalizeFunc;
|
||||
@property({ attribute: false }) public integration?: IntegrationListItem;
|
||||
|
||||
protected override _renderInner(): TemplateResult {
|
||||
const integration = this.integration;
|
||||
const yamlOnly =
|
||||
!integration.config_flow &&
|
||||
!integration.integrations &&
|
||||
!integration.iot_standards;
|
||||
return html`
|
||||
<div part="start" class="start">
|
||||
${integration.is_discovered
|
||||
? html`<ha-svg-icon
|
||||
class="discovered-icon"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>`
|
||||
: html`<ha-domain-icon
|
||||
brand-fallback
|
||||
.domain=${integration.domain}
|
||||
></ha-domain-icon>`}
|
||||
</div>
|
||||
<div part="content" class="content">
|
||||
<div part="headline" class="headline">
|
||||
${integration.name ||
|
||||
domainToName(this._localize, integration.domain)}
|
||||
${integration.is_helper
|
||||
? // @ts-expect-error translation key not yet defined
|
||||
` (${this._localize("ui.panel.config.integrations.config_entry.helper")})`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
<div part="end" class="end">
|
||||
${integration.cloud
|
||||
? html`<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
|
||||
<ha-tooltip for="icon-cloud" placement="left">
|
||||
${this._localize(
|
||||
"ui.panel.config.integrations.config_entry.depends_on_cloud"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing}
|
||||
${!integration.is_built_in
|
||||
? html`<ha-svg-icon
|
||||
id="icon-custom"
|
||||
class=${integration.overwrites_built_in
|
||||
? "overwrites"
|
||||
: "custom"}
|
||||
.path=${mdiPackageVariant}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-custom" placement="left">
|
||||
${this._localize(
|
||||
integration.overwrites_built_in
|
||||
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
|
||||
: "ui.panel.config.integrations.config_entry.custom_integration"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing}
|
||||
${yamlOnly
|
||||
? html`<ha-svg-icon
|
||||
id="icon-yaml"
|
||||
.path=${mdiFileCodeOutline}
|
||||
class="open-in-new"
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-yaml" placement="left">
|
||||
${this._localize(
|
||||
"ui.panel.config.integrations.config_entry.yaml_only"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: html`<ha-icon-next></ha-icon-next>`}
|
||||
</div>
|
||||
`;
|
||||
@property({ type: String, reflect: true }) graphic: GraphicType = "medium";
|
||||
|
||||
// eslint-disable-next-line lit/attribute-names
|
||||
@property({ type: Boolean }) hasMeta = true;
|
||||
|
||||
// @ts-expect-error
|
||||
protected override renderSingleLine() {
|
||||
if (!this.integration) {
|
||||
return nothing;
|
||||
}
|
||||
return html`${this.integration.name ||
|
||||
domainToName(this.hass.localize, this.integration.domain)}
|
||||
${this.integration.is_helper ? " (helper)" : ""}`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = [
|
||||
HaListItemButton.styles,
|
||||
css`
|
||||
.start {
|
||||
--mdc-icon-size: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.end {
|
||||
color: var(--ha-color-text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
.discovered-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.open-in-new {
|
||||
--mdc-icon-size: 22px;
|
||||
padding: 1px;
|
||||
}
|
||||
.end .custom {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.end .overwrites {
|
||||
color: var(--error-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
// @ts-expect-error
|
||||
protected override renderGraphic() {
|
||||
if (!this.integration) {
|
||||
return nothing;
|
||||
}
|
||||
const graphicClasses = {
|
||||
multi: this.multipleGraphics,
|
||||
};
|
||||
|
||||
return html` <span
|
||||
class="mdc-deprecated-list-item__graphic material-icons ${classMap(
|
||||
graphicClasses
|
||||
)}"
|
||||
>
|
||||
${this.integration.is_discovered
|
||||
? html`<ha-svg-icon
|
||||
class="discovered-icon"
|
||||
.path=${mdiDevices}
|
||||
></ha-svg-icon>`
|
||||
: html`<img
|
||||
alt=""
|
||||
loading="lazy"
|
||||
src=${brandsUrl(
|
||||
{
|
||||
domain: this.integration.domain,
|
||||
type: "icon",
|
||||
darkOptimized: this.hass.themes?.darkMode,
|
||||
},
|
||||
this.hass.auth.data.hassUrl
|
||||
)}
|
||||
crossorigin="anonymous"
|
||||
referrerpolicy="no-referrer"
|
||||
/>`}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
// @ts-expect-error
|
||||
protected override renderMeta() {
|
||||
if (!this.integration) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<span class="mdc-deprecated-list-item__meta material-icons">
|
||||
${this.integration.cloud
|
||||
? html` <ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
|
||||
<ha-tooltip for="icon-cloud" placement="left"
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.depends_on_cloud"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: nothing}
|
||||
${!this.integration.is_built_in
|
||||
? html`<span
|
||||
class=${this.integration.overwrites_built_in
|
||||
? "overwrites"
|
||||
: "custom"}
|
||||
>
|
||||
<ha-svg-icon
|
||||
id="icon-custom"
|
||||
.path=${mdiPackageVariant}
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-custom" placement="left"
|
||||
>${this.hass.localize(
|
||||
this.integration.overwrites_built_in
|
||||
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
|
||||
: "ui.panel.config.integrations.config_entry.custom_integration"
|
||||
)}</ha-tooltip
|
||||
></span
|
||||
>`
|
||||
: nothing}
|
||||
${!this.integration.config_flow &&
|
||||
!this.integration.integrations &&
|
||||
!this.integration.iot_standards
|
||||
? html` <ha-svg-icon
|
||||
id="icon-yaml"
|
||||
.path=${mdiFileCodeOutline}
|
||||
class="open-in-new"
|
||||
></ha-svg-icon>
|
||||
<ha-tooltip for="icon-yaml" placement="left">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.integrations.config_entry.yaml_only"
|
||||
)}
|
||||
</ha-tooltip>`
|
||||
: html`<ha-icon-next></ha-icon-next>`}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
--mdc-list-side-padding: 24px;
|
||||
--mdc-list-item-graphic-size: 40px;
|
||||
}
|
||||
:host([graphic="avatar"]:not([twoLine])),
|
||||
:host([graphic="icon"]:not([twoLine])) {
|
||||
height: 48px;
|
||||
}
|
||||
span.material-icons:first-of-type {
|
||||
margin-inline-start: 0px !important;
|
||||
margin-inline-end: var(
|
||||
--mdc-list-item-graphic-margin,
|
||||
16px
|
||||
) !important;
|
||||
direction: var(--direction);
|
||||
}
|
||||
span.material-icons:last-of-type {
|
||||
margin-inline-start: auto !important;
|
||||
margin-inline-end: 0px !important;
|
||||
direction: var(--direction);
|
||||
}
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
.discovered-icon {
|
||||
--mdc-icon-size: 40px;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.mdc-deprecated-list-item__meta {
|
||||
width: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mdc-deprecated-list-item__meta > * {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.mdc-deprecated-list-item__meta > *:last-child {
|
||||
margin-right: 0px;
|
||||
margin-inline-end: 0px;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
ha-icon-next {
|
||||
margin-right: 8px;
|
||||
margin-inline-end: 8px;
|
||||
margin-inline-start: initial;
|
||||
}
|
||||
.open-in-new {
|
||||
--mdc-icon-size: 22px;
|
||||
padding: 1px;
|
||||
}
|
||||
.custom {
|
||||
color: var(--warning-color);
|
||||
}
|
||||
.overwrites {
|
||||
color: var(--error-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -8,12 +8,13 @@ import "../../../../../components/ha-button";
|
||||
import "../../../../../components/ha-dialog";
|
||||
import "../../../../../components/ha-dialog-footer";
|
||||
import "../../../../../components/ha-icon-button";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import "../../../../../components/input/ha-input-search";
|
||||
import "../../../../../components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
|
||||
import "../../../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "../../../../../components/list/types";
|
||||
import "../../../../../components/ha-spinner";
|
||||
import type { ZHADeviceEndpoint, ZHAGroup } from "../../../../../data/zha";
|
||||
import {
|
||||
addMembersToGroup,
|
||||
@@ -129,9 +130,7 @@ class DialogZHAAddGroupMembers
|
||||
? html`
|
||||
<ha-list-selectable
|
||||
multi
|
||||
@ha-list-item-selected=${this._handleSelected}
|
||||
@ha-list-item-deselected=${this
|
||||
._handleDeselected}
|
||||
@ha-list-selected=${this._handleSelected}
|
||||
>
|
||||
<lit-virtualizer
|
||||
scroller
|
||||
@@ -306,26 +305,26 @@ class DialogZHAAddGroupMembers
|
||||
this._filter = (ev.currentTarget as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
private _handleSelected(ev: CustomEvent<number>): void {
|
||||
private _handleSelected(ev: CustomEvent<HaListSelectedDetail>): void {
|
||||
const list = ev.currentTarget as HaListSelectable;
|
||||
let selectedDevicesToAdd = this._selectedDevicesToAdd;
|
||||
const item = list.items[ev.detail] as HaListItemOption | undefined;
|
||||
if (item?.value && !selectedDevicesToAdd.includes(item.value)) {
|
||||
selectedDevicesToAdd = [...selectedDevicesToAdd, item.value];
|
||||
}
|
||||
|
||||
this._selectedDevicesToAdd = selectedDevicesToAdd;
|
||||
}
|
||||
ev.detail.diff?.added.forEach((index) => {
|
||||
const item = list.items[index] as HaListItemOption | undefined;
|
||||
if (item?.value && !selectedDevicesToAdd.includes(item.value)) {
|
||||
selectedDevicesToAdd = [...selectedDevicesToAdd, item.value];
|
||||
}
|
||||
});
|
||||
|
||||
ev.detail.diff?.removed.forEach((index) => {
|
||||
const item = list.items[index] as HaListItemOption | undefined;
|
||||
if (item?.value) {
|
||||
selectedDevicesToAdd = selectedDevicesToAdd.filter(
|
||||
(selectedDeviceId) => selectedDeviceId !== item.value
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
private _handleDeselected(ev: CustomEvent<number>): void {
|
||||
const list = ev.currentTarget as HaListSelectable;
|
||||
let selectedDevicesToAdd = this._selectedDevicesToAdd;
|
||||
const item = list.items[ev.detail] as HaListItemOption | undefined;
|
||||
if (item?.value && selectedDevicesToAdd.includes(item.value)) {
|
||||
selectedDevicesToAdd = selectedDevicesToAdd.filter(
|
||||
(value) => value !== item.value
|
||||
);
|
||||
}
|
||||
this._selectedDevicesToAdd = selectedDevicesToAdd;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import "../../../../../components/item/ha-list-item-option";
|
||||
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
|
||||
import "../../../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
|
||||
import type { HaListSelectedDetail } from "../../../../../components/list/types";
|
||||
import {
|
||||
areasContext,
|
||||
internationalizationContext,
|
||||
@@ -102,8 +103,7 @@ export class ZHADeviceEndpointList extends LitElement {
|
||||
? html`
|
||||
<ha-list-selectable
|
||||
multi
|
||||
@ha-list-item-selected=${this._handleItemSelected}
|
||||
@ha-list-item-deselected=${this._handleItemDeselected}
|
||||
@ha-list-selected=${this._handleListSelectionChanged}
|
||||
>
|
||||
${repeat(
|
||||
deviceEndpoints,
|
||||
@@ -261,38 +261,36 @@ export class ZHADeviceEndpointList extends LitElement {
|
||||
this._filter = (ev.currentTarget as HTMLInputElement).value;
|
||||
}
|
||||
|
||||
private _handleItemSelected(ev: CustomEvent<number>): void {
|
||||
private _handleListSelectionChanged(
|
||||
ev: CustomEvent<HaListSelectedDetail>
|
||||
): void {
|
||||
const list = ev.currentTarget as HaListSelectable;
|
||||
let selectedDeviceIds = this._selectedDeviceIds;
|
||||
|
||||
const item = list.items[ev.detail] as HaListItemOption | undefined;
|
||||
if (item?.value) {
|
||||
selectedDeviceIds = this._setSelectedDeviceId(
|
||||
selectedDeviceIds,
|
||||
item.value,
|
||||
true
|
||||
);
|
||||
ev.detail.diff?.added.forEach((index) => {
|
||||
const item = list.items[index] as HaListItemOption | undefined;
|
||||
if (item?.value) {
|
||||
selectedDeviceIds = this._setSelectedDeviceId(
|
||||
selectedDeviceIds,
|
||||
item.value,
|
||||
true
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this._selectedDeviceIds = selectedDeviceIds;
|
||||
this._fireSelectionChanged();
|
||||
}
|
||||
}
|
||||
ev.detail.diff?.removed.forEach((index) => {
|
||||
const item = list.items[index] as HaListItemOption | undefined;
|
||||
if (item?.value) {
|
||||
selectedDeviceIds = this._setSelectedDeviceId(
|
||||
selectedDeviceIds,
|
||||
item.value,
|
||||
false
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
private _handleItemDeselected(ev: CustomEvent<number>): void {
|
||||
const list = ev.currentTarget as HaListSelectable;
|
||||
let selectedDeviceIds = this._selectedDeviceIds;
|
||||
|
||||
const item = list.items[ev.detail] as HaListItemOption | undefined;
|
||||
if (item?.value) {
|
||||
selectedDeviceIds = this._setSelectedDeviceId(
|
||||
selectedDeviceIds,
|
||||
item.value,
|
||||
false
|
||||
);
|
||||
|
||||
this._selectedDeviceIds = selectedDeviceIds;
|
||||
this._fireSelectionChanged();
|
||||
}
|
||||
this._selectedDeviceIds = selectedDeviceIds;
|
||||
this._fireSelectionChanged();
|
||||
}
|
||||
|
||||
private _setSelectedDeviceId(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -216,7 +216,7 @@ export class HuiEnergyDevicesDetailGraphCard
|
||||
compareStart: Date | undefined,
|
||||
compareEnd: Date | undefined,
|
||||
yAxisFractionDigits: number
|
||||
): ECOption => {
|
||||
): HaECOption => {
|
||||
const commonOptions = getCommonOptions(
|
||||
start,
|
||||
end,
|
||||
|
||||
@@ -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()})`
|
||||
)})`
|
||||
: ""
|
||||
}`,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
};
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -6319,8 +6319,7 @@
|
||||
"resend_confirm_email": "Resend confirmation email",
|
||||
"clicked_confirm": "I opened the confirmation link",
|
||||
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
|
||||
"account_created": "Account created! Check your email for instructions on how to activate your account.",
|
||||
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
|
||||
"account_created": "Account created! Check your email for instructions on how to activate your account."
|
||||
},
|
||||
"account": {
|
||||
"download_support_package": "Download support package",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||