mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-08 10:23:13 +00:00
Compare commits
9 Commits
fix-3383
...
integratio
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31bca796cf | ||
|
|
7bb51c746d | ||
|
|
13e32c41e0 | ||
|
|
d89af52e3b | ||
|
|
da6114fa5f | ||
|
|
c144533834 | ||
|
|
e6c6ab93ef | ||
|
|
62df56e5d9 | ||
|
|
d169eb9c49 |
18
package.json
18
package.json
@@ -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",
|
||||
|
||||
54
src/common/dom/compare-node-order.ts
Normal file
54
src/common/dom/compare-node-order.ts
Normal 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;
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user