Compare commits

...

9 Commits

Author SHA1 Message Date
Aidan Timson
31bca796cf Filter all datapoints for integration page (discovery, attention flows) 2026-05-08 09:50:57 +01:00
Wendelin
7bb51c746d Allow ha-list-items within sub components shadow DOM (#51907)
* Allow ha-list-items within sub components shadow dom.

* fix sort

* Fix start end slots
2026-05-08 09:24:13 +03:00
Tom Carpenter
13e32c41e0 Round bar chart end time to half-hour mark for hourly periods (#51916)
* Don't round bar chart end time for hourly periods

If we do this, it causes the last hour of the energy dashboard bar charts to be cut off. This went unnoticed previously because they were placed at times of xx:00, while now they are times of xx:30.

* Round to 30minute for hourly bars rather than leaving unrounded
This better matches the axes with line charts by cutting off padding into the next day, whilst leaving mid-point bars visible.

* Update tests to account for new behaviour
2026-05-08 08:51:27 +03:00
Tom Carpenter
d89af52e3b Fix type exception in ha-chart-base _updateSankeyRoam() (#51917)
Fix type exception in chart _updateSankeyRoam

When there is no data for some series in the sankey chart, then the series map can contain null entries. This raised an exception as the _updateSankeyRoam tried to access the 'type' property on a null value.

Add an explicit check for null. Using != not !== to also filter undefined in case that ever shows up.
2026-05-08 08:48:24 +03:00
Simon Lamon
da6114fa5f Deduplicate workbox by updating patch (#51919)
Deduplicate by updating patch
2026-05-08 08:47:25 +03:00
renovate[bot]
c144533834 Update workbox monorepo to v7.4.1 (#51918)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 04:54:50 +00:00
renovate[bot]
e6c6ab93ef Update dependency typescript-eslint to v8.59.2 (#51914)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:46:15 +02:00
renovate[bot]
62df56e5d9 Update dependency barcode-detector to v3.1.3 (#51913)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 06:46:00 +02:00
George Caliment
d169eb9c49 Fix ResizeObserver loop on firefox browser (#51897)
* Fix ResizeObserver loop on firefox browser

* Replace requestAnimationFrame workaround
2026-05-07 15:46:04 +03:00
14 changed files with 742 additions and 411 deletions

View File

@@ -80,7 +80,7 @@
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"barcode-detector": "3.1.2",
"barcode-detector": "3.1.3",
"cally": "0.9.2",
"color-name": "2.1.0",
"comlink": "4.4.2",
@@ -121,12 +121,12 @@
"superstruct": "2.0.2",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.0",
"workbox-core": "7.4.0",
"workbox-expiration": "7.4.0",
"workbox-precaching": "7.4.0",
"workbox-routing": "7.4.0",
"workbox-strategies": "7.4.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
"workbox-expiration": "7.4.1",
"workbox-precaching": "7.4.1",
"workbox-routing": "7.4.1",
"workbox-strategies": "7.4.1",
"xss": "1.0.15"
},
"devDependencies": {
@@ -200,12 +200,12 @@
"terser-webpack-plugin": "5.5.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.1",
"typescript-eslint": "8.59.2",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.5",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.0#~/.yarn/patches/workbox-build-npm-7.4.0-c84561662c.patch"
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
},
"resolutions": {
"lit": "3.3.2",

View File

@@ -0,0 +1,54 @@
/**
* Walks up the composed tree (jumping shadow roots → their hosts), returning
* the ancestor chain top-down. Used to compare two nodes that may live in
* different shadow trees — `Node.compareDocumentPosition` only works within a
* single root and returns `DOCUMENT_POSITION_DISCONNECTED` otherwise.
*/
const composedAncestorPath = (node: Node): Node[] => {
const path: Node[] = [];
let cur: Node | null = node;
while (cur) {
path.push(cur);
const parent = cur.parentNode;
if (parent instanceof ShadowRoot) {
cur = parent.host;
} else if (parent) {
cur = parent;
} else {
const root = cur.getRootNode();
cur = root instanceof ShadowRoot ? root.host : null;
}
}
return path.reverse();
};
/**
* Document-order comparator that works across shadow boundaries. Suitable as
* the `Array.prototype.sort` callback for collections of nodes that may live
* in different shadow trees.
*/
export const compareNodeOrder = (a: Node, b: Node): number => {
if (a === b) {
return 0;
}
const pa = composedAncestorPath(a);
const pb = composedAncestorPath(b);
let i = 0;
while (i < pa.length && i < pb.length && pa[i] === pb[i]) {
i++;
}
if (i === 0) {
return 0;
}
if (i === pa.length) {
return -1;
}
if (i === pb.length) {
return 1;
}
// pa[i] and pb[i] are siblings under the LCA, guaranteed same root.
// eslint-disable-next-line no-bitwise
return pa[i].compareDocumentPosition(pb[i]) & Node.DOCUMENT_POSITION_FOLLOWING
? -1
: 1;
};

View File

@@ -1110,7 +1110,7 @@ export class HaChartBase extends LitElement {
private _updateSankeyRoam() {
const option = this.chart?.getOption();
const sankeySeries = (option?.series as any[])?.filter(
(s: any) => s.type === "sankey"
(s: any) => s != null && s.type === "sankey"
);
if (sankeySeries?.length) {
this.chart?.setOption({

View File

@@ -1,6 +1,8 @@
import type { CSSResultGroup } from "lit";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import "../list/types";
import { HaRowItem } from "./ha-row-item";
/**
@@ -39,6 +41,12 @@ export class HaListItemBase extends HaRowItem {
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
fireEvent(this, "ha-list-item-register", { item: this });
}
public disconnectedCallback(): void {
super.disconnectedCallback();
fireEvent(this, "ha-list-item-unregister", { item: this });
}
/**

View File

@@ -62,26 +62,20 @@ export class HaRowItem extends LitElement {
}
protected _renderInner(): TemplateResult {
const hasStart = this._slotController.test("start");
const hasEnd = this._slotController.test("end");
const hasContent = this._slotController.test("content");
return html`
${hasStart
? html`<div part="start" class="start">
<slot name="start"></slot>
</div>`
: nothing}
<div part="start" class="start">
<slot name="start"></slot>
</div>
<div part="content" class="content">
${hasContent
? html`<slot name="content"></slot>`
: this._renderDefaultContent()}
</div>
${hasEnd
? html`<div part="end" class="end">
<slot name="end"></slot>
</div>`
: nothing}
<div part="end" class="end">
<slot name="end"></slot>
</div>
`;
}
@@ -148,6 +142,10 @@ export class HaRowItem extends LitElement {
align-items: center;
flex: 0 0 auto;
}
:host(:not(:has([slot="start"]))) .start,
:host(:not(:has([slot="end"]))) .end {
display: none;
}
.headline {
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -1,10 +1,12 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemBase } from "../item/ha-list-item-base";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import type { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
/**
* @element ha-list-base
@@ -12,9 +14,11 @@ import "./types";
*
* @summary
* Base list container with roving-tabindex keyboard navigation (ArrowUp/Down,
* Home/End, optional Enter/Space activation, optional wrap-focus). Discovers
* slotted `HaListItemBase` descendants. Subclasses override `hostRole` and/or
* `render()` to specialize.
* Home/End, optional Enter/Space activation, optional wrap-focus). Tracks
* `HaListItemBase` descendants via the `ha-list-item-register` /
* `ha-list-item-unregister` events they fire on connect/disconnect — works
* across any nesting depth and shadow boundaries. Subclasses override
* `hostRole` and/or `render()` to specialize.
*
* @slot - List items (`<ha-list-item-*>`).
*
@@ -68,6 +72,14 @@ export class HaListBase extends LitElement {
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public disconnectedCallback() {
@@ -75,11 +87,14 @@ export class HaListBase extends LitElement {
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
}
public firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
this.updateListItems();
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
);
}
public focus(options?: FocusOptions) {
@@ -115,18 +130,14 @@ export class HaListBase extends LitElement {
this._applyActive(focusItem);
}
/**
* Hook called whenever the items array has changed. Subclasses can override
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
const next = this._discoverListItems();
const changed =
next.length !== this.items.length ||
next.some((it, i) => it !== this.items[i]);
if (!changed) {
return;
}
this.items = next;
this._recomputeFocusableIndexes();
if (
this._activeItemIndex >= next.length ||
this._activeItemIndex >= this.items.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
@@ -135,6 +146,32 @@ export class HaListBase extends LitElement {
this._applyActive(false);
}
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (this.items.includes(item)) {
return;
}
const next = [...this.items, item];
next.sort(compareNodeOrder);
this.items = next;
this.updateListItems();
};
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
const item = ev.detail.item;
if (!this.items.includes(item)) {
return;
}
this.items = this.items.filter((it) => it !== item);
this.updateListItems();
};
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
@@ -151,27 +188,12 @@ export class HaListBase extends LitElement {
this._hasFocusableItem = first !== -1;
}
public handleSlotChange = () => {
this.updateListItems();
};
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot @slotchange=${this.handleSlotChange}></slot>
<slot></slot>
</div>`;
}
private _discoverListItems(): HaListItemBase[] {
const slot =
this.renderRoot?.querySelector<HTMLSlotElement>("slot:not([name])");
if (!slot) {
return [];
}
return slot
.assignedElements({ flatten: true })
.filter((el): el is HaListItemBase => el instanceof HaListItemBase);
}
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;

View File

@@ -31,7 +31,7 @@ export class HaListNav extends HaListBase {
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<slot @slotchange=${this.handleSlotChange}></slot>
<slot></slot>
</div>
</nav>`;
}

View File

@@ -11,9 +11,15 @@ export interface HaListActivatedDetail {
item: HaListItemBase;
}
export interface HaListItemRegistrationDetail {
item: HaListItemBase;
}
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
}
}

View File

@@ -344,6 +344,14 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._filter,
this.hass.areas
);
const filteredDiscoveryData = this._filterDiscoveryTree(
discoveryFlows,
this._filter
);
const filteredAttentionFlows = this._filterDiscoveryTree(
attentionFlows,
this._filter
);
const filteredAttentionData = this._filterAttentionTree(
attentionData,
this._filter,
@@ -692,7 +700,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</ha-alert>
</div>`
: nothing}
${discoveryFlows.length
${filteredDiscoveryData.length
? html`
<div class="section">
<h3 class="section-header">
@@ -701,7 +709,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
)}
</h3>
<ha-md-list class="discovered">
${discoveryFlows.map(
${filteredDiscoveryData.map(
(flow) =>
html`<ha-md-list-item class="discovered">
${flow.localized_title}
@@ -720,7 +728,7 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
</div>
`
: nothing}
${attentionFlows.length || filteredAttentionData.length
${filteredAttentionFlows.length || filteredAttentionData.length
? html`
<div class="section">
<h3 class="section-header">
@@ -728,9 +736,9 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
`ui.panel.config.integrations.integration_page.attention_entries`
)}
</h3>
${attentionFlows.length
${filteredAttentionFlows.length
? html`<ha-md-list class="attention">
${attentionFlows.map((flow) => {
${filteredAttentionFlows.map((flow) => {
const attention = ATTENTION_SOURCES.includes(
flow.context.source
);
@@ -1089,6 +1097,28 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
this._filterTree(data, filter, areas)
);
private _filterDiscoveryTree = memoizeOne(
(
data: DataEntryFlowProgressExtended[],
filter: string
): DataEntryFlowProgressExtended[] => {
if (!filter) {
return data;
}
const TITLE_KEYS = ["localized_title"];
const titleMatches = (localized_title: string) =>
multiTermSearch([{ localized_title }], filter, TITLE_KEYS, undefined, {
keys: TITLE_KEYS,
}).length > 0;
return data.filter(
(item) => item.localized_title && titleMatches(item.localized_title)
);
}
);
private _filterAttentionTree = memoizeOne(
(data: ConfigEntryData[], filter: string, areas: HomeAssistant["areas"]) =>
this._filterTree(data, filter, areas)

View File

@@ -60,17 +60,21 @@ export function getSuggestedMax(
noRounding: boolean
): Date {
// Maximum period depends on whether plotting a line chart or discrete bars.
// - For line charts we must be plotting all the way to end of a given period,
// otherwise we cut off the last period of data.
// - For bar charts we need to round down to the start of the final bars period
// to avoid unnecessary padding of the chart.
// - For line charts use noRounding true as we must always plot all the way
// to end of a given period, otherwise we cut off the last period of data.
// - For bar charts with 5minute intervals, leave the full time range
// to ensure we don't cut off any bars
// - For bar charts of hourly intervals, round to half-period to avoid excess
// padding but not cut off the final bar if placed mid interval.
// - For bar charts with whole numbers of days we need to round down to the
// start of the final bars period to avoid unnecessary padding of the chart.
let suggestedMax = new Date(end);
if (noRounding || period === "5minute") {
return suggestedMax;
}
suggestedMax.setMinutes(0, 0, 0);
if (period === "hour") {
suggestedMax.setMinutes(30, 0, 0);
return suggestedMax;
}
// Sometimes around DST we get a time of 0:59 instead of 23:59 as expected.
@@ -78,7 +82,7 @@ export function getSuggestedMax(
if (suggestedMax.getHours() === 0) {
suggestedMax = subHours(suggestedMax, 1);
}
suggestedMax.setHours(0);
suggestedMax.setHours(0, 0, 0, 0);
if (period === "day" || period === "week") {
return suggestedMax;
}

View File

@@ -97,10 +97,10 @@ export class HuiHeadingCard extends LitElement implements LovelaceCard {
if (this._observedBadges) {
this._resizeObserver.observe(this._observedBadges);
} else {
this._badgesOverflowing = false;
}
}
this._measureBadgesOverflow();
}
public disconnectedCallback(): void {

View File

@@ -31,10 +31,10 @@ describe("getSuggestedMax", () => {
assert.equal(result.getTime(), end.getTime());
});
it("rounds down to start of hour for hour period", () => {
it("rounds down to middle of hour for hour period", () => {
const end = new Date("2024-03-15T14:37:22.000");
const result = getSuggestedMax("hour", end, false);
assert.equal(result.getMinutes(), 0);
assert.equal(result.getMinutes(), 30);
assert.equal(result.getSeconds(), 0);
assert.equal(result.getMilliseconds(), 0);
assert.equal(result.getHours(), 14);

883
yarn.lock

File diff suppressed because it is too large Load Diff