Compare commits

..
1 Commits
Author SHA1 Message Date
Petar Petrov 04f744e90e Allow a few pixels of slack when counting sections view columns.
Subtracting wrapper padding made 1080px kiosks fall 8px short of the
3-column threshold, so span-2/3 sections stacked as a single column.
2026-08-13 13:38:20 +03:00
62 changed files with 582 additions and 2567 deletions
-35
View File
@@ -51,39 +51,4 @@ export const demoDevices: DeviceRegistryEntry[] = [
primary_config_entry: "mock-sonos",
entry_type: null,
},
{
...baseDevice,
id: "power-strip",
name: "Power strip",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
},
// Child devices (logical parts of the power strip). They carry the parent's
// inherited hardware fields, mirroring how resolveChildDevices fills them in
// from the WebSocket, and reference the parent via parent_device_id.
{
...baseDevice,
id: "power-strip-outlet-1",
name: "Outlet 1",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
{
...baseDevice,
id: "power-strip-outlet-2",
name: "Outlet 2",
manufacturer: "Acme",
model: "Smart Power Strip",
config_entries: ["mock-hue"],
primary_config_entry: "mock-hue",
entry_type: null,
parent_device_id: "power-strip",
},
];
@@ -152,84 +152,6 @@ const DEVICES: DeviceRegistryEntry[] = [
primary_config_entry: null,
parent_device_id: null,
},
{
area_id: "livingroom",
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_power_strip",
identifiers: [["demo", "strip1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Power strip",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: null,
},
// Child devices of the power strip. They have no area of their own and
// inherit the parent's area ("Livingroom"); the picker renders them indented
// under the parent with a tree connector.
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_1",
identifiers: [["demo", "outlet1"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 1",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
{
area_id: null,
configuration_url: null,
config_entries: ["config_entry_1"],
config_entries_subentries: {},
connections: [],
disabled_by: null,
entry_type: null,
id: "device_outlet_2",
identifiers: [["demo", "outlet2"] as [string, string]],
manufacturer: "Acme",
model: "Smart Power Strip",
model_id: null,
name_by_user: null,
name: "Outlet 2",
sw_version: null,
hw_version: null,
via_device_id: null,
serial_number: null,
labels: [],
created_at: 0,
modified_at: 0,
primary_config_entry: null,
parent_device_id: "device_power_strip",
},
];
const AREAS: DemoArea[] = [
+1 -79
View File
@@ -132,12 +132,12 @@ const ENTITIES = [
fan_modes: ["on_low", "on_high", "auto_low", "auto_high", "off"],
preset_modes: ["home", "eco", "away"],
swing_modes: ["auto", "1", "2", "3", "off"],
switch_horizontal_modes: ["auto", "4", "5", "6", "off"],
current_temperature: 23,
target_temp_high: 24,
target_temp_low: 21,
fan_mode: "auto_low",
preset_mode: "home",
swing_horizontal_modes: ["auto", "4", "5", "6", "off"],
swing_mode: "auto",
swing_horizontal_mode: "off",
supported_features:
@@ -340,84 +340,6 @@ const CONFIGS = [
features: [{ type: "fan-oscillate" }],
},
},
{
heading: "Inline features: one feature",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [{ type: "climate-hvac-modes", style: "dropdown" }],
},
},
{
heading: "Inline features: two features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: three features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: four features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
],
},
},
{
heading: "Inline features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "inline",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
{
heading: "Bottom features: five features",
config: {
type: "tile",
entity: "climate.dual_thermostat",
features_position: "bottom",
features: [
{ type: "climate-hvac-modes", style: "dropdown" },
{ type: "climate-preset-modes", style: "dropdown" },
{ type: "climate-fan-modes", style: "dropdown" },
{ type: "climate-swing-modes", style: "dropdown" },
{ type: "climate-swing-horizontal-modes", style: "dropdown" },
],
},
},
] satisfies DemoCardConfig<TileCardConfig>[];
@customElement("demo-lovelace-tile-card")
+1 -1
View File
@@ -210,7 +210,7 @@
"terser-webpack-plugin": "5.6.1",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"typescript-eslint": "8.66.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"webpack-stats-plugin": "1.1.3",
+38 -103
View File
@@ -3,13 +3,11 @@ import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import type { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceName } from "../../common/entity/compute_device_name";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import { computeRTL } from "../../common/util/compute_rtl";
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
import {
deviceComboBoxKeys,
@@ -28,9 +26,7 @@ import "../ha-alert";
import "../ha-button";
import "../ha-generic-picker";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import "../ha-svg-icon";
import "../ha-tree-indicator";
import { showDeviceReplacedDialog } from "./show-dialog-device-replaced";
export type HaDevicePickerDeviceFilterFunc = (
@@ -132,7 +128,6 @@ export class HaDevicePicker extends LitElement {
entityFilter,
excludeDevices,
value,
nested: true,
})
);
@@ -221,34 +216,6 @@ export class HaDevicePicker extends LitElement {
this.value
);
// The fuzzy search ranks matches by relevance, which would pull a child device
// above its parent (the parent often only matches through the lower-weighted
// child names). Restore the nested order from the full item list and recompute
// which child is last, so the tree connectors stay correct while searching.
private _searchFn: PickerComboBoxSearchFn<DevicePickerItem> = (
_search,
filteredItems,
allItems
) => {
const matchedIds = new Set(filteredItems.map((item) => item.id));
const ordered = allItems.filter((item) => matchedIds.has(item.id));
// Keep any items the search added that are not part of the nested list
// (for example the "no items available" placeholder or additional items).
const orderedIds = new Set(ordered.map((item) => item.id));
const extras = filteredItems.filter((item) => !orderedIds.has(item.id));
return [
...ordered.map((item, index) => {
if (!item.is_child) {
return item;
}
const nextItem = ordered[index + 1];
return { ...item, last: !nextItem || !nextItem.is_child };
}),
...extras,
];
};
private _valueRenderer = memoizeOne(
(
configEntriesLookup: Record<string, ConfigEntry>,
@@ -312,76 +279,46 @@ export class HaDevicePicker extends LitElement {
}
);
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => {
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`
<ha-combo-box-item
type="button"
style=${
item.is_child
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
item.is_child
? html`<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${item.last}
private _rowRenderer: RenderItemFunction<DevicePickerItem> = (item) => html`
<ha-combo-box-item type="button">
${
item.domain
? html`
<img
slot="start"
></ha-tree-indicator>`
: nothing
}
${
item.domain
? html`
<img
slot="start"
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
alt=""
crossorigin="anonymous"
referrerpolicy="no-referrer"
src=${brandsUrl(
{
domain: item.domain,
type: "icon",
darkOptimized: this.hass.themes.darkMode,
},
this.hass.auth.data.hassUrl
)}
/>
`
: nothing
}
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
};
<span slot="headline">${item.primary}</span>
${
item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing
}
${
item.domain_name
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
`
: nothing
}
</ha-combo-box-item>
`;
protected render() {
const placeholder =
@@ -438,8 +375,6 @@ export class HaDevicePicker extends LitElement {
.value=${this.value}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.searchFn=${this._searchFn}
no-sort
.hideClearIcon=${this.hideClearIcon}
.valueRenderer=${valueRenderer}
.searchKeys=${deviceComboBoxKeys}
+3 -11
View File
@@ -143,8 +143,7 @@ export class HaControlSelect extends LitElement {
? repeat(
this.options,
(option) => option.value,
(option, index) =>
this._renderOption(option, index === this._tabbableIndex)
(option) => this._renderOption(option)
)
: nothing
}
@@ -152,14 +151,7 @@ export class HaControlSelect extends LitElement {
`;
}
/* a radio group with no selection puts its first option in the tab sequence */
private get _tabbableIndex() {
const selectedIndex =
this.options?.findIndex((option) => option.value === this.value) ?? -1;
return selectedIndex === -1 ? 0 : selectedIndex;
}
private _renderOption(option: ControlSelectOption, tabbable: boolean) {
private _renderOption(option: ControlSelectOption) {
const isSelected = this.value === option.value;
return html`
@@ -170,7 +162,7 @@ export class HaControlSelect extends LitElement {
selected: isSelected,
})}
role="radio"
tabindex=${tabbable ? "0" : "-1"}
tabindex=${isSelected ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
@@ -1,25 +0,0 @@
import { DEFAULT_MIN_KELVIN } from "../../common/color/convert-light-color";
import type { Selector } from "../../data/selector";
/**
* Value a selector already displays when no field value is set.
* Used when enabling an optional service/trigger/condition field.
*/
export const getSelectorFallbackValue = (selector: Selector): unknown => {
if ("constant" in selector) {
return selector.constant?.value;
}
if ("boolean" in selector) {
return false;
}
if ("number" in selector) {
return selector.number?.min ?? 0;
}
if ("color_temp" in selector) {
if (selector.color_temp?.unit === "kelvin") {
return selector.color_temp.min ?? DEFAULT_MIN_KELVIN;
}
return selector.color_temp?.min ?? selector.color_temp?.min_mireds ?? 153;
}
return undefined;
};
+14 -3
View File
@@ -35,7 +35,6 @@ import {
} from "../data/selector";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { documentationUrl } from "../util/documentation-url";
import { getSelectorFallbackValue } from "./ha-form/get-selector-fallback-value";
import "./ha-checkbox";
import type { HaCheckbox } from "./ha-checkbox";
import "./ha-icon-button";
@@ -800,8 +799,20 @@ export class HaServiceControl extends LitElement {
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
+19 -70
View File
@@ -28,7 +28,6 @@ import {
type DevicePickerItem,
} from "../data/device/device_picker";
import {
devicesInEffectiveArea,
fetchDeviceCompositeSplits,
type DeviceCompositeSplits,
} from "../data/device/device_registry";
@@ -159,7 +158,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
excludeDevices,
value,
idPrefix,
nested: true,
})
);
@@ -684,10 +682,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "area") {
// Splitting an area yields its effective-area devices, so a child device
// that belongs to a different area is not pulled into this area.
devicesInEffectiveArea(this.hass.devices, itemId).forEach((device) => {
Object.values(this.hass.devices).forEach((device) => {
if (
device.area_id === itemId &&
!this.value!.device_id?.includes(device.id) &&
deviceMeetsFilter(
device,
@@ -719,18 +716,9 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
});
} else if (type === "device") {
// Splitting a device into entities includes its child devices' entities,
// since targeting the device would target its children too.
const deviceIds = new Set([
itemId,
...Object.values(this.hass.devices)
.filter((device) => device.parent_device_id === itemId)
.map((device) => device.id),
]);
Object.values(this.hass.entities).forEach((entity) => {
if (
entity.device_id &&
deviceIds.has(entity.device_id) &&
entity.device_id === itemId &&
!this.value!.entity_id?.includes(entity.entity_id) &&
entityRegMeetsFilter(
entity,
@@ -1013,28 +1001,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
if (!filterType || filterType === "device") {
const selectedDeviceIds = targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined;
// A selected parent device already targets its children, so exclude
// those children from the picker too (mirrors selecting a floor
// removing its areas from the list).
const excludeDeviceIds = selectedDeviceIds
? [
...selectedDeviceIds,
...Object.values(this.hass.devices)
.filter(
(device) =>
device.parent_device_id !== null &&
selectedDeviceIds.includes(device.parent_device_id)
)
.map((device) => device.id),
]
: undefined;
let deviceItems = this._getDevicesMemoized(
this.hass,
configEntryLookup,
@@ -1042,41 +1008,26 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
includeDeviceClasses,
deviceFilter,
entityFilter,
excludeDeviceIds,
targetValue?.device_id
? replacingDeviceId
? ensureArray(targetValue.device_id).filter(
(deviceId) => deviceId !== replacingDeviceId
)
: ensureArray(targetValue.device_id)
: undefined,
replacingDeviceId,
`device${SEPARATOR}`
);
// getDevices already returns child devices nested under their parent
// with the top-level devices sorted; keep that order rather than
// re-sorting by label, which would separate children from their parent.
).sort(this._sortBySortingLabel);
if (searchTerm) {
// Keep the nested parent-then-children order (sort=false), matching
// the areas group; the default sorted search would reorder matches by
// relevance and pull children above their parent.
deviceItems = this._filterGroup(
"device",
deviceItems,
searchTerm,
deviceComboBoxKeys,
false
deviceComboBoxKeys
);
}
// Recompute the tree "last child" flag over the (possibly filtered)
// list so the last visible child of each parent draws its end connector.
deviceItems = deviceItems.map((item, index) => {
if (!(item as DevicePickerItem).is_child) {
return item;
}
const nextItem = deviceItems[index + 1] as
DevicePickerItem | undefined;
return {
...item,
last: !nextItem || !nextItem.is_child,
};
});
if (!filterType && deviceItems.length) {
// show group title
items.push(localize("ui.components.target-picker.type.devices"));
@@ -1294,9 +1245,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
let hasFloor = false;
let rtl = false;
let showEntityId = false;
const isChildDeviceRow =
type === "device" && !!(item as DevicePickerItem).is_child;
if (type === "area" || type === "floor" || isChildDeviceRow) {
if (type === "area" || type === "floor") {
rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
@@ -1316,27 +1265,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
.type=${type === "empty" ? "text" : "button"}
class=${type === "empty" ? "empty" : ""}
style=${
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
(item as FloorComboBoxItem).type === "area" && hasFloor
? "--md-list-item-leading-space: var(--ha-space-12);"
: ""
}
>
${
((item as FloorComboBoxItem).type === "area" && hasFloor) ||
isChildDeviceRow
(item as FloorComboBoxItem).type === "area" && hasFloor
? html`
<ha-tree-indicator
style=${styleMap({
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${(item as { last?: boolean }).last}
.end=${
(item as FloorComboBoxItem & { last?: boolean | undefined })
.last
}
slot="start"
></ha-tree-indicator>
`
+3 -24
View File
@@ -8,31 +8,10 @@ export class HaTreeIndicator extends LitElement {
public end?: boolean = false;
protected render(): TemplateResult {
// preserveAspectRatio="none" lets the connector stretch to the host box, so
// it can span the full height of a taller row instead of being letterboxed
// to a square in the middle. non-scaling-stroke keeps the line width and
// dash pattern identical no matter how far it is stretched.
return html`
<svg
width="100%"
height="100%"
viewBox="0 0 48 48"
preserveAspectRatio="none"
>
<line
x1="24"
y1="0"
x2="24"
y2=${this.end ? "24" : "48"}
vector-effect="non-scaling-stroke"
></line>
<line
x1="24"
y1="24"
x2="36"
y2="24"
vector-effect="non-scaling-stroke"
></line>
<svg width="100%" height="100%" viewBox="0 0 48 48">
<line x1="24" y1="0" x2="24" y2=${this.end ? "24" : "48"}></line>
<line x1="24" y1="24" x2="36" y2="24"></line>
</svg>
`;
}
@@ -393,9 +393,6 @@ export class HaTargetPickerItemRow extends LitElement {
nextEntries.referenced_entities =
entries?.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.area_id === rowItem ||
!entity.device_id ||
@@ -419,9 +416,6 @@ export class HaTargetPickerItemRow extends LitElement {
this.type === "label" && entries
? entries.referenced_entities.filter((entity_id) => {
const entity = this.hass.entities[entity_id];
if (!entity) {
return false;
}
return (
entity.labels.includes(this.itemId) &&
!entries.referenced_devices.includes(entity.device_id || "")
@@ -430,7 +424,7 @@ export class HaTargetPickerItemRow extends LitElement {
: nextType === "device" && entries
? entries.referenced_entities.filter(
(entity_id) =>
this.hass.entities[entity_id]?.area_id === this.itemId
this.hass.entities[entity_id].area_id === this.itemId
)
: [];
@@ -439,7 +433,7 @@ export class HaTargetPickerItemRow extends LitElement {
? entries.referenced_devices.filter(
(device_id) =>
!devicesInAreas.includes(device_id) &&
this.hass.devices[device_id]?.labels.includes(this.itemId)
this.hass.devices[device_id].labels.includes(this.itemId)
)
: [];
@@ -534,12 +528,6 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_areas = entries.referenced_areas.filter(
(area_id) => {
const area = this.hass.areas[area_id];
// Absent from the registry is not a filter decision: drop the id
// without marking it hidden, so entities targeted through their
// own area or label are not dropped along with it.
if (!area) {
return false;
}
if (
(this.type === "floor" || area.labels.includes(this.itemId)) &&
areaMeetsFilter(
@@ -572,9 +560,6 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_devices = entries.referenced_devices.filter(
(device_id) => {
const device = this.hass.devices[device_id];
if (!device) {
return false;
}
if (
!hiddenAreaIds.includes(device.area_id || "") &&
deviceMeetsFilter(
@@ -600,11 +585,6 @@ export class HaTargetPickerItemRow extends LitElement {
entries.referenced_entities = entries.referenced_entities.filter(
(entity_id) => {
const entity = this.hass.entities[entity_id];
// Core can reference entities that are absent from the display
// registry (e.g. disabled ones expanded from an area).
if (!entity) {
return false;
}
if (hiddenDeviceIds.includes(entity.device_id || "")) {
return false;
}
+11 -51
View File
@@ -1,5 +1,5 @@
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -25,13 +25,6 @@ export class HaTileContainer extends LitElement {
@property({ attribute: false })
public actionHandlerOptions?: ActionHandlerOptions;
@state() private _hasFeatures = false;
private _handleFeaturesSlotChange(ev: Event) {
this._hasFeatures =
(ev.target as HTMLSlotElement).assignedElements().length > 0;
}
private _handleFocus(ev: FocusEvent) {
if ((ev.target as HTMLElement).matches(":focus-visible")) {
this.setAttribute("focused", "");
@@ -43,12 +36,8 @@ export class HaTileContainer extends LitElement {
}
protected render() {
const isInline = this.featurePosition === "inline";
const containerClasses = {
inline: isInline,
"has-features-below": isInline && this._hasFeatures,
"fixed-height": this.fixedInfoHeight,
};
const containerOrientationClass =
this.featurePosition === "inline" ? "horizontal" : "";
const contentClasses = {
vertical: this.vertical,
"fixed-info-height": this.fixedInfoHeight,
@@ -67,21 +56,15 @@ export class HaTileContainer extends LitElement {
<ha-ripple .disabled=${!this.interactive}></ha-ripple>
</div>
<div
class="container ${classMap(containerClasses)}"
class="container ${containerOrientationClass}"
@action=${stopPropagation}
@click=${stopPropagation}
>
<div class="row">
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot name="features-inline"></slot>
<div class="content ${classMap(contentClasses)}">
<slot name="icon"></slot>
<slot name="info" id="info"></slot>
</div>
<slot
name="features"
@slotchange=${this._handleFeaturesSlotChange}
></slot>
<slot name="features"></slot>
</div>
`;
}
@@ -114,13 +97,7 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
flex: 1;
}
.row {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.container.inline .row {
.container.horizontal {
flex-direction: row;
}
@@ -176,30 +153,13 @@ export class HaTileContainer extends LitElement {
padding: 0 var(--ha-space-3) var(--ha-space-3) var(--ha-space-3);
}
.container.inline ::slotted([slot="features-inline"]) {
/* size the feature on the 6 column grid track, so it lines up with neighbouring tiles */
.container.horizontal ::slotted([slot="features"]) {
width: calc(50% - var(--column-gap, 0px) / 2 - var(--ha-space-3));
flex: none;
--feature-height: var(--ha-space-9);
padding: 0 var(--ha-space-3);
padding-inline-start: 0;
}
/* the inline feature keeps the icon height, unless the card reserves a row it can fill */
.container.inline:not(.has-features-below)
::slotted([slot="features-inline"]),
.container.inline:not(.fixed-height) ::slotted([slot="features-inline"]) {
--feature-height: var(--ha-space-9);
}
.container.inline.has-features-below ::slotted([slot="features"]) {
/* keep both columns under the inline feature, which sits on the grid track */
--ha-card-feature-column-gap: calc(
var(--column-gap, 0px) + var(--ha-space-3) * 2
);
--ha-card-feature-divider: 1px solid var(--ha-color-border-neutral-quiet);
--ha-card-feature-divider-inset: calc(
var(--ha-space-3) + var(--column-gap, 0px) / 2
);
}
[role="button"] {
cursor: pointer;
pointer-events: auto;
+20 -152
View File
@@ -3,7 +3,6 @@ import { computeDeviceNameDisplay } from "../../common/entity/compute_device_nam
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
@@ -25,17 +24,12 @@ import {
export interface DevicePickerItem extends PickerComboBoxItem {
domain?: string;
domain_name?: string;
// Set when this device is a child rendered indented under its parent.
is_child?: boolean;
// Set on the last child of a parent so the tree connector draws its end.
last?: boolean;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
parentDeviceName?: string;
}
export interface GetDevicesOptions {
@@ -47,10 +41,6 @@ export interface GetDevicesOptions {
excludeDevices?: string[];
value?: string;
idPrefix?: string;
// When set, order the result so children directly follow their parent and
// flag them for indented rendering. Requires the picker to disable its own
// sorting (no-sort) so this order is preserved.
nested?: boolean;
}
export const computeDeviceAreaLabel = (
@@ -79,16 +69,6 @@ export const computeDeviceAreaLabel = (
? computeAreaName(viaDeviceArea)
: undefined;
// A child device is a logical part of its parent. We surface the parent name
// only as a search term (below) — not in the area label, which stays the pure
// (inherited) area. The nested tree rendering communicates the relationship.
const parentDevice = device.parent_device_id
? devices[device.parent_device_id]
: undefined;
const parentDeviceName = parentDevice
? computeDeviceNameDisplay(parentDevice, localize, states)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
@@ -97,7 +77,7 @@ export const computeDeviceAreaLabel = (
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName };
return { areaName, viaDeviceName, viaDeviceAreaName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
@@ -125,14 +105,6 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.viaDeviceArea",
weight: 3,
},
{
name: "search_labels.parentDeviceName",
weight: 3,
},
{
name: "search_labels.childDeviceNames",
weight: 3,
},
];
export const getDevices = (
@@ -149,7 +121,6 @@ export const getDevices = (
excludeDevices,
value,
idPrefix = "",
nested,
} = options ?? {};
const devices = Object.values(hass.devices);
@@ -157,60 +128,26 @@ export const getDevices = (
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
const filtersEntities =
includeDomains || excludeDomains || includeDeviceClasses || entityFilter;
if (filtersEntities) {
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
}
// Targeting a device also targets its child devices (a parent inherits its
// children's entities), so a device should match an entity-based filter when
// it OR any of its children has a matching entity. Build a parent -> children
// map and resolve each device's effective entity set accordingly. Nesting is
// single-level, so one hop covers it.
const filterChildrenByParent = new Map<string, DeviceRegistryEntry[]>();
if (filtersEntities) {
for (const device of devices) {
if (device.parent_device_id) {
const siblings = filterChildrenByParent.get(device.parent_device_id);
if (siblings) {
siblings.push(device);
} else {
filterChildrenByParent.set(device.parent_device_id, [device]);
}
}
}
}
const effectiveEntities = (
deviceId: string
): EntityRegistryDisplayEntry[] => {
const own = deviceEntityLookup[deviceId] ?? [];
const children = filterChildrenByParent.get(deviceId);
if (!children) {
return own;
}
const combined = [...own];
for (const child of children) {
const childEntities = deviceEntityLookup[child.id];
if (childEntities) {
combined.push(...childEntities);
}
}
return combined;
};
let inputDevices = devices.filter(
(device) => device.id === value || !device.disabled_by
);
if (includeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) =>
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -218,11 +155,11 @@ export const getDevices = (
if (excludeDomains) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return devEntities.every(
return entities.every(
(entity) => !excludeDomains.includes(computeDomain(entity.entity_id))
);
});
@@ -236,11 +173,11 @@ export const getDevices = (
if (includeDeviceClasses) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = hass.states[entity.entity_id];
if (!stateObj) {
return false;
@@ -255,8 +192,8 @@ export const getDevices = (
if (entityFilter) {
inputDevices = inputDevices.filter((device) => {
const devEntities = effectiveEntities(device.id);
if (!devEntities.length) {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return devEntities.some((entity) => {
@@ -285,7 +222,7 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName, parentDeviceName } =
const { areaName, viaDeviceName, viaDeviceAreaName } =
computeDeviceAreaLabel(
device,
hass.areas,
@@ -322,79 +259,10 @@ export const getDevices = (
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
parentDeviceName: parentDeviceName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
});
if (!nested) {
return outputDevices;
}
// Order children directly after their parent and flag them for indented
// rendering. outputDevices is 1:1 with inputDevices, so we can pair them up.
const itemByDeviceId = new Map<string, DevicePickerItem>();
inputDevices.forEach((device, index) => {
itemByDeviceId.set(device.id, outputDevices[index]);
});
const presentIds = new Set(inputDevices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of inputDevices) {
const parentId = device.parent_device_id;
// A child whose parent was filtered out is shown as a top-level row.
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const compareByName = (a: DeviceRegistryEntry, b: DeviceRegistryEntry) =>
caseInsensitiveStringCompare(
itemByDeviceId.get(a.id)!.primary,
itemByDeviceId.get(b.id)!.primary,
hass.locale.language
);
topLevel.sort(compareByName);
const ordered: DevicePickerItem[] = [];
for (const device of topLevel) {
const parentItem = itemByDeviceId.get(device.id)!;
const children = childrenByParent.get(device.id);
if (children) {
children.sort(compareByName);
// Add the children's names to the parent's search terms so a search that
// matches a child keeps the parent visible (mirrors how a floor stays
// visible when one of its areas matches).
ordered.push({
...parentItem,
search_labels: {
...parentItem.search_labels,
childDeviceNames: children
.map((child) => itemByDeviceId.get(child.id)!.primary)
.join(" "),
},
});
children.forEach((child, index) => {
ordered.push({
...itemByDeviceId.get(child.id)!,
is_child: true,
last: index === children.length - 1,
});
});
} else {
ordered.push(parentItem);
}
}
return ordered;
return outputDevices;
};
-69
View File
@@ -81,75 +81,6 @@ export type DeviceRegistryListEntry =
export const isChildDevice = (device: DeviceRegistryEntry): boolean =>
device.parent_device_id !== null;
/**
* Devices whose effective area is the given area: devices with that area, and
* child devices that inherit it because they have no area of their own. Mirrors
* core's dr.async_entries_for_area, so a child device with a different explicit
* area is not part of its parent's area.
*/
export const devicesInEffectiveArea = (
devices: Record<string, DeviceRegistryEntry>,
areaId: string
): DeviceRegistryEntry[] =>
Object.values(devices).filter((device) => {
if (device.area_id) {
return device.area_id === areaId;
}
if (device.parent_device_id) {
return devices[device.parent_device_id]?.area_id === areaId;
}
return false;
});
export interface DeviceRowItem {
device: DeviceRegistryEntry;
isChild: boolean;
// True for the last child of a parent, so the tree connector draws its end.
isLastChild: boolean;
}
/**
* Order a flat device list so each child directly follows its parent, flagging
* children for indented rendering. The incoming order of the top-level devices
* (and of the children within each parent) is preserved. A child whose parent
* is not in the list is treated as a top-level device.
*/
export const groupDevicesByParent = (
devices: DeviceRegistryEntry[]
): DeviceRowItem[] => {
const presentIds = new Set(devices.map((device) => device.id));
const childrenByParent = new Map<string, DeviceRegistryEntry[]>();
const topLevel: DeviceRegistryEntry[] = [];
for (const device of devices) {
const parentId = device.parent_device_id;
if (parentId && presentIds.has(parentId)) {
const siblings = childrenByParent.get(parentId);
if (siblings) {
siblings.push(device);
} else {
childrenByParent.set(parentId, [device]);
}
} else {
topLevel.push(device);
}
}
const result: DeviceRowItem[] = [];
for (const device of topLevel) {
result.push({ device, isChild: false, isLastChild: false });
const children = childrenByParent.get(device.id) ?? [];
children.forEach((child, index) => {
result.push({
device: child,
isChild: true,
isLastChild: index === children.length - 1,
});
});
}
return result;
};
export type DeviceEntityDisplayLookup = Record<
string,
EntityRegistryDisplayEntry[]
-55
View File
@@ -22,7 +22,6 @@ import {
import type { DateRange } from "../common/datetime/calc_date_range";
import { calcDateRange } from "../common/datetime/calc_date_range";
import { formatTime24h } from "../common/datetime/format_time";
import { DEFAULT_ENTITY_NAME } from "../common/entity/compute_entity_name_display";
import { formatNumber } from "../common/number/format_number";
import { normalizeValueBySIPrefix } from "../common/number/normalize-by-si-prefix";
import { groupBy } from "../common/util/group-by";
@@ -37,7 +36,6 @@ import type {
import {
fetchStatistics,
getDisplayUnit,
getStatisticLabel,
getStatisticMetadata,
VOLUME_UNITS,
} from "./recorder";
@@ -313,59 +311,6 @@ export interface EnergySourceByType {
export const energySourcesByType = (prefs: EnergyPreferences) =>
groupBy(prefs.energy_sources, (item) => item.type) as EnergySourceByType;
/**
* Display name of a configured statistic. A name set by the user always wins;
* otherwise the entity is named the same way the rest of the UI names
* entities, so devices sharing an entity name stay distinguishable.
* Statistics without an entity (external or removed) keep the statistic label.
*/
export const computeEnergyLabel = (
hass: HomeAssistant,
statisticId: string,
statisticsMetaData?: StatisticsMetaData,
customName?: string
): string => {
if (customName) {
return customName;
}
const stateObj = hass.states[statisticId];
if (stateObj) {
return hass.formatEntityName(stateObj, DEFAULT_ENTITY_NAME);
}
return getStatisticLabel(hass, statisticId, statisticsMetaData);
};
/**
* Device labels keyed by statistic id. Cards that show live power or flow
* key their nodes by `stat_rate` instead of `stat_consumption`; devices
* without the requested statistic are left out.
*/
export const computeEnergyDeviceLabels = (
hass: HomeAssistant,
devices: DeviceConsumptionEnergyPreference[],
statsMetadata?: Record<string, StatisticsMetaData>,
statisticKey: "stat_consumption" | "stat_rate" = "stat_consumption"
): Record<string, string> => {
const labels: Record<string, string> = {};
for (const device of devices) {
const statisticId = device[statisticKey];
if (statisticId) {
labels[statisticId] = computeEnergyLabel(
hass,
statisticId,
statsMetadata?.[statisticId],
device.name
);
}
}
return labels;
};
export interface EnergyData {
start: Date;
end?: Date;
+2 -5
View File
@@ -2,15 +2,12 @@ import type { Connection } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../../../types";
import type { LovelaceResource } from "../resource";
import type { LovelaceStrategyConfig } from "./strategy";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewRawConfig,
} from "./view";
import type { LovelaceViewRawConfig } from "./view";
export interface LovelaceDashboardBaseConfig {}
export interface LovelaceConfig extends LovelaceDashboardBaseConfig {
background?: LovelaceDashboardBackgroundConfig;
background?: string;
views: LovelaceViewRawConfig[];
}
+1 -4
View File
@@ -31,9 +31,6 @@ export interface LovelaceViewBackgroundConfig {
attachment?: "scroll" | "fixed";
}
export type LovelaceDashboardBackgroundConfig =
string | LovelaceViewBackgroundConfig;
export interface LovelaceViewHeaderConfig {
card?: LovelaceCardConfig;
layout?: "start" | "center" | "responsive";
@@ -63,7 +60,7 @@ export interface LovelaceBaseViewConfig {
show_icon_and_title?: boolean;
theme?: string;
panel?: boolean;
background?: LovelaceDashboardBackgroundConfig;
background?: string | LovelaceViewBackgroundConfig;
visible?: boolean | ShowViewConfig[];
subview?: boolean;
back_path?: string;
+10 -23
View File
@@ -14,7 +14,6 @@ import type {
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
devicesInEffectiveArea,
getDeviceIntegrationLookup,
} from "./device/device_registry";
import type {
@@ -727,10 +726,9 @@ export const expandAreaTarget = (
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
// Devices of an area are its effective-area members: a child device inheriting
// this area counts, a child with a different explicit area does not.
devicesInEffectiveArea(devices, areaId).forEach((device) => {
Object.values(devices).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -792,8 +790,9 @@ export const areaMeetsTargetSelector = (
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = devicesInEffectiveArea(devices, areaId).some(
(device) =>
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass.states,
Object.values(entities),
@@ -801,7 +800,11 @@ export const areaMeetsTargetSelector = (
targetSelector,
entitySources
)
);
) {
return true;
}
return false;
});
if (hasMatchingdevice) {
return true;
}
@@ -843,8 +846,6 @@ export const deviceMeetsTargetSelector = (
}
}
if (targetSelector.target?.entity) {
// Only the device's own entities: a child device is reached through the
// device target itself, so a parent must not match on a child's behalf.
const entities = entityRegistry.filter(
(reg) => reg.device_id === device.id
);
@@ -1111,11 +1112,6 @@ export const resolveEntityIDs = (
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
// Only a directly targeted device pulls in its child devices. Devices that are
// only reached through a label or an area must not, because core does not
// inherit labels to children and resolves areas by effective area membership.
const directDevices = new Set(targetDevices);
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
@@ -1147,15 +1143,6 @@ export const resolveEntityIDs = (
expanded.entities.forEach((id) => targetEntities.add(id));
});
// Targeting a device also targets its child devices, matching core's
// server-side target resolution. Only direct device targets expand this way;
// nesting is single-level, so one pass is enough.
Object.values(devices).forEach((device) => {
if (device.parent_device_id && directDevices.has(device.parent_device_id)) {
targetDevices.add(device.id);
}
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
+4 -8
View File
@@ -7,10 +7,7 @@ import type { CallWS, HomeAssistant } from "../types";
import type { AreaRegistryEntry } from "./area/area_registry";
import type { FloorComboBoxItem } from "./area_floor_picker";
import type { DevicePickerItem } from "./device/device_picker";
import {
devicesInEffectiveArea,
type DeviceRegistryEntry,
} from "./device/device_registry";
import type { DeviceRegistryEntry } from "./device/device_registry";
import type { HaEntityPickerEntityFilterFunc } from "./entity/entity";
import type { EntityComboBoxItem } from "./entity/entity_picker";
import type { EntityRegistryDisplayEntry } from "./entity/entity_registry";
@@ -128,7 +125,9 @@ export const areaMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
const areaDevices = devicesInEffectiveArea(devices, area.area_id);
const areaDevices = Object.values(devices).filter(
(device) => device.area_id === area.area_id
);
if (
areaDevices.some((device) =>
@@ -179,9 +178,6 @@ export const deviceMeetsFilter = (
entityFilter?: HaEntityPickerEntityFilterFunc,
includeSecondary = false
): boolean => {
// Only the device's own entities: child devices are targeted through the
// device itself (see core's target resolution), not by making a parent match
// on behalf of a child.
const devEntities = Object.values(entities).filter(
(entity) => entity.device_id === device.id
);
@@ -370,7 +370,6 @@ export class HaAutomationAddSearch extends LitElement {
width: "var(--ha-space-12)",
position: "absolute",
top: "0",
height: "100%",
left: rtl ? undefined : "var(--ha-space-1)",
right: rtl ? "var(--ha-space-1)" : undefined,
transform: rtl ? "scaleX(-1)" : "",
@@ -7,7 +7,6 @@ import { createDurationData } from "../../../../../common/datetime/create_durati
import { durationDataToSeconds } from "../../../../../common/datetime/duration_to_seconds";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../../common/dom/stop_propagation";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -430,8 +429,20 @@ export class HaPlatformCondition extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
@@ -4,7 +4,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { getSelectorFallbackValue } from "../../../../../components/ha-form/get-selector-fallback-value";
import "../../../../../components/ha-checkbox";
import "../../../../../components/ha-selector/ha-selector";
import "../../../../../components/ha-settings-row";
@@ -423,8 +422,20 @@ export class HaPlatformTrigger extends LitElement {
Object.entries(this.description).find(([k, _value]) => k === key)?.[1];
let defaultValue = field?.default;
if (defaultValue == null && field?.selector) {
defaultValue = getSelectorFallbackValue(field.selector);
if (
defaultValue == null &&
field?.selector &&
"constant" in field.selector
) {
defaultValue = field.selector.constant?.value;
}
if (
defaultValue == null &&
field?.selector &&
"boolean" in field.selector
) {
defaultValue = false;
}
if (defaultValue != null) {
@@ -1,168 +0,0 @@
import { consume } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDeviceNameDisplay } from "../../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../../common/entity/context/get_device_context";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import "../../../../components/ha-card";
import "../../../../components/ha-icon-next";
import "../../../../components/ha-list-item";
import { fullEntitiesContext } from "../../../../data/context";
import type { DeviceRegistryEntry } from "../../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
const MAX_VISIBLE_CHILD_DEVICES = 10;
@customElement("ha-device-child-devices-card")
export class HaDeviceChildDevicesCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public deviceId!: string;
@state() public _showAll = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
private _entityCounts = memoizeOne(
(entities: EntityRegistryEntry[]): Record<string, number> => {
const counts: Record<string, number> = {};
for (const entity of entities) {
if (entity.device_id) {
counts[entity.device_id] = (counts[entity.device_id] ?? 0) + 1;
}
}
return counts;
}
);
private _childDevices = memoizeOne(
(
deviceId: string,
devices: Record<string, DeviceRegistryEntry>
): DeviceRegistryEntry[] =>
Object.values(devices)
.filter((device) => device.parent_device_id === deviceId)
.sort((d1, d2) =>
caseInsensitiveStringCompare(
computeDeviceNameDisplay(d1, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(d2, this.hass.localize, this.hass.states),
this.hass.locale.language
)
)
);
protected render() {
const childDevices = this._childDevices(this.deviceId, this.hass.devices);
if (childDevices.length === 0) {
return nothing;
}
const entityCounts = this._entityCounts(this._entityReg);
return html`
<ha-card>
<h1 class="card-header">
${this.hass.localize("ui.panel.config.devices.child_devices.heading")}
</h1>
${(this._showAll
? childDevices
: childDevices.slice(0, MAX_VISIBLE_CHILD_DEVICES)
).map((childDevice) => {
const area = getDeviceArea(
childDevice,
this.hass.areas,
this.hass.devices
);
const entityCount = entityCounts[childDevice.id] ?? 0;
const secondary = [
area?.name,
entityCount
? this.hass.localize(
"ui.panel.config.common.quick_links.entities",
{ count: entityCount }
)
: undefined,
]
.filter(Boolean)
.join(" • ");
return html`
<a href=${`/config/devices/device/${childDevice.id}`}>
<ha-list-item hasMeta .twoline=${!!secondary}>
${computeDeviceNameDisplay(
childDevice,
this.hass.localize,
this.hass.states
)}
${
secondary
? html`<span slot="secondary">${secondary}</span>`
: nothing
}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
</a>
`;
})}
${
!this._showAll && childDevices.length > MAX_VISIBLE_CHILD_DEVICES
? html`
<button class="show-more" @click=${this._toggleShowAll}>
${this.hass.localize(
"ui.panel.config.devices.child_devices.show_more",
{ count: childDevices.length - MAX_VISIBLE_CHILD_DEVICES }
)}
</button>
`
: ""
}
</ha-card>
`;
}
private _toggleShowAll() {
this._showAll = !this._showAll;
}
static styles = css`
:host {
display: block;
}
.card-header {
padding-bottom: 0;
}
a {
text-decoration: none;
color: var(--primary-text-color);
}
button.show-more {
color: var(--primary-color);
text-align: left;
cursor: pointer;
background: none;
border-width: initial;
border-style: none;
border-color: initial;
border-image: initial;
padding: 16px;
font: inherit;
}
button.show-more:focus {
outline: none;
text-decoration: underline;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-device-child-devices-card": HaDeviceChildDevicesCard;
}
}
@@ -93,27 +93,6 @@ export class HaDeviceCard extends LitElement {
`
: ""
}
${
this.device.parent_device_id
? html`
<div class="extra-info">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.part_of"
)}
<span class="hub"
><a
href="/config/devices/device/${
this.device.parent_device_id
}"
>${this._computeDeviceNameDisplay(
this.device.parent_device_id
)}</a
></span
>
</div>
`
: ""
}
${
this.device.via_device_id
? html`
@@ -107,7 +107,6 @@ import { createSearchParam } from "../../../common/url/search-params";
import { brandsUrl } from "../../../util/brands-url";
import { fileDownload } from "../../../util/file_download";
import "../../logbook/ha-logbook";
import "./device-detail/ha-device-child-devices-card";
import "./device-detail/ha-device-entities-card";
import "./device-detail/ha-device-info-card";
import "./device-detail/ha-device-linked-devices-card";
@@ -901,10 +900,6 @@ export class HaConfigDevicePage extends LitElement {
: ""
}
</ha-device-info-card>
<ha-device-child-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
></ha-device-child-devices-card>
<ha-device-linked-devices-card
.hass=${this.hass}
.deviceId=${this.deviceId}
@@ -459,15 +459,6 @@ export class HaConfigDeviceDashboard extends LitElement {
? new Map(labelReg.map((label) => [label.label_id, label]))
: undefined;
// Ids of devices that have at least one child device, so a parent can be
// grouped together with its children.
const deviceIdsWithChildren = new Set<string>();
for (const dev of Object.values(devices)) {
if (dev.parent_device_id) {
deviceIdsWithChildren.add(dev.parent_device_id);
}
}
const formattedOutputDevices = outputDevices.map((device) => {
const deviceEntries = sortConfigEntries(
device.config_entries
@@ -481,15 +472,6 @@ export class HaConfigDeviceDashboard extends LitElement {
.map((lbl) => labelLookup!.get(lbl))
.filter((entry): entry is LabelRegistryEntry => entry !== undefined);
const parentDevice = device.parent_device_id
? this.hass.devices[device.parent_device_id]
: undefined;
// The device that identifies this device's family: its parent for a
// child device, itself for a device that has children.
const familyParentDevice =
parentDevice ??
(deviceIdsWithChildren.has(device.id) ? device : undefined);
const { areaName } = computeDeviceAreaLabel(
device,
this.hass.areas,
@@ -545,29 +527,6 @@ export class HaConfigDeviceDashboard extends LitElement {
"ui.panel.config.devices.data_table.no_integration"
),
domains: deviceEntries.map((entry) => entry.domain),
parent_device_name: parentDevice
? computeDeviceNameDisplay(
parentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[parentDevice.id]
)
: "",
// Grouping key that keeps a device with its family: children group
// under their parent's name, a parent groups under its own name, and
// standalone devices stay ungrouped. The name is always computed from
// the family's parent device with the same arguments, so a parent and
// its children can never end up in different groups. Like the area and
// floor columns, this groups on the display name rather than the id,
// because the data table renders the raw group value as its header.
device_family_name: familyParentDevice
? computeDeviceNameDisplay(
familyParentDevice,
this.hass.localize,
this.hass.states,
deviceEntityLookup[familyParentDevice.id]
)
: undefined,
firmware_version: device.sw_version || undefined,
battery_entity: [
this._batteryEntity(device.id, deviceEntityLookup),
@@ -628,16 +587,6 @@ export class HaConfigDeviceDashboard extends LitElement {
flex: 2,
minWidth: "150px",
extraTemplate: (device) => html`
${
device.parent_device_name
? html`<div style="color: var(--secondary-text-color);">
${localize(
"ui.panel.config.devices.data_table.part_of_device",
{ name: device.parent_device_name }
)}
</div>`
: nothing
}
${
device.label_entries.length
? html`
@@ -658,19 +607,6 @@ export class HaConfigDeviceDashboard extends LitElement {
groupable: true,
minWidth: "120px",
},
device_family_name: {
title: localize("ui.panel.config.devices.data_table.parent_device"),
// Keyed on the family name so grouping/sorting keeps a parent together
// with its children (grouping uses the column key directly). The cell
// only shows the parent name for child devices. Filterable stays on
// even when hidden, so searching a parent's name surfaces its children.
sortable: true,
filterable: true,
groupable: true,
defaultHidden: true,
minWidth: "120px",
template: (device) => device.parent_device_name || "",
},
manufacturer: {
title: localize("ui.panel.config.devices.data_table.manufacturer"),
sortable: true,
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -25,11 +23,9 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -108,7 +104,18 @@ export class EnergyDeviceSettingsWater extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
${this._renderName(device)}
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderIssueIndicator(
this.validationResult?.device_consumption_water[
index
@@ -148,32 +155,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -299,22 +280,6 @@ export class EnergyDeviceSettingsWater extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,8 +11,6 @@ import { css, html, LitElement, nothing } from "lit";
import { repeat } from "lit/directives/repeat";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeAreaName } from "../../../../common/entity/compute_area_name";
import { getEntityAreaId } from "../../../../common/entity/context/get_entity_context";
import "../../../../components/ha-card";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
@@ -25,11 +23,9 @@ import type {
EnergyPreferencesValidation,
EnergyValidationIssue,
} from "../../../../data/energy";
import {
computeEnergyLabel,
saveEnergyPreferences,
} from "../../../../data/energy";
import { saveEnergyPreferences } from "../../../../data/energy";
import type { StatisticsMetaData } from "../../../../data/recorder";
import { getStatisticLabel } from "../../../../data/recorder";
import {
showAlertDialog,
showConfirmationDialog,
@@ -108,7 +104,18 @@ export class EnergyDeviceSettings extends LitElement {
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>
</div>
${this._renderName(device)}
<span class="content"
>${
device.name ||
getStatisticLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[
device.stat_consumption
]
)
}</span
>
${this._renderIssueIndicator(
this.validationResult?.device_consumption[
index
@@ -148,32 +155,6 @@ export class EnergyDeviceSettings extends LitElement {
`;
}
private _renderName(device: DeviceConsumptionEnergyPreference) {
const name = computeEnergyLabel(
this.hass,
device.stat_consumption,
this.statsMetadata?.[device.stat_consumption],
device.name
);
const areaId = getEntityAreaId(
device.stat_consumption,
this.hass.entities,
this.hass.devices
);
const area = areaId ? this.hass.areas[areaId] : undefined;
const areaName = area ? computeAreaName(area) : undefined;
return html`
<div class="content">
<span class="label">${name}</span>
${
areaName
? html`<span class="label secondary">${areaName}</span>`
: nothing
}
</div>
`;
}
private _renderIssueIndicator(
issues: EnergyValidationIssue[] | undefined,
index: number
@@ -295,22 +276,6 @@ export class EnergyDeviceSettings extends LitElement {
haStyle,
energyCardStyles,
css`
.row {
height: 58px;
}
.content {
display: flex;
flex-direction: column;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.label.secondary {
color: var(--secondary-text-color);
font-size: 0.9em;
}
.handle {
cursor: move; /* fallback if grab cursor is unsupported */
cursor: grab;
@@ -11,11 +11,9 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -176,7 +174,7 @@ export class DialogEnergyDeviceSettingsWater
.value=${this._device?.name || ""}
.placeholder=${
this._device
? computeEnergyLabel(
? getStatisticLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -11,11 +11,9 @@ import "../../../../components/input/ha-input";
import "./ha-energy-upstream-device-picker";
import type { HaInput } from "../../../../components/input/ha-input";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { energyStatisticHelpUrl } from "../../../../data/energy";
import {
computeEnergyLabel,
energyStatisticHelpUrl,
} from "../../../../data/energy";
import {
getStatisticLabel,
getStatisticMetadata,
isExternalStatistic,
} from "../../../../data/recorder";
@@ -172,7 +170,7 @@ export class DialogEnergyDeviceSettings
.value=${this._device?.name || ""}
.placeholder=${
this._device
? computeEnergyLabel(
? getStatisticLabel(
this.hass,
this._device.stat_consumption,
this._params?.statsMetadata?.[this._device.stat_consumption]
@@ -7,6 +7,7 @@ import memoizeOne from "memoize-one";
import { computeEntityNameList } from "../../../../common/entity/compute_entity_name_display";
import { computeStateName } from "../../../../common/entity/compute_state_name";
import { fireEvent } from "../../../../common/dom/fire_event";
import { computeRTL } from "../../../../common/util/compute_rtl";
import "../../../../components/entity/state-badge";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-generic-picker";
@@ -14,7 +15,6 @@ import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-svg-icon";
import type { DeviceConsumptionEnergyPreference } from "../../../../data/energy";
import { computeEnergyLabel } from "../../../../data/energy";
import { domainToName } from "../../../../data/integration";
import {
getStatisticLabel,
@@ -73,18 +73,20 @@ export class HaEnergyUpstreamDevicePicker extends LitElement {
this.hass.floors
);
const isRTL = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const friendlyName = computeStateName(stateObj); // Keep this for search
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
return {
id: statisticId,
// Match the label shown in the device list and the graphs.
primary: computeEnergyLabel(
this.hass,
statisticId,
this.statsMetadata?.[statisticId],
name
),
secondary: areaName,
primary: name || entityName || deviceName || statisticId,
secondary,
stateObj,
search_labels: {
entityName: entityName || null,
@@ -10,15 +10,12 @@ import {
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { getDeviceArea } from "../../../common/entity/context/get_device_context";
import { navigate } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-tree-indicator";
import {
disableConfigEntry,
type ConfigEntry,
@@ -51,13 +48,6 @@ class HaConfigEntryDeviceRow extends LitElement {
@property({ attribute: false }) public entities!: EntityRegistryEntry[];
// Rendered indented under its parent device.
@property({ type: Boolean, reflect: true, attribute: "is-child" })
public isChild = false;
// The last child of its parent, so the tree connector draws its end.
@property({ attribute: false }) public isLastChild = false;
protected render() {
const device = this.device;
@@ -65,11 +55,6 @@ class HaConfigEntryDeviceRow extends LitElement {
const area = getDeviceArea(device, this.hass.areas, this.hass.devices);
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
const supportingText = [
device.model || device.sw_version || device.manufacturer,
area ? area.name : undefined,
@@ -80,25 +65,6 @@ class HaConfigEntryDeviceRow extends LitElement {
@click=${this._handleNavigateToDevice}
class=${classMap({ disabled: Boolean(device.disabled_by) })}
>
${
this.isChild
? html`<ha-tree-indicator
style=${styleMap({
position: "absolute",
// Span the full row height so consecutive children form one
// continuous line; the elbow sits at the vertical centre.
top: "0",
// Align the connector under the parent device icon; the leading
// space (and thus the icon column) is smaller in narrow mode.
left: rtl ? undefined : this.narrow ? "4px" : "44px",
right: rtl ? (this.narrow ? "4px" : "44px") : undefined,
transform: rtl ? "scaleX(-1)" : "",
})}
.end=${this.isLastChild}
slot="start"
></ha-tree-indicator>`
: nothing
}
<ha-svg-icon
.path=${
device.entry_type === "service"
@@ -385,22 +351,12 @@ class HaConfigEntryDeviceRow extends LitElement {
--md-ripple-hover-color: transparent;
--md-ripple-pressed-color: transparent;
}
:host([is-child]) ha-md-list-item {
--md-list-item-leading-space: 88px;
}
.disabled {
opacity: 0.5;
}
:host([narrow]) ha-md-list-item {
--md-list-item-leading-space: 16px;
}
:host([narrow][is-child]) ha-md-list-item {
--md-list-item-leading-space: 48px;
}
ha-tree-indicator {
width: 48px;
height: 100%;
}
.vertical-divider {
height: 100%;
width: 1px;
@@ -45,7 +45,6 @@ import {
} from "../../../data/config_entries";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import { getConfigEntryDiagnosticsDownloadUrl } from "../../../data/diagnostics";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
import {
@@ -480,16 +479,14 @@ export class HaConfigEntryRow extends LitElement {
</ha-md-list-item>
${
this._devicesExpanded
? groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
? ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)
: nothing
@@ -512,16 +509,14 @@ export class HaConfigEntryRow extends LitElement {
`
)}`
: html`
${groupDevicesByParent(ownDevices).map(
({ device, isChild, isLastChild }) =>
${ownDevices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${item}
.device=${device}
.entities=${entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
`
@@ -15,7 +15,6 @@ import "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import type { ConfigEntry } from "../../../data/config_entries";
import { deleteSubEntry, updateSubEntry } from "../../../data/config_entries";
import { groupDevicesByParent } from "../../../data/device/device_registry";
import type { DiagnosticInfo } from "../../../data/diagnostics";
import type { EntityRegistryEntry } from "../../../data/entity/entity_registry";
import type { IntegrationManifest } from "../../../data/integration";
@@ -192,16 +191,14 @@ class HaConfigSubEntryRow extends LitElement {
${
this._expanded
? html`
${groupDevicesByParent(devices).map(
({ device, isChild, isLastChild }) =>
${devices.map(
(device) =>
html`<ha-config-entry-device-row
.hass=${this.hass}
.narrow=${this.narrow}
.entry=${this.entry}
.device=${device}
.entities=${this.entities}
.isChild=${isChild}
.isLastChild=${isLastChild}
></ha-config-entry-device-row>`
)}
${services.map(
@@ -1,46 +1,29 @@
import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { slugify } from "../../../../common/string/slugify";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-form/ha-form";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tab-group";
import "../../../../components/ha-tab-group-tab";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceDashboard } from "../../../../data/lovelace/dashboard";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
LovelaceDashboardMutableParams,
} from "../../../../data/lovelace/dashboard";
import { DirtyStateProviderMixin } from "../../../../mixins/dirty-state-provider-mixin";
import {
haStyleDialog,
haStyleDialogFixedTop,
} from "../../../../resources/styles";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../../lovelace/editor/view-editor/hui-view-background-editor";
import type { LovelaceDashboardDetailsDialogParams } from "./show-dialog-lovelace-dashboard-detail";
import { pickAvailableDashboardUrlPath } from "./pick-available-dashboard-url-path";
const TABS = ["tab-settings", "tab-background"] as const;
interface DashboardDetailState {
dashboard: Partial<LovelaceDashboard>;
background?: LovelaceConfig["background"];
}
@customElement("dialog-lovelace-dashboard-detail")
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<DashboardDetailState>()(
LitElement
) {
export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<
Partial<LovelaceDashboard>
>()(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: LovelaceDashboardDetailsDialogParams;
@@ -55,59 +38,39 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
@state() private _submitting = false;
@state() private _currTab: (typeof TABS)[number] = TABS[0];
@state() private _backgroundConfig?: LovelaceConfig;
public showDialog(params: LovelaceDashboardDetailsDialogParams) {
public showDialog(params: LovelaceDashboardDetailsDialogParams): void {
this._params = params;
this._error = undefined;
this._urlPathChanged = false;
this._currTab = TABS[0];
this._backgroundConfig = params.lovelaceConfig;
this._open = true;
if (this._params.dashboard) {
this._data = this._params.dashboard;
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: this._params.dashboard,
background: this._params.lovelaceConfig?.background,
}
);
this._initDirtyTracking({ type: "deep" }, this._data);
} else {
const suggestions = this._params.suggestions;
this._data = {
show_in_sidebar: true,
icon: this._params.suggestions?.icon,
title: this._params.suggestions?.title ?? "",
icon: suggestions?.icon,
title: suggestions?.title ?? "",
require_admin: false,
mode: "storage",
};
this._initDirtyTracking(
{ type: "deep" },
{
dashboard: {},
background: this._params.lovelaceConfig?.background,
}
);
if (this._params.suggestions?.title) {
this._fillUrlPath(this._params.suggestions.title);
// New dashboards have no saved baseline, so track against an emptyobject to mark them dirty from the outset (keeps Create enabled).
this._initDirtyTracking({ type: "deep" }, {});
if (suggestions?.title) {
this._fillUrlPath(suggestions.title);
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
this._updateDirtyState(this._data!);
}
}
public closeDialog() {
public closeDialog(): void {
this._open = false;
}
private _dialogClosed() {
private _dialogClosed(): void {
this._params = undefined;
this._data = undefined;
this._backgroundConfig = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -119,18 +82,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
const yamlMode = this._params.dashboard?.mode === "yaml";
const titleInvalid = !this._data.title || !this._data.title.trim();
const dialogTitle = this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
);
const showBackgroundTab =
this._params.dashboard?.mode !== "yaml" &&
Boolean(this._params.lovelaceConfig) &&
Boolean(this._params.saveConfig);
const cancelButton = html`
<ha-button
@@ -145,43 +96,41 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
return html`
<ha-dialog
.open=${this._open}
header-title=${showBackgroundTab ? nothing : dialogTitle}
width=${showBackgroundTab ? "large" : "medium"}
header-title=${
this._params.urlPath
? this._data.title ||
this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.edit_dashboard"
)
: this.hass.localize(
"ui.panel.config.lovelace.dashboards.detail.new_dashboard"
)
}
.preventScrimClose=${this.isDirtyState}
@closed=${this._dialogClosed}
>
${
showBackgroundTab
? html`
<ha-dialog-header show-border slot="header">
<ha-icon-button
slot="navigationIcon"
@click=${this.closeDialog}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
></ha-icon-button>
<h2 slot="title">${dialogTitle}</h2>
<ha-tab-group @wa-tab-show=${this._handleTabChanged}>
${TABS.map(
(tab) => html`
<ha-tab-group-tab
slot="nav"
.panel=${tab}
.active=${this._currTab === tab}
>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.${tab.replace("-", "_")}`
)}
</ha-tab-group-tab>
`
)}
</ha-tab-group>
</ha-dialog-header>
`
: nothing
}
<div>
${this._renderContent(this._params, this._data, showBackgroundTab)}
${
yamlMode
? this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
)
: html`
<ha-form
autofocus
.schema=${this._schema(
this._params,
this._data?.require_admin
)}
.data=${this._data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`
}
</div>
<ha-dialog-footer slot="footer">
${
@@ -236,46 +185,6 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
`;
}
private _renderContent(
params: LovelaceDashboardDetailsDialogParams,
data: Partial<LovelaceDashboard>,
showBackgroundTab: boolean
): string | TemplateResult<1> | typeof nothing {
if (params.dashboard?.mode === "yaml") {
return this.hass.localize(
"ui.panel.config.lovelace.dashboards.cant_edit_yaml"
);
}
if (this._currTab === "tab-background" && showBackgroundTab) {
return html`
${
this._error?.base
? html`<ha-alert alert-type="error">${this._error.base}</ha-alert>`
: nothing
}
<hui-view-background-editor
.hass=${this.hass}
.config=${this._backgroundConfig}
@background-config-changed=${this._backgroundConfigChanged}
></hui-view-background-editor>
`;
}
return html`
<ha-form
autofocus
.schema=${this._schema(params, data.require_admin)}
.data=${data}
.hass=${this.hass}
.error=${this._error}
.computeLabel=${this._computeLabel}
.computeHelper=${this._computeHelper}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _schema = memoizeOne(
(params: LovelaceDashboardDetailsDialogParams, requireAdmin?: boolean) =>
[
@@ -342,16 +251,15 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
)
: "";
private _valueChanged(
ev: HASSDomEvent<{ value: Partial<LovelaceDashboard> }>
) {
private _valueChanged(ev: CustomEvent) {
this._error = undefined;
if (ev.detail.value.url_path !== this._data?.url_path) {
const value = ev.detail.value;
if (value.url_path !== this._data?.url_path) {
this._urlPathChanged = true;
if (
!ev.detail.value.url_path ||
ev.detail.value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(ev.detail.value.url_path)
!value.url_path ||
value.url_path === "lovelace" ||
!/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]+$/.test(value.url_path)
) {
this._error = {
url_path: this.hass.localize(
@@ -360,30 +268,13 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
};
}
}
if (ev.detail.value.title !== this._data?.title) {
this._data = ev.detail.value;
if (ev.detail.value.title) {
this._fillUrlPath(ev.detail.value.title);
}
if (value.title !== this._data?.title) {
this._data = value;
this._fillUrlPath(value.title);
} else {
this._data = ev.detail.value;
}
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
}
private _backgroundConfigChanged(
ev: HASSDomEvent<{ config: LovelaceConfig }>
) {
this._backgroundConfig = ev.detail.config;
if (this._data) {
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig.background,
});
this._data = value;
}
this._updateDirtyState(this._data!);
}
private _fillUrlPath(title: string) {
@@ -395,55 +286,36 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
const baseSlug = slugifyTitle.includes("-")
? slugifyTitle
: `dashboard-${slugifyTitle}`;
const taken = this._params?.takenUrlPaths;
this._data = {
...this._data,
url_path:
this._params?.takenUrlPaths !== undefined
? pickAvailableDashboardUrlPath(baseSlug, this._params.takenUrlPaths)
taken !== undefined
? pickAvailableDashboardUrlPath(baseSlug, taken)
: baseSlug,
};
this._updateDirtyState({
dashboard: this._data,
background: this._backgroundConfig?.background,
});
this._updateDirtyState(this._data!);
}
private async _updateDashboard() {
if (!this._params || !this._data) {
return;
}
if (this._params.urlPath && this._params.dashboard?.mode === "yaml") {
if (this._params?.urlPath && this._params.dashboard?.mode === "yaml") {
this.closeDialog();
return;
}
this._submitting = true;
try {
if (this._params.dashboard) {
await this._params.updateDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
});
} else if (this._params.createDashboard) {
await this._params.createDashboard({
require_admin: this._data.require_admin ?? false,
show_in_sidebar: this._data.show_in_sidebar ?? true,
icon: this._data.icon || undefined,
title: this._data.title ?? "",
url_path: this._data.url_path ?? "",
mode: "storage",
});
}
if (
this._backgroundConfig &&
this._params.saveConfig &&
this._params.lovelaceConfig &&
this._backgroundConfig.background !==
this._params.lovelaceConfig.background
) {
await this._params.saveConfig(this._backgroundConfig);
if (this._params!.dashboard) {
const values: Partial<LovelaceDashboardMutableParams> = {
require_admin: this._data!.require_admin,
show_in_sidebar: this._data!.show_in_sidebar,
icon: this._data!.icon || undefined,
title: this._data!.title,
};
await this._params!.updateDashboard(values);
} else if (this._params!.createDashboard) {
await this._params!.createDashboard(
this._data as LovelaceDashboardCreateParams
);
}
this._markDirtyStateClean();
this.closeDialog();
@@ -467,25 +339,10 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
}
}
private _handleTabChanged(
ev: HASSDomEvent<{
name: (typeof TABS)[number];
}>
) {
if (ev.detail.name === this._currTab) {
return;
}
this._currTab = ev.detail.name;
}
private async _deleteDashboard() {
if (!this._params) {
return;
}
this._submitting = true;
try {
if (await this._params.removeDashboard()) {
if (await this._params!.removeDashboard()) {
this.closeDialog();
}
} finally {
@@ -494,30 +351,7 @@ export class DialogLovelaceDashboardDetail extends DirtyStateProviderMixin<Dashb
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
haStyleDialogFixedTop,
css`
ha-dialog {
--dialog-content-padding: var(--ha-space-6);
}
h2 {
margin: 0;
font-size: inherit;
font-weight: inherit;
}
ha-tab-group-tab {
flex: 1;
}
ha-tab-group-tab::part(base) {
width: 100%;
justify-content: center;
}
`,
];
return [haStyleDialog, css``];
}
}
@@ -1,5 +1,4 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type {
LovelaceDashboard,
LovelaceDashboardCreateParams,
@@ -18,8 +17,6 @@ export interface LovelaceDashboardDetailsDialogParams {
* auto-generated paths avoid collisions by appending -2, -3, and so on.
*/
takenUrlPaths?: ReadonlySet<string>;
lovelaceConfig?: LovelaceConfig;
saveConfig?: (config: LovelaceConfig) => Promise<void>;
createDashboard?: (values: LovelaceDashboardCreateParams) => Promise<unknown>;
updateDashboard: (
updates: Partial<LovelaceDashboardMutableParams>
@@ -1,33 +0,0 @@
import type {
LovelaceCardFeatureConfig,
LovelaceCardFeaturePosition,
} from "../types";
export interface CardFeatureLayout {
inline: LovelaceCardFeatureConfig[];
below: LovelaceCardFeatureConfig[];
/** Columns filled by the below features, 0 when there are none */
columns: number;
}
const INLINE_COLUMNS = 2;
export const computeCardFeatureLayout = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): CardFeatureLayout => {
if (position !== "inline") {
return { inline: [], below: features ?? [], columns: 1 };
}
const inline = features?.slice(0, 1) ?? [];
const below = features?.slice(1) ?? [];
return { inline, below, columns: Math.min(below.length, INLINE_COLUMNS) };
};
export const computeCardFeatureRows = (
features: LovelaceCardFeatureConfig[] | undefined,
position: LovelaceCardFeaturePosition
): number => {
const { below, columns } = computeCardFeatureLayout(features, position);
return Math.ceil(below.length / Math.max(columns, 1));
};
@@ -1,6 +1,5 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../../../types";
import "./hui-card-feature";
import type {
@@ -33,32 +32,22 @@ export class HuiCardFeatures extends LitElement {
@property({ attribute: false })
public position?: LovelaceCardFeaturePosition;
@property({ type: Number, reflect: true })
public columns = 1;
protected render() {
if (!this.features) {
return nothing;
}
const lastIndex = this.features.length - 1;
const columns = Math.max(this.columns, 1);
return html`
${this.features.map((feature, index) => {
const column = index % columns;
return html`
${this.features.map(
(feature) => html`
<hui-card-feature
class=${classMap({
divided: column > 0,
wide: column === 0 && index === lastIndex,
})}
.hass=${this.hass}
.context=${this.context}
.color=${this.color}
.feature=${feature}
.position=${this.position}
></hui-card-feature>
`;
})}
`
)}
`;
}
@@ -66,38 +55,20 @@ export class HuiCardFeatures extends LitElement {
:host {
--feature-color: var(--state-icon-color);
--feature-height: 42px;
--feature-columns: 1;
--feature-border-radius: var(
--ha-card-features-border-radius,
var(--ha-border-radius-lg)
);
--feature-button-spacing: 12px;
--feature-column-gap: var(
--ha-card-feature-column-gap,
var(--ha-card-feature-gap, 12px)
);
--feature-divider-inset: var(--ha-card-feature-divider-inset, 0px);
pointer-events: none;
position: relative;
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--ha-card-feature-gap, 12px) var(--feature-column-gap);
display: flex;
flex-direction: column;
gap: var(--ha-card-feature-gap, 12px);
width: 100%;
box-sizing: border-box;
align-content: space-evenly;
}
:host([columns="2"]) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.wide {
grid-column: 1 / -1;
}
/* pull the divider out of the column and into the middle of the gutter */
.divided {
box-sizing: border-box;
margin-inline-start: calc(-1 * var(--feature-divider-inset));
padding-inline-start: var(--feature-divider-inset);
border-inline-start: var(--ha-card-feature-divider, none);
justify-content: space-evenly;
}
`;
}
@@ -1,7 +1,7 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
consumeEntityState,
@@ -202,7 +202,14 @@ class HuiMediaPlayerPlaybackCardFeature
});
}
static styles = cardFeatureStyles;
static styles = [
cardFeatureStyles,
css`
ha-control-button-group {
overflow: hidden;
}
`,
];
}
declare global {
@@ -8,13 +8,13 @@ import type {
} from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getSuggestedPeriod,
getSummedData,
} from "../../../../data/energy";
import type { Statistics } from "../../../../data/recorder";
import type { Statistics, StatisticsMetaData } from "../../../../data/recorder";
import {
calculateStatisticSumGrowth,
getStatisticLabel,
isExternalStatistic,
} from "../../../../data/recorder";
import type { HomeAssistant } from "../../../../types";
@@ -69,13 +69,13 @@ interface ProcessContext {
end: Date;
compareStart?: Date;
untrackedOrder: number;
deviceLabels: Record<string, string>;
}
function processDataSet(
ctx: ProcessContext,
computedStyle: CSSStyleDeclaration,
statistics: Statistics,
statisticsMetaData: Record<string, StatisticsMetaData>,
devices: DeviceConsumptionEnergyPreference[],
sorted_devices: string[],
childMap: Record<string, string[]>,
@@ -167,7 +167,12 @@ function processDataSet(
}
const name =
ctx.deviceLabels[source.stat_consumption] +
(source.name ||
getStatisticLabel(
ctx.hass,
source.stat_consumption,
statisticsMetaData[source.stat_consumption]
)) +
(source.stat_consumption in childMap
? ` (${ctx.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_detail_graph.untracked")})`
: "");
@@ -346,8 +351,6 @@ export function generateEnergyDevicesDetailGraphData(
const data = energyData.stats;
const compareData = energyData.statsCompare;
const devices = energyData.prefs.device_consumption;
const ctx: ProcessContext = {
hass,
config,
@@ -355,13 +358,10 @@ export function generateEnergyDevicesDetailGraphData(
end,
compareStart,
untrackedOrder,
deviceLabels: computeEnergyDeviceLabels(
hass,
devices,
energyData.statsMetadata
),
};
const devices = energyData.prefs.device_consumption;
const childMap: Record<string, string[]> = {};
devices.forEach((d) => {
if (d.included_in_stat) {
@@ -425,6 +425,7 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
compareData,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -467,6 +468,7 @@ export function generateEnergyDevicesDetailGraphData(
ctx,
computedStyles,
data,
energyData.statsMetadata,
energyData.prefs.device_consumption,
sorted_devices,
childMap,
@@ -16,7 +16,6 @@ import "../../../../components/chart/ha-chart-tooltip-marker";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
getEnergyDataCollection,
getSummedData,
validateEnergyCollectionKey,
@@ -92,8 +91,6 @@ export class HuiEnergyDevicesGraphCard
private _compoundStats: string[] = [];
private _deviceLabels: Record<string, string> = {};
protected hassSubscribeRequiredHostProps = ["_config"];
public hassSubscribe(): UnsubscribeFunc[] {
@@ -298,8 +295,9 @@ export class HuiEnergyDevicesGraphCard
? ` (${this.hass.localize("ui.panel.lovelace.cards.energy.energy_devices_graph.untracked")})`
: "";
return (
// The untracked slice is not a statistic, so it has no label.
(this._deviceLabels[statisticId] ||
(this._data?.prefs.device_consumption.find(
(d) => d.stat_consumption === statisticId
)?.name ||
getStatisticLabel(
this.hass,
statisticId,
@@ -379,12 +377,6 @@ export class HuiEnergyDevicesGraphCard
.map((d) => d.included_in_stat)
.filter(Boolean) as string[];
this._deviceLabels = computeEnergyDeviceLabels(
this.hass,
energyData.prefs.device_consumption,
energyData.statsMetadata
);
const devices = energyData.prefs.device_consumption;
const devicesTotals: Record<string, number> = {};
devices.forEach((device) => {
@@ -8,7 +8,6 @@ import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeConsumptionData,
computeEnergyDeviceLabels,
energySourcesByType,
getEnergyDataCollection,
getSummedData,
@@ -273,14 +272,8 @@ class HuiEnergySankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
this.hass,
statConsumption,
@@ -7,7 +7,6 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData, EnergyPreferences } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatPowerShort,
getEnergyDataCollection,
getPowerFromState,
@@ -279,13 +278,6 @@ class HuiPowerSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -302,7 +294,7 @@ class HuiPowerSankeyCard
initialUntracked: homeNode.value,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentPower(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
+20 -37
View File
@@ -36,10 +36,6 @@ import { UNAVAILABLE, UNKNOWN } from "../../../data/entity/entity";
import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../card-features/common/feature-layout";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action";
@@ -155,12 +151,14 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
}
public getCardSize(): number {
const featuresPosition =
this._config && this._featurePosition(this._config);
const displayType = this._config?.display_type || "picture";
const featureRows = this._config ? this._featureRows(this._config) : 0;
const featuresCount = this._config?.features?.length || 0;
return (
1 +
(displayType === "compact" ? (this._config?.vertical ? 1 : 0) : 2) +
featureRows
(featuresPosition === "inline" ? 0 : featuresCount)
);
}
@@ -172,12 +170,13 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
? this._featurePosition(this._config)
: "bottom";
const featuresCount = this._config?.features?.length || 0;
if (this._config && featuresCount) {
if (featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
columns = 12;
} else {
rows += featuresCount;
}
rows += this._featureRows(this._config);
}
const displayType = this._config?.display_type || "picture";
@@ -556,13 +555,15 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
return config.features_position || "bottom";
});
private _featureLayout = memoizeOne((config: AreaCardConfig) =>
computeCardFeatureLayout(config.features, this._featurePosition(config))
);
private _displayedFeatures = memoizeOne((config: AreaCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
private _featureRows = memoizeOne((config: AreaCardConfig) =>
computeCardFeatureRows(config.features, this._featurePosition(config))
);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
public willUpdate(changedProps: PropertyValues) {
if (changedProps.has("_config") || this._ratio === null) {
@@ -600,7 +601,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
const secondary = this._computeSensorsDisplay();
const featurePosition = this._featurePosition(this._config);
const features = this._featureLayout(this._config);
const features = this._displayedFeatures(this._config);
const displayType = this._config.display_type || "picture";
@@ -619,11 +620,8 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
"--tile-color": color,
};
/* the picture takes the extra height, so only the compact type reserves a row */
const fixedInfoHeight =
displayType === "compact" &&
this.layout === "grid" &&
this._config.grid_options?.rows !== "auto";
this.layout === "grid" && this._config.grid_options?.rows !== "auto";
return html`
<ha-card style=${styleMap(style)}>
@@ -712,30 +710,15 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
.secondary=${secondary}
></ha-tile-info>
${
features.inline.length > 0
? html`
<hui-card-features
slot="features-inline"
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.inline}
.position=${featurePosition}
></hui-card-features>
`
: nothing
}
${
features.below.length > 0
features.length > 0
? html`
<hui-card-features
slot="features"
.columns=${features.columns}
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.below}
.position=${"bottom"}
.features=${features}
.position=${featurePosition}
></hui-card-features>
`
: nothing
+22 -31
View File
@@ -22,10 +22,6 @@ import type { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
import "../../../state-display/state-display";
import type { HomeAssistant } from "../../../types";
import "../card-features/hui-card-features";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../card-features/common/feature-layout";
import type { LovelaceCardFeatureContext } from "../card-features/types";
import { findEntities } from "../common/find-entities";
import { handleAction } from "../common/handle-action";
@@ -111,8 +107,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
public getCardSize(): number {
const featureRows = this._config ? this._featureRows(this._config) : 0;
return 1 + (this._config?.vertical ? 1 : 0) + featureRows;
const featuresPosition =
this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
return (
1 +
(this._config?.vertical ? 1 : 0) +
(featuresPosition === "inline" ? 0 : featuresCount)
);
}
public getGridOptions(): LovelaceGridOptions {
@@ -121,11 +123,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
let rows = 1;
const featurePosition = this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
if (this._config && featuresCount) {
if (featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
} else {
rows += featuresCount;
}
rows += this._featureRows(this._config);
}
if (this._config?.vertical) {
@@ -231,13 +234,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
return config.features_position || "bottom";
});
private _featureLayout = memoizeOne((config: TileCardConfig) =>
computeCardFeatureLayout(config.features, this._featurePosition(config))
);
private _displayedFeatures = memoizeOne((config: TileCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
private _featureRows = memoizeOne((config: TileCardConfig) =>
computeCardFeatureRows(config.features, this._featurePosition(config))
);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
protected render() {
if (!this._config || !this.hass) {
@@ -281,7 +286,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
: undefined;
const featurePosition = this._featurePosition(this._config);
const features = this._featureLayout(this._config);
const features = this._displayedFeatures(this._config);
const hasImage = Boolean(imageUrl);
@@ -336,28 +341,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
}
</ha-tile-info>
${
features.inline.length > 0
? html`
<hui-card-features
slot="features-inline"
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.inline}
></hui-card-features>
`
: nothing
}
${
features.below.length > 0
features.length > 0
? html`
<hui-card-features
slot="features"
.columns=${features.columns}
.hass=${this.hass}
.context=${this._featureContext}
.color=${this._config.color}
.features=${features.below}
.features=${features}
></hui-card-features>
`
: nothing
@@ -6,7 +6,6 @@ import { classMap } from "lit/directives/class-map";
import "../../../../components/ha-card";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
formatFlowRateShort,
getEnergyDataCollection,
getFlowRateFromState,
@@ -242,13 +241,6 @@ class HuiWaterFlowSankeyCard
}
}
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data.statsMetadata,
"stat_rate"
);
const {
deviceNodes,
parentLinks,
@@ -265,7 +257,7 @@ class HuiWaterFlowSankeyCard
initialUntracked: effectiveTotalInflow,
getId: (device) => device.stat_rate,
getValue: (id) => this._getCurrentFlowRate(id),
getLabel: (id) => deviceLabels[id] || this._getEntityLabel(id),
getLabel: (id, name) => name || this._getEntityLabel(id),
getEntityId: (id) => id,
});
links.push(...deviceLinks);
@@ -7,7 +7,6 @@ import "../../../../components/ha-card";
import "../../../../components/ha-svg-icon";
import type { EnergyData } from "../../../../data/energy";
import {
computeEnergyDeviceLabels,
getEnergyDataCollection,
validateEnergyCollectionKey,
} from "../../../../data/energy";
@@ -216,14 +215,8 @@ class HuiWaterSankeyCard
? calculateStatisticSumGrowth(this._data!.stats[statConsumption]) || 0
: 0;
const deviceLabels = computeEnergyDeviceLabels(
this.hass,
prefs.device_consumption_water,
this._data!.statsMetadata
);
const deviceLabel = (statConsumption: string) =>
deviceLabels[statConsumption] ||
const deviceLabel = (statConsumption: string, name?: string) =>
name ||
getStatisticLabel(
this.hass,
statConsumption,
@@ -181,7 +181,7 @@ export class HuiDialogEditView extends DirtyStateProviderMixin<LovelaceViewConfi
<hui-view-background-editor
.hass=${this.hass}
.config=${this._config}
@background-config-changed=${this._viewConfigChanged}
@view-config-changed=${this._viewConfigChanged}
></hui-view-background-editor>
`;
break;
@@ -2,41 +2,35 @@ import memoizeOne from "memoize-one";
import { LitElement, css, html, nothing } from "lit";
import type { PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import {
fireEvent,
type HASSDomEvent,
} from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import type {
LovelaceDashboardBackgroundConfig,
LovelaceViewBackgroundConfig,
} from "../../../../data/lovelace/config/view";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import {
isMediaSourceContentId,
resolveMediaSource,
} from "../../../../data/media_source";
export interface BackgroundConfigTarget {
background?: LovelaceDashboardBackgroundConfig;
}
@customElement("hui-view-background-editor")
export class HuiViewBackgroundEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config?: BackgroundConfigTarget;
@state() private _config!: LovelaceViewConfig;
@state({ attribute: false }) private _resolvedImage?: string;
set config(config: LovelaceViewConfig) {
this._config = config;
}
private _localizeValueCallback = (key: string) =>
this.hass.localize(key as Parameters<LocalizeFunc>[0]);
private _schema = memoizeOne(
(showSettings: boolean) =>
(localize: LocalizeFunc, showSettings: boolean) =>
[
{
name: "image",
@@ -46,7 +40,7 @@ export class HuiViewBackgroundEditor extends LitElement {
clearable: true,
image_upload: true,
hide_content_type: true,
content_id_helper: this.hass.localize(
content_id_helper: localize(
"ui.panel.lovelace.editor.card.picture.content_id_helper"
),
},
@@ -129,14 +123,14 @@ export class HuiViewBackgroundEditor extends LitElement {
] as const
);
protected updated(changedProps: PropertyValues<this>) {
protected updated(changedProps: PropertyValues) {
if (
this.config &&
this._config &&
this.hass &&
(changedProps.has("config") ||
(changedProps.has("_config") ||
(changedProps.has("hass") && !changedProps.get("hass")))
) {
const background = this._backgroundData(this.config);
const background = this._backgroundData(this._config);
this.style.setProperty(
"--picture-opacity",
`${(background.opacity ?? 100) / 100}`
@@ -162,7 +156,7 @@ export class HuiViewBackgroundEditor extends LitElement {
return nothing;
}
const background = this._backgroundData(this.config);
const background = this._backgroundData(this._config);
return html`
${
@@ -180,7 +174,7 @@ export class HuiViewBackgroundEditor extends LitElement {
<ha-form
.hass=${this.hass}
.data=${background}
.schema=${this._schema(true)}
.schema=${this._schema(this.hass.localize, true)}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
.localizeValue=${this._localizeValueCallback}
@@ -189,7 +183,7 @@ export class HuiViewBackgroundEditor extends LitElement {
}
private _backgroundData = memoizeOne(
(backgroundConfig?: BackgroundConfigTarget) => {
(backgroundConfig?: LovelaceViewConfig) => {
let background = backgroundConfig?.background;
if (typeof background === "string") {
const backgroundUrl = background.match(
@@ -226,15 +220,12 @@ export class HuiViewBackgroundEditor extends LitElement {
}
);
private _valueChanged(
ev: HASSDomEvent<{ value: LovelaceViewBackgroundConfig }>
) {
fireEvent(this, "background-config-changed", {
config: {
...(this.config || {}),
background: ev.detail.value,
},
});
private _valueChanged(ev: CustomEvent): void {
const config = {
...this._config,
background: ev.detail.value,
};
fireEvent(this, "view-config-changed", { config });
}
private _computeLabelCallback = (
@@ -301,10 +292,4 @@ declare global {
interface HTMLElementTagNameMap {
"hui-view-background-editor": HuiViewBackgroundEditor;
}
interface HASSDomEvents {
"background-config-changed": {
config: BackgroundConfigTarget;
};
}
}
-8
View File
@@ -1081,18 +1081,10 @@ class HUIRoot extends LitElement {
await this.hass.loadFragmentTranslation("config");
const dashboards = await fetchDashboards(this.hass);
const dashboard = dashboards.find((d) => d.url_path === urlPath);
const lovelace = this.lovelace;
const lovelaceConfig =
lovelace && !isStrategyDashboard(lovelace.rawConfig)
? lovelace.rawConfig
: undefined;
showDashboardDetailDialog(this, {
dashboard,
urlPath,
...(lovelace && lovelaceConfig
? { lovelaceConfig, saveConfig: lovelace.saveConfig }
: {}),
updateDashboard: async (values) => {
await updateDashboard(this.hass!, dashboard!.id, values);
},
@@ -0,0 +1,29 @@
/**
* Allow a column a few pixels under --column-min-width rather than dropping
* it. Subtracting wrapper padding (#53515) made 1080px viewports 8px short of
* the 3-column threshold, so span-2/3 sections stacked as a single column.
* 16px also covers a typical scrollbar without undoing 1-column sidebar
* stacking below ~720px.
*/
export const SECTION_COLUMN_FIT_TOLERANCE_PX = 16;
export const parseCssPx = (value: string): number => {
const parsed = parseFloat(value);
return Number.isFinite(parsed) ? parsed : 0;
};
export const computeSectionsColumnCount = (
totalWidth: number,
padding: number,
minColumnWidth: number,
columnGap: number
): number => {
if (totalWidth <= 0) {
return 1;
}
const columns = Math.floor(
(totalWidth - padding + columnGap + SECTION_COLUMN_FIT_TOLERANCE_PX) /
(minColumnWidth + columnGap)
);
return Math.max(1, columns);
};
+13 -9
View File
@@ -33,6 +33,10 @@ import {
import type { HuiSection } from "../sections/hui-section";
import "../sections/hui-section-background";
import type { Lovelace } from "../types";
import {
computeSectionsColumnCount,
parseCssPx,
} from "./compute-sections-column-count";
import { generateDefaultSection } from "./default-section";
import "./hui-view-footer";
import "./hui-view-header";
@@ -41,8 +45,6 @@ import { computeSectionsBackgroundAlignment } from "./sections-background-alignm
export const DEFAULT_MAX_COLUMNS = 4;
const parsePx = (value: string) => parseInt(value.replace("px", ""));
@customElement("hui-sections-view")
export class SectionsView extends LitElement implements LovelaceViewElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -93,18 +95,20 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
const container = this.shadowRoot!.querySelector(".container")!;
const containerStyle = getComputedStyle(container);
const paddingLeft = parsePx(wrapperStyle.paddingLeft);
const paddingRight = parsePx(wrapperStyle.paddingRight);
const paddingLeft = parseCssPx(wrapperStyle.paddingLeft);
const paddingRight = parseCssPx(wrapperStyle.paddingRight);
const padding = paddingLeft + paddingRight;
const minColumnWidth = parsePx(
const minColumnWidth = parseCssPx(
style.getPropertyValue("--column-min-width")
);
const columnGap = parsePx(containerStyle.columnGap);
const columnGap = parseCssPx(containerStyle.columnGap);
const columns = Math.floor(
(totalWidth - padding + columnGap) / (minColumnWidth + columnGap)
return computeSectionsColumnCount(
totalWidth,
padding,
minColumnWidth,
columnGap
);
return Math.max(columns, 1);
},
});
+1 -8
View File
@@ -6814,10 +6814,6 @@
"heading": "Connected devices",
"show_more": "+{count} devices not shown"
},
"child_devices": {
"heading": "Sub-devices",
"show_more": "+{count} devices not shown"
},
"linked_devices": {
"heading": "Linked devices",
"description": "These devices share hardware with this device and are managed by other integrations."
@@ -6899,8 +6895,6 @@
"manufacturer": "Manufacturer",
"model": "Model",
"integration": "Integration",
"parent_device": "Parent device",
"part_of_device": "Part of {name}",
"firmware_version": "Firmware",
"battery": "Battery",
"disabled_by": "Disabled",
@@ -7193,7 +7187,6 @@
"disable_error": "Enabling or disabling of the integration failed",
"manuf": "by {manufacturer}",
"via": "Connected via",
"part_of": "Part of",
"firmware": "Firmware: {version}",
"hardware": "Hardware: {version}",
"version": "Version {version}",
@@ -10472,7 +10465,7 @@
"bottom": "Bottom",
"bottom_description": "Displays all features stacked",
"inline": "Inline",
"inline_description": "Displays features in two columns, starting next to the name"
"inline_description": "Displays only the first feature"
},
"features_position_helper_vertical": "Always displayed at the bottom if the content layout is vertical",
"content_layout": "Content layout",
@@ -1,55 +0,0 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_MIN_KELVIN } from "../../../src/common/color/convert-light-color";
import { getSelectorFallbackValue } from "../../../src/components/ha-form/get-selector-fallback-value";
describe("getSelectorFallbackValue", () => {
it("returns the constant selector value", () => {
expect(getSelectorFallbackValue({ constant: { value: "fixed" } })).toBe(
"fixed"
);
expect(getSelectorFallbackValue({ constant: { value: 0 } })).toBe(0);
expect(getSelectorFallbackValue({ constant: { value: false } })).toBe(
false
);
});
it("returns false for boolean selectors", () => {
expect(getSelectorFallbackValue({ boolean: null })).toBe(false);
expect(getSelectorFallbackValue({ boolean: {} })).toBe(false);
});
it("returns number min, or 0 when min is omitted", () => {
expect(getSelectorFallbackValue({ number: { min: 2000 } })).toBe(2000);
expect(getSelectorFallbackValue({ number: { min: 0, max: 100 } })).toBe(0);
expect(getSelectorFallbackValue({ number: null })).toBe(0);
});
it("returns kelvin min for color_temp, falling back to DEFAULT_MIN_KELVIN", () => {
expect(
getSelectorFallbackValue({
color_temp: { unit: "kelvin", min: 2000, max: 6500 },
})
).toBe(2000);
expect(getSelectorFallbackValue({ color_temp: { unit: "kelvin" } })).toBe(
DEFAULT_MIN_KELVIN
);
});
it("returns mired min for color_temp, including legacy min_mireds", () => {
expect(
getSelectorFallbackValue({
color_temp: { unit: "mired", min: 154, max: 500 },
})
).toBe(154);
expect(getSelectorFallbackValue({ color_temp: { min_mireds: 160 } })).toBe(
160
);
expect(getSelectorFallbackValue({ color_temp: null })).toBe(153);
});
it("returns undefined when the selector has no displayed fallback", () => {
expect(getSelectorFallbackValue({ text: null })).toBeUndefined();
expect(getSelectorFallbackValue({ entity: null })).toBeUndefined();
expect(getSelectorFallbackValue({ target: {} })).toBeUndefined();
});
});
@@ -1,176 +0,0 @@
import { describe, expect, it } from "vitest";
import "../../../src/components/target-picker/ha-target-picker-item-row";
import type { HaTargetPickerItemRow } from "../../../src/components/target-picker/ha-target-picker-item-row";
import type { AreaRegistryEntry } from "../../../src/data/area/area_registry";
import type { DeviceRegistryEntry } from "../../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity/entity_registry";
import type {
ExtractFromTargetResult,
TargetType,
} from "../../../src/data/target";
import type { HomeAssistant } from "../../../src/types";
const extractResult = (
referenced: Partial<ExtractFromTargetResult>
): ExtractFromTargetResult => ({
missing_areas: [],
missing_devices: [],
missing_floors: [],
missing_labels: [],
referenced_areas: [],
referenced_devices: [],
referenced_entities: [],
...referenced,
});
const mkEntity = (
entity_id: string,
rest: Partial<EntityRegistryDisplayEntry> = {}
): EntityRegistryDisplayEntry => ({ entity_id, labels: [], ...rest });
const mkDevice = (
id: string,
rest: Partial<DeviceRegistryEntry> = {}
): DeviceRegistryEntry =>
({ id, area_id: null, labels: [], ...rest }) as DeviceRegistryEntry;
interface Registries {
entities?: Record<string, EntityRegistryDisplayEntry>;
devices?: Record<string, DeviceRegistryEntry>;
areas?: Record<string, AreaRegistryEntry>;
}
// Runs the row's extraction against `result`, with only the registry entries in
// `registries` available to filter it.
const extractedBy = async (
result: ExtractFromTargetResult,
registries: Registries,
{
type = "area",
itemId = "area_1",
}: { type?: TargetType; itemId?: string } = {}
) => {
const el = document.createElement(
"ha-target-picker-item-row"
) as HaTargetPickerItemRow;
el.type = type;
el.itemId = itemId;
el.hass = {
areas: {},
devices: {},
entities: {},
states: {},
callWS: async () => result,
...registries,
} as unknown as HomeAssistant;
await (el as any)._updateItemData();
return (el as any)._entries as ExtractFromTargetResult | undefined;
};
describe("ha-target-picker-item-row target extraction", () => {
it("drops entities that are missing from the entity registry", async () => {
// Disabled entities are referenced by core but never reach the display
// registry, which is the crash in #52964.
const entries = await extractedBy(
extractResult({
referenced_entities: ["light.known", "light.disabled"],
}),
{
entities: {
"light.known": mkEntity("light.known", { area_id: "area_1" }),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.known"]);
});
it("keeps every entity when all registry entries resolve", async () => {
const entries = await extractedBy(
extractResult({
referenced_entities: ["light.one", "light.two"],
}),
{
entities: {
"light.one": mkEntity("light.one", { area_id: "area_1" }),
"light.two": mkEntity("light.two", { area_id: "area_1" }),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.one", "light.two"]);
});
it("drops a device missing from the registry, and entities linked only through it", async () => {
const entries = await extractedBy(
extractResult({
referenced_devices: ["dev_known", "dev_missing"],
referenced_entities: ["light.on_known_dev", "light.on_missing_dev"],
}),
{
devices: { dev_known: mkDevice("dev_known") },
entities: {
"light.on_known_dev": mkEntity("light.on_known_dev", {
device_id: "dev_known",
}),
"light.on_missing_dev": mkEntity("light.on_missing_dev", {
device_id: "dev_missing",
}),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_devices).toEqual(["dev_known"]);
expect(entries!.referenced_entities).toEqual(["light.on_known_dev"]);
});
it("keeps an entity targeted through its own area when its device is missing", async () => {
// A device we do not know about is not a filter decision, so it must not
// take an entity that the area targets directly down with it.
const entries = await extractedBy(
extractResult({
referenced_devices: ["dev_missing"],
referenced_entities: ["light.explicit_area"],
}),
{
entities: {
"light.explicit_area": mkEntity("light.explicit_area", {
area_id: "area_1",
device_id: "dev_missing",
}),
},
}
);
expect(entries).toBeDefined();
expect(entries!.referenced_entities).toEqual(["light.explicit_area"]);
});
it("keeps devices of a floor whose area is missing from the registry", async () => {
// Same rule for areas: an area we do not know about must not mark itself
// hidden and drop the devices the floor references through it.
const entries = await extractedBy(
extractResult({
referenced_areas: ["area_missing"],
referenced_devices: ["dev_1"],
referenced_entities: ["light.on_dev1"],
}),
{
areas: {},
devices: { dev_1: mkDevice("dev_1", { area_id: "area_missing" }) },
entities: {
"light.on_dev1": mkEntity("light.on_dev1", { device_id: "dev_1" }),
},
},
{ type: "floor", itemId: "floor_1" }
);
expect(entries).toBeDefined();
expect(entries!.referenced_devices).toEqual(["dev_1"]);
expect(entries!.referenced_entities).toEqual(["light.on_dev1"]);
});
});
@@ -1,66 +0,0 @@
import { assert, describe, it } from "vitest";
import { devicesInEffectiveArea } from "../../src/data/device/device_registry";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
const device = (
partial: Partial<DeviceRegistryEntry> & { id: string }
): DeviceRegistryEntry =>
({
area_id: null,
parent_device_id: null,
...partial,
}) as DeviceRegistryEntry;
describe("devicesInEffectiveArea", () => {
it("includes devices with the area set", () => {
const devices = {
a: device({ id: "a", area_id: "kitchen" }),
b: device({ id: "b", area_id: "bedroom" }),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
["a"]
);
});
it("includes a child device inheriting its parent's area", () => {
const devices = {
parent: device({ id: "parent", area_id: "kitchen" }),
child: device({ id: "child", parent_device_id: "parent" }),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen")
.map((d) => d.id)
.sort(),
["child", "parent"]
);
});
it("excludes a child device with a different explicit area", () => {
const devices = {
parent: device({ id: "parent", area_id: "kitchen" }),
child: device({
id: "child",
area_id: "bedroom",
parent_device_id: "parent",
}),
};
assert.deepEqual(
devicesInEffectiveArea(devices, "kitchen").map((d) => d.id),
["parent"]
);
// ...and the child belongs to its own area instead.
assert.deepEqual(
devicesInEffectiveArea(devices, "bedroom").map((d) => d.id),
["child"]
);
});
it("excludes a child whose parent has no area", () => {
const devices = {
parent: device({ id: "parent" }),
child: device({ id: "child", parent_device_id: "parent" }),
};
assert.deepEqual(devicesInEffectiveArea(devices, "kitchen"), []);
});
});
-154
View File
@@ -13,19 +13,13 @@ import {
} from "../../src/data/translation";
import {
computeConsumptionSingle,
computeEnergyLabel,
computeEnergyDeviceLabels,
formatConsumptionShort,
calculateSolarConsumedGauge,
formatPowerShort,
getNextEnergyPeriodStart,
getEnergyDefaultPeriodStorageKey,
} from "../../src/data/energy";
import type { DeviceRegistryEntry } from "../../src/data/device/device_registry";
import type { EntityRegistryDisplayEntry } from "../../src/data/entity/entity_registry";
import type { StatisticsMetaData } from "../../src/data/recorder";
import type { HomeAssistant } from "../../src/types";
import { createMockEntityState, createMockHass } from "../fixtures/hass";
const checkConsumptionResult = (
input: {
@@ -950,151 +944,3 @@ describe("getEnergyDefaultPeriodStorageKey", () => {
);
});
});
describe("computeEnergyLabel", () => {
const ENTITY_ID = "sensor.washer_energy";
const createEntry = (
entry: Partial<EntityRegistryDisplayEntry>
): EntityRegistryDisplayEntry =>
({
entity_id: ENTITY_ID,
labels: [],
...entry,
}) as EntityRegistryDisplayEntry;
const createDevice = (
device: Partial<DeviceRegistryEntry>
): DeviceRegistryEntry =>
({ id: "device1", name_by_user: null, ...device }) as DeviceRegistryEntry;
const createHass = (
friendlyName: string,
entry?: Partial<EntityRegistryDisplayEntry>,
device?: Partial<DeviceRegistryEntry>
) =>
createMockHass(
{
[ENTITY_ID]: createMockEntityState(ENTITY_ID, "1", {
friendly_name: friendlyName,
}),
},
{
entities: entry ? { [ENTITY_ID]: createEntry(entry) } : {},
devices: device ? { device1: createDevice(device) } : {},
}
);
it("composes the device and entity name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the device name alone when the entity has no name of its own", () => {
const hass = createHass(
"Washer",
{ name: "Washer", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer");
});
it("distinguishes entities sharing a name by their device", () => {
const hass = createHass(
"Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Dishwasher" }
);
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Dishwasher Energy");
});
it("keeps a name set by the user", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, "Laundry"),
"Laundry"
);
});
it("ignores an empty name", () => {
const hass = createHass(
"Washer Energy",
{ name: "Energy", device_id: "device1" },
{ name: "Washer" }
);
assert.equal(
computeEnergyLabel(hass, ENTITY_ID, undefined, ""),
"Washer Energy"
);
});
it("falls back to the friendly name for an entity outside the registry", () => {
const hass = createHass("Washer Energy");
assert.equal(computeEnergyLabel(hass, ENTITY_ID), "Washer Energy");
});
it("uses the statistic metadata name when there is no entity", () => {
const hass = createMockHass();
assert.equal(
computeEnergyLabel(hass, "external:solar", {
statistic_id: "external:solar",
name: "Solar production",
} as StatisticsMetaData),
"Solar production"
);
});
it("falls back to the statistic id when there is nothing to name it with", () => {
const hass = createMockHass();
assert.equal(computeEnergyLabel(hass, "external:solar"), "external:solar");
});
});
describe("computeEnergyDeviceLabels", () => {
const DEVICES = [
{
stat_consumption: "sensor.washer_energy",
stat_rate: "sensor.washer_power",
},
{ stat_consumption: "sensor.heater_energy", name: "Heater" },
];
const hass = createMockHass({
"sensor.washer_energy": createMockEntityState("sensor.washer_energy", "1", {
friendly_name: "Washer Energy",
}),
"sensor.washer_power": createMockEntityState("sensor.washer_power", "5", {
friendly_name: "Washer Power",
}),
});
it("keys labels by the consumption statistic", () => {
assert.deepEqual(computeEnergyDeviceLabels(hass, DEVICES), {
"sensor.washer_energy": "Washer Energy",
"sensor.heater_energy": "Heater",
});
});
it("keys labels by the rate statistic, skipping devices without one", () => {
assert.deepEqual(
computeEnergyDeviceLabels(hass, DEVICES, undefined, "stat_rate"),
{ "sensor.washer_power": "Washer Power" }
);
});
});
+9 -32
View File
@@ -1,10 +1,9 @@
/**
* Deterministic `HomeAssistant` stub covering exactly what the chart data
* transforms read: states, registries, locale, config, localize, and entity
* state/name formatting. Everything is stable across runs.
* transforms read: states, entities, locale, config, localize, and entity
* state formatting. Everything is stable across runs.
*/
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import { computeEntityNameDisplay } from "../../src/common/entity/compute_entity_name_display";
import type { LocalizeFunc } from "../../src/common/translations/localize";
import {
DateFormat,
@@ -44,43 +43,21 @@ export const createMockEntityState = (
context: { id: "fixture", parent_id: null, user_id: null },
});
export const createMockHass = (
states: HassEntities = {},
registries: Partial<
Pick<HomeAssistant, "entities" | "devices" | "areas" | "floors">
> = {}
): HomeAssistant => {
const entities = registries.entities ?? {};
const devices = registries.devices ?? {};
const areas = registries.areas ?? {};
const floors = registries.floors ?? {};
return {
export const createMockHass = (states: HassEntities = {}): HomeAssistant =>
({
states,
entities,
devices,
areas,
floors,
entities: {},
devices: {},
areas: {},
floors: {},
config: demoConfig,
locale: mockLocale,
language: "en",
localize: mockLocalize,
translationMetadata: { translations: {} },
formatEntityState: (stateObj: HassEntity, state?: string) =>
state ?? stateObj.state,
formatEntityAttributeValue: (stateObj: HassEntity, attribute: string) =>
String(stateObj.attributes[attribute]),
formatEntityAttributeName: (_stateObj: HassEntity, attribute: string) =>
attribute,
formatEntityName: ((stateObj, name, options) =>
computeEntityNameDisplay(
stateObj,
name,
entities,
devices,
areas,
floors,
options
)) satisfies HomeAssistant["formatEntityName"],
} as unknown as HomeAssistant;
};
}) as unknown as HomeAssistant;
@@ -1,65 +0,0 @@
import { describe, expect, it } from "vitest";
import {
computeCardFeatureLayout,
computeCardFeatureRows,
} from "../../../../../src/panels/lovelace/card-features/common/feature-layout";
import type { LovelaceCardFeatureConfig } from "../../../../../src/panels/lovelace/card-features/types";
const features = (count: number): LovelaceCardFeatureConfig[] =>
Array.from({ length: count }, () => ({ type: "toggle" }) as const);
describe("computeCardFeatureLayout", () => {
it("stacks every feature in bottom position", () => {
const layout = computeCardFeatureLayout(features(3), "bottom");
expect(layout.inline).toHaveLength(0);
expect(layout.below).toHaveLength(3);
expect(layout.columns).toBe(1);
});
it("handles a missing feature list", () => {
expect(computeCardFeatureLayout(undefined, "inline")).toEqual({
inline: [],
below: [],
columns: 0,
});
});
it("puts the first feature inline and stacks the rest", () => {
const configs: LovelaceCardFeatureConfig[] = [
{ type: "toggle" },
{ type: "cover-open-close" },
{ type: "cover-position" },
];
const layout = computeCardFeatureLayout(configs, "inline");
expect(layout.inline).toEqual([configs[0]]);
expect(layout.below).toEqual([configs[1], configs[2]]);
});
it("fills no column when the inline feature is alone", () => {
expect(computeCardFeatureLayout(features(1), "inline").columns).toBe(0);
});
it("fills one column for a single feature below", () => {
expect(computeCardFeatureLayout(features(2), "inline").columns).toBe(1);
});
it("caps the features below at two columns", () => {
expect(computeCardFeatureLayout(features(5), "inline").columns).toBe(2);
});
});
describe("computeCardFeatureRows", () => {
it("counts one row per feature in bottom position", () => {
const rows = [0, 1, 2, 3].map((count) =>
computeCardFeatureRows(features(count), "bottom")
);
expect(rows).toEqual([0, 1, 2, 3]);
});
it("pairs the features below the inline one", () => {
const rows = [0, 1, 2, 3, 4, 5, 6].map((count) =>
computeCardFeatureRows(features(count), "inline")
);
expect(rows).toEqual([0, 0, 1, 1, 2, 2, 3]);
});
});
@@ -1,172 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import "../../../../src/panels/lovelace/cards/hui-tile-card";
import type { LovelaceCardFeatureConfig } from "../../../../src/panels/lovelace/card-features/types";
import type {
LovelaceCard,
LovelaceGridOptions,
} from "../../../../src/panels/lovelace/types";
import type { TileCardConfig } from "../../../../src/panels/lovelace/cards/types";
// getCardSize() and getGridOptions() drive how much space the tile card claims
// in masonry and sections views. In "inline" mode the first feature shares the
// name row and the remaining features are laid out two per row below it, so the
// counting differs from "bottom" mode; these tests pin that arithmetic down.
// Bundler-defined globals the card's import graph reads at eval time.
vi.hoisted(() => {
Object.assign(globalThis, {
__STATIC_PATH__: "/",
__HASS_URL__: "",
__BUILD__: "modern",
__VERSION__: "test",
__BACKWARDS_COMPAT__: false,
__SUPERVISOR__: false,
__NAMESPACE__: "frontend",
});
});
const features = (count: number): LovelaceCardFeatureConfig[] =>
Array.from(
{ length: count },
() => ({ type: "toggle" }) as LovelaceCardFeatureConfig
);
const makeCard = (config: Partial<TileCardConfig>): LovelaceCard => {
const card = document.createElement("hui-tile-card") as LovelaceCard;
card.setConfig({
type: "tile",
entity: "light.test",
...config,
} as TileCardConfig);
return card;
};
describe("hui-tile-card getCardSize", () => {
it("is 1 for a bare tile", () => {
expect(makeCard({}).getCardSize()).toBe(1);
});
it("adds a row for the vertical layout", () => {
expect(makeCard({ vertical: true }).getCardSize()).toBe(2);
});
it("counts every feature in bottom mode", () => {
expect(
makeCard({
features_position: "bottom",
features: features(3),
}).getCardSize()
).toBe(4);
});
it("pairs the features below the inline one", () => {
const sizes = [1, 2, 3, 4, 5, 6].map((count) =>
makeCard({
features_position: "inline",
features: features(count),
}).getCardSize()
);
expect(sizes).toEqual([1, 2, 2, 3, 3, 4]);
});
it("does not add rows for a single inline feature", () => {
expect(
makeCard({
features_position: "inline",
features: features(1),
}).getCardSize()
).toBe(1);
});
it("does not add rows for inline mode with no features", () => {
expect(
makeCard({
features_position: "inline",
features: features(0),
}).getCardSize()
).toBe(1);
});
it("ignores inline mode when the layout is vertical", () => {
// vertical forces bottom positioning, so all features are stacked
expect(
makeCard({
vertical: true,
features_position: "inline",
features: features(2),
}).getCardSize()
).toBe(4);
});
});
describe("hui-tile-card getGridOptions", () => {
const gridOptions = (config: Partial<TileCardConfig>): LovelaceGridOptions =>
makeCard(config).getGridOptions!();
it("is a single 6-wide row for a bare tile", () => {
expect(gridOptions({})).toEqual({
columns: 6,
rows: 1,
min_columns: 6,
min_rows: 1,
});
});
it("adds one row per feature in bottom mode", () => {
expect(
gridOptions({ features_position: "bottom", features: features(3) })
).toEqual({
columns: 6,
rows: 4,
min_columns: 6,
min_rows: 4,
});
});
it("widens to 12 columns and pairs the extra features in inline mode", () => {
expect(
gridOptions({ features_position: "inline", features: features(3) })
).toEqual({
columns: 6,
rows: 2,
min_columns: 12,
min_rows: 2,
});
});
it("adds a row per pair of features below the inline one", () => {
const rows = [1, 2, 3, 4, 5, 6].map(
(count) =>
gridOptions({ features_position: "inline", features: features(count) })
.rows
);
expect(rows).toEqual([1, 2, 2, 3, 3, 4]);
});
it("keeps a single row for one inline feature", () => {
expect(
gridOptions({ features_position: "inline", features: features(1) })
).toEqual({
columns: 6,
rows: 1,
min_columns: 12,
min_rows: 1,
});
});
it("stacks all features and narrows columns in vertical mode", () => {
// vertical forces bottom positioning and adds its own row
expect(
gridOptions({
vertical: true,
features_position: "inline",
features: features(2),
})
).toEqual({
columns: 6,
rows: 4,
min_columns: 3,
min_rows: 4,
});
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import {
computeSectionsColumnCount,
parseCssPx,
} from "../../../../src/panels/lovelace/views/compute-sections-column-count";
// Defaults from hui-sections-view: --column-min-width 320px, --column-gap 32px,
// wrapper padding 0 var(--column-gap) → 64px.
const MIN_COLUMN_WIDTH = 320;
const COLUMN_GAP = 32;
const PADDING = 64;
const columnsFor = (totalWidth: number) =>
computeSectionsColumnCount(totalWidth, PADDING, MIN_COLUMN_WIDTH, COLUMN_GAP);
describe("parseCssPx", () => {
it("parses integer pixel values", () => {
expect(parseCssPx("32px")).toBe(32);
});
it("parses fractional pixel values from zoom", () => {
expect(parseCssPx("31.68px")).toBe(31.68);
});
it("returns 0 for empty or non-numeric values", () => {
expect(parseCssPx("")).toBe(0);
expect(parseCssPx("auto")).toBe(0);
});
});
describe("computeSectionsColumnCount", () => {
it("returns 3 columns at 1080px (kiosk width just under the exact fit)", () => {
expect(columnsFor(1080)).toBe(3);
});
it("returns 3 columns at the exact 3-column fit of 1088px", () => {
expect(columnsFor(1088)).toBe(3);
});
it("returns 1 column at 670px so the sidebar still stacks", () => {
expect(columnsFor(670)).toBe(1);
});
it("returns 1 column when width is missing or zero", () => {
expect(columnsFor(0)).toBe(1);
expect(
computeSectionsColumnCount(-10, PADDING, MIN_COLUMN_WIDTH, COLUMN_GAP)
).toBe(1);
});
it("still returns 3 columns with zoom-scaled fractional CSS pixels", () => {
const minColumnWidth = parseCssPx("316.8px");
const columnGap = parseCssPx("31.68px");
const padding = parseCssPx("31.68px") + parseCssPx("31.68px");
expect(
computeSectionsColumnCount(1080, padding, minColumnWidth, columnGap)
).toBe(3);
});
});
+73 -73
View File
@@ -5771,105 +5771,105 @@ __metadata:
languageName: node
linkType: hard
"@typescript-eslint/eslint-plugin@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.67.0"
"@typescript-eslint/eslint-plugin@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/eslint-plugin@npm:8.66.0"
dependencies:
"@eslint-community/regexpp": "npm:^4.12.2"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/type-utils": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/type-utils": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
ignore: "npm:^7.0.5"
natural-compare: "npm:^1.4.0"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
"@typescript-eslint/parser": ^8.67.0
"@typescript-eslint/parser": ^8.66.0
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/d53f9e51c6be98ff4cdcb94de24e2951f5e2b7a1dfc56c4f8a2d81e61346c511c0478117626d9cfd5d5d63f5e7a0e0653dc1abe1e7d926a3f9806aaf1fa11bd2
checksum: 10/bb144ff0c27592b6a53d964fe1e2145dd0737f5fe50fe7dcd88217ca5ac4e470d7965d97745a63de16252ac2880d00732a472b9cec14d7d6929ebda97e7bcbb1
languageName: node
linkType: hard
"@typescript-eslint/parser@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/parser@npm:8.67.0"
"@typescript-eslint/parser@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/parser@npm:8.66.0"
dependencies:
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
debug: "npm:^4.4.3"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/fa156ade066d0886ea0daa0f872940cdb450a2b5dbd1687e0df64ec19aac86711dde51e17e6b20e72387aa38daec811736821e9db950e8557affa7faea63d65c
checksum: 10/a0451abe17d2eeff6ff2e1bf637d5ea19b400378d98f4511672e0f30dc5a7971d03352e32764927176c3632f245d5f85e8b28a0a3e66d020effcf0c61624e9b6
languageName: node
linkType: hard
"@typescript-eslint/project-service@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/project-service@npm:8.67.0"
"@typescript-eslint/project-service@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/project-service@npm:8.66.0"
dependencies:
"@typescript-eslint/tsconfig-utils": "npm:^8.67.0"
"@typescript-eslint/types": "npm:^8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:^8.66.0"
"@typescript-eslint/types": "npm:^8.66.0"
debug: "npm:^4.4.3"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/3beccdee1e74060eac4e6c5e7edd14a800c8c98e3313dbfe5838a357e83babc22c41c3ebe637c331c4b93db2da50f5479ccf4677db7b6b1a760f3cd27795b36f
checksum: 10/e8a71f69ee7f4bd9ca1c6a0a5110361b9927d17df2f41cdf6d042203b49aabfbddece79dec7882fde8dc016b6278671a2ba3f7bf08e5ae241582627b68be3a8b
languageName: node
linkType: hard
"@typescript-eslint/scope-manager@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/scope-manager@npm:8.67.0"
"@typescript-eslint/scope-manager@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/scope-manager@npm:8.66.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
checksum: 10/a0ca8d52e6c5b2538f7df860acd5a36437cce8e6d5bcededdfde04c02489f4b523cb178b502c266d138cff6758ea375ebe311b2b6af373b127ded4876a697ffb
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
checksum: 10/f2024cd2819dc3b967f32089ea1a0b9099acabf12860699432f0ba802d75b01ed35ea488a50576cae544813d5cbe9bd9ee5815bc4f11cefc89fe2fb24559238f
languageName: node
linkType: hard
"@typescript-eslint/tsconfig-utils@npm:8.67.0, @typescript-eslint/tsconfig-utils@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.67.0"
"@typescript-eslint/tsconfig-utils@npm:8.66.0, @typescript-eslint/tsconfig-utils@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/tsconfig-utils@npm:8.66.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/736d0ab8a273b4e4e17d412b390386eec0171cd3f7ba451bacd92c4f931051bba2049e040fdae84603ea0e07430afb05b49c6d8b6d429f9012d3c6b03eb69f54
checksum: 10/271b28660edc9e6272ae07a91f8da2c69e8a6b009e8588f1009c485c11fe4e66e09356d50dbeabf8e6a44c95c8adeb630f1b6089bdd68aec2512d0036cef7704
languageName: node
linkType: hard
"@typescript-eslint/type-utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/type-utils@npm:8.67.0"
"@typescript-eslint/type-utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/type-utils@npm:8.66.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
debug: "npm:^4.4.3"
ts-api-utils: "npm:^2.5.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/a0b943e5a8f3c2e8490ec28599eb94561d7ad029fcf42f2e9a0e8d2685ce625898371cd0a7d3a906fe8b4077d72fee8329cad512a5b0521bc2167f7d88a984fb
checksum: 10/6d3c55a591b96cbac4ac845ad1d281bac54df12a631ca550370d266aed30760fa9d498e44b938e454941930e3decda820c6e544e0748254c5089425d120e1ed1
languageName: node
linkType: hard
"@typescript-eslint/types@npm:8.67.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/types@npm:8.67.0"
checksum: 10/8edc1a14a52c7566409c19b34fb72ba3a62a3b409c98b1d496de9fcd83179fb128c8df2fde391c199316a54cdc5d314ba70ce7b440161dd850b73a44f3e54e6d
"@typescript-eslint/types@npm:8.66.0, @typescript-eslint/types@npm:^8.56.0, @typescript-eslint/types@npm:^8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/types@npm:8.66.0"
checksum: 10/de1ea79e7056c11e38e4001a899b88511793a0aa1702508af42ee179bc7303bed2eb02f177a7ae438f20eab00aee9407c7e842bec6fc5b7bb1fdcab4a7cd5c4b
languageName: node
linkType: hard
"@typescript-eslint/typescript-estree@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/typescript-estree@npm:8.67.0"
"@typescript-eslint/typescript-estree@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/typescript-estree@npm:8.66.0"
dependencies:
"@typescript-eslint/project-service": "npm:8.67.0"
"@typescript-eslint/tsconfig-utils": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/visitor-keys": "npm:8.67.0"
"@typescript-eslint/project-service": "npm:8.66.0"
"@typescript-eslint/tsconfig-utils": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/visitor-keys": "npm:8.66.0"
debug: "npm:^4.4.3"
minimatch: "npm:^10.2.2"
semver: "npm:^7.7.3"
@@ -5877,32 +5877,32 @@ __metadata:
ts-api-utils: "npm:^2.5.0"
peerDependencies:
typescript: ">=4.8.4 <6.1.0"
checksum: 10/31c5be812f67c997b9d1e9324c4289b6268a12e9948492403fdd9c9b5df12dcc00f845ae93ae37c78f717583519060175f2147f165b65112eef1e40acf44ad1d
checksum: 10/0834efcdd4468c0dd954089daf26b40175e204402bd4075b06cd910786bb6de7a15aec4e79e40f0980f39fce5bfd9c871ec8fcc6871a8895a34a273bb187fd26
languageName: node
linkType: hard
"@typescript-eslint/utils@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/utils@npm:8.67.0"
"@typescript-eslint/utils@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/utils@npm:8.66.0"
dependencies:
"@eslint-community/eslint-utils": "npm:^4.9.1"
"@typescript-eslint/scope-manager": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/scope-manager": "npm:8.66.0"
"@typescript-eslint/types": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/8703468cdea3630275c6faacc031e7e4bd7fb8f8988e80ab987811ae3c63a9a9677496cee2b4811f49cb9bc9e6f6c0bdb91cb213353019ce31df0a2b351cea51
checksum: 10/e7a24ee0f35d58b4392daa03fb1ea37ab7e4519949a034be89151df1b0e9fc416831f50cb69f54150272e252cba350964632ee9bfbdb7e62ac599cc731de56e1
languageName: node
linkType: hard
"@typescript-eslint/visitor-keys@npm:8.67.0":
version: 8.67.0
resolution: "@typescript-eslint/visitor-keys@npm:8.67.0"
"@typescript-eslint/visitor-keys@npm:8.66.0":
version: 8.66.0
resolution: "@typescript-eslint/visitor-keys@npm:8.66.0"
dependencies:
"@typescript-eslint/types": "npm:8.67.0"
"@typescript-eslint/types": "npm:8.66.0"
eslint-visitor-keys: "npm:^5.0.0"
checksum: 10/c92c9b202105a8f398ab2ad4be58c8c8fd9af6b5ddd28cdcd57d75f7f04f69561a56a05aac9a008668c64faa7222ebb3fefe19097923a18eb2b9f7a90809a5be
checksum: 10/d47e936b10ec94a96f69f77a62463f4634fe72d12d42dd302d7777f7b98689468e0b67c538a52232b723695208f1b3fdeae5745eb3e71a70a90818fdd827a0bc
languageName: node
linkType: hard
@@ -10040,7 +10040,7 @@ __metadata:
tinykeys: "patch:tinykeys@npm%3A4.0.0#~/.yarn/patches/tinykeys-npm-4.0.0-a6ca3fd771.patch"
ts-lit-plugin: "npm:2.0.2"
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.67.0"
typescript-eslint: "npm:8.66.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
webpack-stats-plugin: "npm:1.1.3"
@@ -14892,18 +14892,18 @@ __metadata:
languageName: node
linkType: hard
"typescript-eslint@npm:8.67.0":
version: 8.67.0
resolution: "typescript-eslint@npm:8.67.0"
"typescript-eslint@npm:8.66.0":
version: 8.66.0
resolution: "typescript-eslint@npm:8.66.0"
dependencies:
"@typescript-eslint/eslint-plugin": "npm:8.67.0"
"@typescript-eslint/parser": "npm:8.67.0"
"@typescript-eslint/typescript-estree": "npm:8.67.0"
"@typescript-eslint/utils": "npm:8.67.0"
"@typescript-eslint/eslint-plugin": "npm:8.66.0"
"@typescript-eslint/parser": "npm:8.66.0"
"@typescript-eslint/typescript-estree": "npm:8.66.0"
"@typescript-eslint/utils": "npm:8.66.0"
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: ">=4.8.4 <6.1.0"
checksum: 10/4efc2cea5b067f65d58e010fda3888ec94f2e67ebfa289be78c3a02458787b7125cf8f6095776b912005fb69e8748ad4018777b16436a0658957c163ef213bf9
checksum: 10/e76da9c684e4fe012b49a5120dd57553df0788cf1cffcbd5dc72c98de33e549f306495e710292f111ee058366b1e5d8d603c2bb14385b12bb35edc7bbf13ad55
languageName: node
linkType: hard