Compare commits

...
Author SHA1 Message Date
Paul Bottein c350d46d44 Keep range selection on release outside the chart and simplify the highlight 2026-10-29 17:12:46 +01:00
Paul Bottein e5a43b48c2 Add range selection and highlight to charts 2026-10-29 17:12:46 +01:00
Paul Bottein 5f11dc4580 Replace hide-zoom-controls with a zoom slider mode on charts (#55468) 2026-10-29 17:12:40 +01:00
Simon Lamon 27489f188e date-range-picker migrate list (#55471) 2026-10-29 17:57:24 +02:00
Simon Lamon 97fffc3b43 ha-config-person migrate list (#55473) 2026-10-29 17:56:23 +02:00
renovate[bot] a30193b8ef Update dependency marked to v18.1.2 (#55470)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 17:50:42 +02:00
Aidan Timson cf063dc19f Update the PR template for the contributors docs (#55459) 2026-10-29 17:05:36 +02:00
Simon Lamon 5db829b7be pick-auth-provider migrate list (#55462) 2026-10-29 17:04:51 +02:00
Maarten Lakerveld 421f8b1986 Load the legacy Intl polyfills once per page and bundle PluralRules with its data (#55452)
Each legacy entry bundle carries its own copy of intl-polyfill.ts, so the
module-level guard only stopped re-runs within one bundle. With the bundles
loading in parallel, the second bundle could probe Intl.PluralRules after the
first had installed the polyfill but before its locale data chunk arrived. The
FormatJS should-polyfill probe calls select(), which throws on a data-less
polyfill, rejecting the top-level await so the entry never evaluated. On the
companion app this surfaces as ExternalBusTimeout on old WebViews.

Share one promise per page on globalThis so every bundle waits for the same
run, and import the PluralRules polyfill and its English data from one module
so Intl.PluralRules is never installed without data.
2026-10-29 17:02:23 +02:00
Simon Lamon a140eedc6e Clean up ha-radio-list-item (#55454) 2026-10-29 17:00:59 +02:00
renovate[bot] 2933568658 Update dependency home-assistant-js-websocket to v9.8.1 (#55450)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-29 16:59:26 +02:00
Simon Lamon 29faeaf7fa Remove incorrect type usage from date-range-picker (#55460) 2026-10-29 16:58:55 +02:00
Simon Lamon 95ba532ceb Removes incorrect type from cast (#55461) 2026-10-29 16:58:33 +02:00
Guillaume Rischard 352ecb9d6d Simplify OpenStreetMap attribution (#55447)
As per https://osmfoundation.org/wiki/Licence/Attribution_Guidelines:

> Attribution must be to “OpenStreetMap”.
[...]
> The historical forms of attribution “© OpenStreetMap contributors” or “© OpenStreetMap” are acceptable.
2026-10-29 15:27:49 +01:00
Erwin Douna 3f73049a3d Fade out energy period loading spinner (#55431) 2026-10-29 15:06:53 +01:00
Petar Petrov 50ad0c6cd0 Name new disk mounts after the partition's volume label (#55441) 2026-10-29 14:58:56 +01:00
20 changed files with 348 additions and 151 deletions

No files matched your search

+5 -3
View File
@@ -51,8 +51,8 @@
- This PR fixes or closes issue: fixes #
- This PR is related to issue or discussion:
- Link to documentation pull request:
- Link to developer documentation pull request:
- Link to [documentation][docs-repository] pull request:
- Link to [contributors documentation][contributors-repository] pull request:
- Link to backend pull request:
## Checklist
@@ -63,7 +63,8 @@
for before merging your code.
AI tools are welcome, but contributors are responsible for *fully*
understanding the code before submitting a PR.
understanding the code before submitting a PR. Please follow our AI policy:
https://contributors.home-assistant.io/start/ai-policy/
-->
- [ ] I understand the code I am submitting and can explain how it works.
@@ -105,4 +106,5 @@ To help with the load of incoming pull requests:
Below, some useful links you could explore:
-->
[docs-repository]: https://github.com/home-assistant/home-assistant.io
[contributors-repository]: https://github.com/home-assistant/contributors.home-assistant.io
[perfect-pr]: https://contributors.home-assistant.io/start/pull-requests/#before-you-open-it
+1 -2
View File
@@ -1,4 +1,3 @@
import type { ActionDetail } from "@material/mwc-list/mwc-list";
import { mdiCast, mdiCastConnected, mdiViewDashboard } from "@mdi/js";
import type { Auth, Connection } from "home-assistant-js-websocket";
import type { TemplateResult, PropertyValues } from "lit";
@@ -215,7 +214,7 @@ class HcCast extends LitElement {
this.castManager.requestSession();
}
private async _handlePickView(ev: CustomEvent<ActionDetail>) {
private async _handlePickView(ev: CustomEvent<{ index: number }>) {
const path = this.lovelaceViews?.[ev.detail.index]?.path ?? ev.detail.index;
await ensureConnectedCastSession(this.castManager!, this.auth!);
castSendShowLovelaceView(this.castManager, this.auth.data.hassUrl, path);
+2 -2
View File
@@ -101,7 +101,7 @@
"fullcalendar": "7.1.3",
"fuse.js": "7.5.0",
"hls.js": "1.7.3",
"home-assistant-js-websocket": "9.8.0",
"home-assistant-js-websocket": "9.8.1",
"idb-keyval": "6.3.0",
"intl-messageformat": "12.1.4",
"js-yaml": "5.5.0",
@@ -110,7 +110,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"maplibre-gl": "6.16.0",
"marked": "18.1.1",
"marked": "18.1.2",
"memoize-one": "6.0.0",
"nanoid": "6.0.2",
"node-vibrant": "4.0.4",
+10 -10
View File
@@ -364,8 +364,8 @@ importers:
specifier: 1.7.3
version: 1.7.3
home-assistant-js-websocket:
specifier: 9.8.0
version: 9.8.0
specifier: 9.8.1
version: 9.8.1
idb-keyval:
specifier: 6.3.0
version: 6.3.0
@@ -391,8 +391,8 @@ importers:
specifier: 6.16.0
version: 6.16.0
marked:
specifier: 18.1.1
version: 18.1.1
specifier: 18.1.2
version: 18.1.2
memoize-one:
specifier: 6.0.0
version: 6.0.0
@@ -5205,8 +5205,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-MsPlx6yVW4Qv4C7mEVou4/gk/5cN2dVxOTnrNjPSmyH2f0Ln+/UkG10Ax1PE6OvrmTVPKgQYozTtBaPOuCzq7A==}
[email protected].0:
resolution: {integrity: sha512-Nl5asoKQJwJ5a8N0U72jkSAUdO+hsY/T+Upp0VaZMUSt6A2gtdyvKfBIEYbpnYK3NP83Q/YGaC5XYkO5c08Img==}
[email protected].1:
resolution: {integrity: sha512-WvovbrSoYLTKfh6aY5SxSWPPy50/mLWLCihQL8G2N+2e3mTrg3QTQCAp4R32/LwbkRBDGj0XNs6JFj8e2IYEUw==}
[email protected]:
resolution: {integrity: sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==}
@@ -5907,8 +5907,8 @@ packages:
engines: {node: '>= 18'}
hasBin: true
[email protected].1:
resolution: {integrity: sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==}
[email protected].2:
resolution: {integrity: sha512-DfOnvLAQ6WYeWxWO1u4RyFOeqyHup6dks5PPqnoFiooU7ipRvidO/2j8plhAHIiZg574E9ZlzSjUiPMLAzFkgg==}
engines: {node: '>= 20'}
hasBin: true
@@ -12962,7 +12962,7 @@ snapshots:
[email protected]: {}
[email protected].0: {}
[email protected].1: {}
[email protected]:
dependencies:
@@ -13689,7 +13689,7 @@ snapshots:
[email protected]: {}
[email protected].1: {}
[email protected].2: {}
[email protected]: {}
+10 -12
View File
@@ -4,8 +4,8 @@ import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-icon-next";
import "../components/ha-list";
import "../components/ha-list-item";
import "../components/item/ha-list-item-button";
import "../components/list/ha-list-base";
import type { AuthProvider } from "../data/auth";
declare global {
@@ -32,21 +32,19 @@ export class HaPickAuthProvider extends LitElement {
>${this._localize("ui.panel.page-authorize.pick_auth_provider")}</span
>
</h3>
<ha-list>
<ha-list-base>
${this.authProviders.map(
(provider) => html`
<ha-list-item
hasMeta
role="button"
<ha-list-item-button
.auth_provider=${provider}
@click=${this._handlePick}
>
${provider.name}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>
<span slot="headline">${provider.name}</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -80,9 +78,9 @@ export class HaPickAuthProvider extends LitElement {
background: var(--card-background-color);
padding: 0 15px;
}
ha-list {
ha-list-base {
margin: 16px -16px 0;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: 24px;
}
`;
}
+1 -1
View File
@@ -28,7 +28,7 @@ const RASTER_TILE_URL = `${MAP_TILES_PATH}/raster/{z}/{x}/{y}.png?token={token}`
// policy strips again unless the tiles ask for it back.
const DEMO_RASTER_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const OSM_ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors';
'<a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>';
// Browsers keep about 16 live WebGL contexts and drop the oldest, which a
// dashboard full of map cards hits. A transient loss is restored, hence a grace.
+84 -9
View File
@@ -89,6 +89,9 @@ export const DEFAULT_CHART_WIDTH = 500;
// is short enough for a whole screenful; on a desktop that would cover the page.
const VISIBILITY_ROOT_MARGIN_NARROW = "100%";
const VISIBILITY_ROOT_MARGIN = "300px";
const CLICK_DRAG_TOLERANCE = 4;
export type ZoomSliderMode = "auto" | "always" | "never";
const DEFERRED_PROPS = [
"options",
"data",
@@ -163,15 +166,20 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@property({ attribute: "small-controls", type: Boolean })
public smallControls?: boolean;
// For charts zoomed from outside, like the synced History charts
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
// "auto" shows the zoom slider once zoomed. "never" hides every zoom
// control, for charts zoomed from outside like the synced History charts.
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
// For charts sized to their content, like timelines, which grow to fit the
// zoom slider instead of giving up plot height
@property({ attribute: "grow-for-zoom-slider", type: Boolean })
public growForZoomSlider = false;
// Dragging selects a time range instead of zooming
@property({ attribute: "select-range", type: Boolean })
public selectRange = false;
// extraComponents is not reactive and should not trigger updates
public extraComponents?: any[];
@@ -197,6 +205,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
@state() private _isZoomed = false;
private _pointerDownX?: number;
// Separate from _isZoomed, whose touch handling reruns graph layouts
@state() private _isGraphRoamed = false;
@@ -450,6 +460,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
if (!this.chart) {
return;
}
if (changedProps.has("selectRange")) {
this._updateSelectRange();
}
const themeChanged = changedProps.has("_themes") && this.hasUpdated;
if (!themeChanged && !DEFERRED_PROPS.some((p) => changedProps.has(p))) {
return;
@@ -617,7 +630,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
>
${
(this._isZoomed || this._isGraphRoamed) &&
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
!showZoomSlider
? html`<ha-icon-button
class="zoom-reset"
@@ -901,6 +914,32 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this.chart.on("click", (e: ECElementEvent) => {
fireEvent(this, "chart-click", e);
});
this.chart.getZr().on("mousedown", (e: ECElementEvent) => {
this._pointerDownX = e.offsetX;
});
this.chart.getZr().on("click", (e: ECElementEvent) => {
const point = [e.offsetX, e.offsetY];
// A drag that ends on the plot is not a click
const dragged =
this._pointerDownX !== undefined &&
Math.abs(e.offsetX - this._pointerDownX) > CLICK_DRAG_TOLERANCE;
if (dragged || !this.chart?.containPixel("grid", point)) {
return;
}
const [x, y] = this.chart.convertFromPixel("grid", point) as number[];
fireEvent(this, "chart-grid-click", { x, y });
});
this.chart.on("brushEnd", (e: any) => {
const range = e.areas?.[0]?.coordRange as number[] | undefined;
// The caller shows the selection, snapped to its data
this.chart?.dispatchAction({ type: "brush", areas: [] });
if (range) {
fireEvent(this, "chart-range-select", {
start: range[0],
end: range[1],
});
}
});
this.chart.on("sankeyroam", () => {
const option = this.chart!.getOption();
const series = option.series as any[];
@@ -966,6 +1005,9 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
this._legendItemCount = this._getLegendItems()?.length;
this._updateSankeyRoam();
this._updateDragToZoom();
if (this.selectRange) {
this._updateSelectRange();
}
if (zoomStart !== 0 || zoomEnd !== 100) {
this.zoom(zoomStart, zoomEnd, true);
}
@@ -1017,7 +1059,8 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// A chart can switch type (e.g. pie and bar) without being rebuilt
private _updateDragToZoom() {
const active = !this._touchInput && this._hasZoomableXAxis();
const active =
!this._touchInput && this._hasZoomableXAxis() && !this.selectRange;
if (!this.chart || active === this._dragToZoom) {
return;
}
@@ -1029,6 +1072,33 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
});
}
private _updateSelectRange() {
if (!this.chart) {
return;
}
if (this.selectRange) {
this.chart.setOption({
brush: { xAxisIndex: 0, transformable: false },
});
}
const brushCursor = {
type: "takeGlobalCursor",
key: "brush",
brushOption: {
brushType: this.selectRange ? "lineX" : false,
brushMode: "single",
},
};
// Brush and drag to zoom share the global cursor, the last one taken wins
if (this.selectRange) {
this._updateDragToZoom();
this.chart.dispatchAction(brushCursor);
} else {
this.chart.dispatchAction(brushCursor);
this._updateDragToZoom();
}
}
private _hasZoomableXAxis() {
const xAxis = ensureArray(this.options?.xAxis)?.[0] as
XAXisOption | undefined;
@@ -1064,9 +1134,12 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
private _showZoomSlider() {
const [start, end] = this._zoomRange;
return (
!this.hideZoomControls &&
this.zoomSlider !== "never" &&
this._hasZoomableXAxis() &&
(this._zoomSliderHeld || start !== 0 || end !== 100)
(this.zoomSlider === "always" ||
this._zoomSliderHeld ||
start !== 0 ||
end !== 100)
);
}
@@ -1532,7 +1605,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
}
private _handleClickZoom = (e: ECElementEvent) => {
if (!this.chart) {
if (!this.chart || this.selectRange) {
return;
}
// Handle sankey chart double-click zoom
@@ -1745,7 +1818,7 @@ export class HaChartBase extends MobileAwareMixin(LitElement) {
// it, so a quick drag out would lose the release and its selection
if (
ev.type === "pointerdown" &&
this._dragToZoom &&
(this._dragToZoom || this.selectRange) &&
this._isZoomPress(ev)
) {
this.chart
@@ -2317,6 +2390,8 @@ declare global {
"dataset-hidden": { id: string };
"dataset-unhidden": { id: string };
"chart-click": ECElementEvent;
"chart-grid-click": { x: number; y: number };
"chart-range-select": { start: number; end: number };
"legend-label-click": { id: string };
"chart-zoom": {
start: number;
@@ -24,6 +24,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import {
@@ -105,8 +106,8 @@ export class StateHistoryChartLine extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: LineSeriesOption[] = [];
@@ -159,7 +160,7 @@ export class StateHistoryChartLine extends LitElement {
@dataset-unhidden=${this._datasetUnhidden}
@chart-zoom=${this._handleDataZoom}
.expandLegend=${this.expandLegend}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
.clickLabelForMoreInfo=${this.clickForMoreInfo}
@legend-label-click=${this._handleLegendLabelClick}
></ha-chart-base>
@@ -22,6 +22,7 @@ import type {
HomeAssistantInternationalization,
} from "../../types";
import { DEFAULT_CHART_WIDTH, MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import type { ZoomSliderMode } from "./ha-chart-base";
import { itemTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
@@ -68,8 +69,8 @@ export class StateHistoryChartTimeline extends LitElement {
@property({ attribute: false }) public chartIndex?;
@property({ attribute: "hide-zoom-controls", type: Boolean })
public hideZoomControls = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@state() private _chartData: CustomSeriesOption[] = [];
@@ -105,7 +106,7 @@ export class StateHistoryChartTimeline extends LitElement {
grow-for-zoom-slider
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
.hideZoomControls=${this.hideZoomControls}
.zoomSlider=${this.zoomSlider}
></ha-chart-base>
`;
}
+2 -2
View File
@@ -218,7 +218,7 @@ export class StateHistoryCharts extends LitElement {
@chart-zoom-with-index=${this._handleTimelineSync}
.height=${this.virtualize ? undefined : this.height}
.expandLegend=${this.expandLegend}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-line>
</div> `;
}
@@ -237,7 +237,7 @@ export class StateHistoryCharts extends LitElement {
.clickForMoreInfo=${this.clickForMoreInfo}
@y-width-changed=${this._yWidthChanged}
@chart-zoom-with-index=${this._handleTimelineSync}
?hide-zoom-controls=${this.syncCharts}
.zoomSlider=${this.syncCharts ? "never" : "auto"}
></state-history-chart-timeline>
</div> `;
};
+79 -3
View File
@@ -5,7 +5,7 @@ import type {
import type { HassConfig, HassEntities } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
@@ -49,7 +49,11 @@ import type {
HomeAssistantUI,
} from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import type {
CustomLegendOption,
HaChartBase,
ZoomSliderMode,
} from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
@@ -128,6 +132,19 @@ export class StatisticsChart extends LitElement {
@property({ attribute: "expand-legend", type: Boolean })
public expandLegend?: boolean;
@property({ attribute: "select-range", type: Boolean })
public selectRange = false;
@property({ attribute: "zoom-slider" })
public zoomSlider: ZoomSliderMode = "auto";
@property({ attribute: false }) public highlight?: {
start: number;
end: number;
};
@query("ha-chart-base") private _chartBase?: HaChartBase;
@state() private _chartData: (LineSeriesOption | BarSeriesOption)[] = [];
@state() private _legendData: CustomLegendOption["data"];
@@ -243,9 +260,15 @@ export class StatisticsChart extends LitElement {
return html`
<ha-chart-base
.data=${this._chartData}
.data=${this._highlightedChartData(
this._chartData,
this.highlight,
getComputedStyle(this).getPropertyValue("--primary-color").trim()
)}
.options=${this._chartOptions}
.height=${this.height}
.selectRange=${this.selectRange}
.zoomSlider=${this.zoomSlider}
style=${styleMap({ height: this.height })}
@dataset-hidden=${this._datasetHidden}
@dataset-unhidden=${this._datasetUnhidden}
@@ -259,6 +282,59 @@ export class StatisticsChart extends LitElement {
`;
}
public resetZoom() {
this._chartBase?.zoom(0, 100);
}
private _highlightedChartData = memoizeOne(
(
data: (LineSeriesOption | BarSeriesOption)[],
highlight: { start: number; end: number } | undefined,
color: string
): (LineSeriesOption | BarSeriesOption)[] => {
if (!data.length) {
return data;
}
if (data[0].type === "bar") {
if (!highlight) {
return data;
}
// Bars outside the highlight fade, so the highlighted ones stand out.
// Some points are already objects, like negative bars with round caps.
return data.map((series) => ({
...series,
data: (series.data as any[]).map((item) => {
const point =
item && typeof item === "object" && "value" in item
? item
: { value: item };
// Current state bars only carry their time
const start = new Date(point.value[2] ?? point.value[0]).getTime();
return start >= highlight.start && start < highlight.end
? item
: {
...point,
itemStyle: { ...point.itemStyle, opacity: 0.3 },
};
}),
})) as BarSeriesOption[];
}
// Every series gets an area, empty when not highlighted, as the chart
// merges series and would keep an old one
return data.map((series, index) => ({
...series,
markArea: {
silent: true,
itemStyle: { color, opacity: 0.15 },
data:
highlight && index === 0
? [[{ xAxis: highlight.start }, { xAxis: highlight.end }]]
: [],
},
}));
}
);
private _datasetHidden(ev: HASSDomEvent<HASSDomEvents["dataset-hidden"]>) {
this._hiddenStats.add(ev.detail.id);
this.requestUpdate("_hiddenStats");
+21 -12
View File
@@ -1,6 +1,5 @@
import { TZDate } from "@date-fns/tz";
import type { ContextType } from "@lit/context";
import type { ActionDetail } from "@material/mwc-list";
import { mdiCalendarToday } from "@mdi/js";
import "cally";
import type { HassConfig } from "home-assistant-js-websocket/dist/types";
@@ -17,6 +16,7 @@ import {
import { transform } from "../../common/decorators/transform";
import { fireEvent } from "../../common/dom/fire_event";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
HASSDomTargetEvent,
} from "../../common/dom/fire_event";
@@ -32,8 +32,8 @@ import type { HaBaseTimeInput } from "../ha-base-time-input";
import "../ha-icon-button";
import "../ha-icon-button-next";
import "../ha-icon-button-prev";
import "../ha-list";
import "../ha-list-item";
import "../item/ha-list-item-button";
import "../list/ha-list-base";
import "../ha-time-input";
import type { HaTimeInput } from "../ha-time-input";
import type { DateRangePickerRanges } from "./ha-date-range-picker";
@@ -166,11 +166,19 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
return html`
<ha-list @action=${this._setDateRange} activatable>
${Object.keys(this.ranges!).map(
(name) => html`<ha-list-item>${name}</ha-list-item>`
<ha-list-base>
${Object.entries(this.ranges!).map(
([name, range], index) => html`
<ha-list-item-button
.range=${range}
.index=${index}
@click=${this._setDateRange}
>
<span slot="headline">${name}</span>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
`;
}
@@ -343,11 +351,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._saveDateRangePreset(chip.range, chip.index);
}
private _setDateRange(ev: HASSDomEvent<ActionDetail>) {
const dateRange: [Date, Date] = Object.values(this.ranges!)[
ev.detail.index
];
this._saveDateRangePreset(dateRange, ev.detail.index);
private _setDateRange(
ev: HASSDomCurrentTargetEvent<
HTMLElement & { range: [Date, Date]; index: number }
>
) {
this._saveDateRangePreset(ev.currentTarget.range, ev.currentTarget.index);
}
private _saveDateRangePreset(range: [Date, Date], index: number) {
-52
View File
@@ -1,52 +0,0 @@
import { css } from "lit";
import { RadioListItemBase } from "@material/mwc-list/mwc-radio-list-item-base";
import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-item.css";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import { customElement } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
@customElement("ha-radio-list-item")
export class HaRadioListItem extends RadioListItemBase {
async onChange(event) {
super.onChange(event);
fireEvent(this, event.type);
}
static override styles = [
styles,
controlStyles,
css`
:host {
--mdc-theme-secondary: var(--primary-color);
}
:host([graphic="avatar"]) .mdc-deprecated-list-item__graphic,
:host([graphic="medium"]) .mdc-deprecated-list-item__graphic,
:host([graphic="large"]) .mdc-deprecated-list-item__graphic,
:host([graphic="control"]) .mdc-deprecated-list-item__graphic {
margin-inline-end: var(--mdc-list-item-graphic-margin, 16px);
margin-inline-start: 0px;
direction: var(--direction);
}
.mdc-deprecated-list-item__meta {
flex-shrink: 0;
direction: var(--direction);
margin-inline-start: auto;
margin-inline-end: 0;
}
.mdc-deprecated-list-item__graphic {
margin-top: var(--radio-list-item-graphic-margin-top);
}
:host([graphic="icon"]) .mdc-deprecated-list-item__graphic {
margin-inline-start: 0;
margin-inline-end: var(--mdc-list-item-graphic-margin, 32px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-radio-list-item": HaRadioListItem;
}
}
+16 -16
View File
@@ -5,9 +5,10 @@ import { customElement, property, state } from "lit/decorators";
import { stringCompare } from "../../../common/string/compare";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import "../../../components/item/ha-list-item-base";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/user/ha-person-badge";
import type { Person } from "../../../data/person";
import {
@@ -92,23 +93,22 @@ export class HaConfigPerson extends LitElement {
</span>
<ha-card outlined class="storage">
<ha-list>
<ha-list-base>
${this._storageItems.map(
(entry) => html`
<ha-list-item
graphic="avatar"
<ha-list-item-button
@click=${this._openEditEntry}
.entry=${entry}
>
<ha-person-badge
.person=${entry}
slot="graphic"
slot="start"
></ha-person-badge>
<span>${entry.name}</span>
</ha-list-item>
<span slot="headline">${entry.name}</span>
</ha-list-item-button>
`
)}
</ha-list>
</ha-list-base>
${
this._storageItems.length === 0
? html`
@@ -138,19 +138,19 @@ export class HaConfigPerson extends LitElement {
this._configItems.length > 0
? html`
<ha-card outlined header="Configuration.yaml persons">
<ha-list>
<ha-list-base>
${this._configItems.map(
(entry) => html`
<ha-list-item graphic="avatar">
<ha-list-item-base>
<ha-person-badge
.person=${entry}
slot="graphic"
slot="start"
></ha-person-badge>
<span>${entry.name}</span>
</ha-list-item>
<span slot="headline">${entry.name}</span>
</ha-list-item-base>
`
)}
</ha-list>
</ha-list-base>
</ha-card>
`
: nothing
@@ -299,7 +299,7 @@ export class HaConfigPerson extends LitElement {
align-items: center;
justify-content: space-around;
}
ha-list:has(+ .empty) {
ha-list-base:has(+ .empty) {
display: none;
}
`;
@@ -73,6 +73,10 @@ const mountPartitionLabel = ({
return drive ? `${drive} — ${identity}, ${size}` : `${identity}, ${size}`;
};
// Supervisor accepts only ASCII letters, digits and underscores.
const mountNameFromLabel = (label?: string): string =>
(label ?? "").replace(/^\W+|\W+$/g, "").replace(/\W+/g, "_");
const requiredFieldsFilled = (
schema: readonly HaFormSchema[],
data?: MountFormData
@@ -305,6 +309,8 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _originalType?: SupervisorMountType;
private _suggestedName?: string;
@state() private _diskIdentity?: string;
public connectedCallback(): void {
@@ -529,7 +535,9 @@ class ViewMountDialog extends DirtyStateProviderMixin<
private _valueChanged(ev: CustomEvent) {
this._validationError = {};
this._validationWarning = {};
const previous = this._data;
this._data = ev.detail.value;
this._suggestName(previous);
if (this._data?.name && !/^\w+$/.test(this._data.name)) {
this._validationError.name = "invalid_name";
}
@@ -556,6 +564,32 @@ class ViewMountDialog extends DirtyStateProviderMixin<
);
}
// Name a new disk mount after the partition's volume label, unless the user
// typed a name of their own.
private _suggestName(previous?: MountFormData) {
const data = this._data;
if (data?.name !== previous?.name) {
this._suggestedName = undefined;
}
if (
this._existing ||
data?.type !== SupervisorMountType.DISK ||
(data.device === previous?.device &&
previous?.type === SupervisorMountType.DISK) ||
(data.name && data.name !== this._suggestedName)
) {
return;
}
const name = mountNameFromLabel(
this._partitions?.find((entry) => entry.partition.device === data.device)
?.partition.label
);
if (name !== (data.name ?? "")) {
data.name = name;
}
this._suggestedName = name;
}
private async _connectMount(ev) {
const progressButton = ev.target as HaProgressButton;
this._error = undefined;
@@ -28,7 +28,7 @@ import type { ContextType } from "@lit/context";
import type { HassConfig } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import {
@@ -78,7 +78,6 @@ import type {
HomeAssistantConfig,
HomeAssistantInternationalization,
} from "../../../types";
import { animationStyles } from "../../../resources/theme/animations.globals";
const RANGE_KEYS: DateRange[] = [
"today",
@@ -151,6 +150,10 @@ export class HuiEnergyPeriodSelector extends LitElement {
@state() private _loading = false;
@state() private _loadingLeaving = false;
@query(".loading-indicator") private _loadingIndicator?: HTMLElement;
private _loadingTimer?: ReturnType<typeof setTimeout>;
private _resizeObserver?: ResizeObserver;
@@ -379,10 +382,15 @@ export class HuiEnergyPeriodSelector extends LitElement {
: nothing
}
${
this._loading
this._loading || this._loadingLeaving
? html`<ha-spinner
class="loading-indicator"
class=${classMap({
"loading-indicator": true,
leaving: this._loadingLeaving,
})}
size="small"
@transitionend=${this._loadingTransitionDone}
@transitioncancel=${this._loadingTransitionDone}
></ha-spinner>`
: nothing
}
@@ -727,6 +735,10 @@ export class HuiEnergyPeriodSelector extends LitElement {
private _updateDates(energyData: EnergyData): void {
clearTimeout(this._loadingTimer);
if (this._loading) {
this._loadingLeaving = true;
this._removeLoadingIndicatorWithoutTransition();
}
this._loading = false;
this._compare = energyData.startCompare !== undefined;
this._startDate = energyData.start;
@@ -759,10 +771,30 @@ export class HuiEnergyPeriodSelector extends LitElement {
// Basically to ensure there's no "flash loading" when data is loaded quickly
clearTimeout(this._loadingTimer);
this._loadingTimer = setTimeout(() => {
this._loadingLeaving = false;
this._loading = true;
}, 200);
}
private async _removeLoadingIndicatorWithoutTransition() {
await this.updateComplete;
// No transition starts when the spinner was still at opacity 0
this._removeIdleLoadingIndicator();
}
private _loadingTransitionDone(ev: TransitionEvent) {
if (ev.propertyName === "opacity") {
this._removeIdleLoadingIndicator();
}
}
private _removeIdleLoadingIndicator() {
// Reversing the fade-in cancels a transition while the fade-out runs
if (!this._loadingIndicator?.getAnimations().length) {
this._loadingLeaving = false;
}
}
private _getDatePickerPlacement = memoizeOne(
(
verticalOpeningDirection: VerticalOpeningDirection | undefined,
@@ -778,7 +810,6 @@ export class HuiEnergyPeriodSelector extends LitElement {
);
static styles = css`
${animationStyles}
:host {
display: block;
}
@@ -843,7 +874,15 @@ export class HuiEnergyPeriodSelector extends LitElement {
position: absolute;
right: var(--ha-space-2);
align-items: center;
animation: fade-in var(--ha-animation-duration-normal) ease-in-out;
transition: opacity var(--ha-animation-duration-normal) ease-in-out;
}
@starting-style {
.loading-indicator {
opacity: 0;
}
}
.loading-indicator.leaving {
opacity: 0;
}
.date-actions .overflow {
display: flex;
+8
View File
@@ -15,6 +15,8 @@ import {
DataZoomComponent,
VisualMapComponent,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
} from "echarts/components";
// Features like Universal Transition and Label Layout
@@ -48,6 +50,8 @@ import type {
GridComponentOption,
DataZoomComponentOption,
VisualMapComponentOption,
MarkAreaComponentOption,
BrushComponentOption,
} from "echarts/components";
import type { ComposeOption } from "echarts/core";
@@ -62,6 +66,8 @@ export type ECOption = ComposeOption<
| GridComponentOption
| DataZoomComponentOption
| VisualMapComponentOption
| MarkAreaComponentOption
| BrushComponentOption
| SankeySeriesOption
| GraphSeriesOption
| SunburstSeriesOption
@@ -91,6 +97,8 @@ echarts.use([
UniversalTransition,
CanvasRenderer,
ToolboxComponent,
MarkAreaComponent,
BrushComponent,
]);
export { LinearGradient };
@@ -0,0 +1,10 @@
// Install the Intl.PluralRules polyfill together with its locale data.
// FormatJS replaces Intl.PluralRules as soon as polyfill-force runs, and a
// data-less polyfill throws from select(). Keeping both imports in one module
// closes that gap for every caller, including the should-polyfill probes
// that other entry bundles run.
import "@formatjs/intl-pluralrules/polyfill-force";
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
import "@formatjs/intl-pluralrules/locale-data/en";
+10 -15
View File
@@ -14,8 +14,6 @@ import {
polyfillTimeZoneData,
} from "./locale-data-polyfill";
let polyfilled = false;
const _polyfillTimeZoneData = polyfillTimeZoneData;
// The DateTimeFormat and DurationFormat polyfills call BigInt() when formatting
@@ -47,10 +45,6 @@ const setDefaultTimeZone = () => {
};
const polyfillIntl = async () => {
if (polyfilled) {
return;
}
polyfilled = true;
const locale = getLocalLanguage();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillGetCanonicalLocales()) {
@@ -79,14 +73,10 @@ const polyfillIntl = async () => {
polyfills.push(import("@formatjs/intl-numberformat/polyfill-force"));
}
if (shouldPolyfillPluralRules(locale)) {
polyfills.push(
import("@formatjs/intl-pluralrules/polyfill-force").then(
// Locale data for plural rules breaks current JSON conversions as it includes functions,
// so only import English to avoid huge bundles
// TODO: Setup JS imports instead of JSON fetches
() => import("@formatjs/intl-pluralrules/locale-data/en")
)
);
// The polyfill and its English data are one chunk, so Intl.PluralRules
// is never installed without data. Its should-polyfill probe calls
// select(), which throws on a data-less polyfill.
polyfills.push(import("./intl-pluralrules"));
}
if (shouldPolyfillRelativeTimeFormat(locale)) {
polyfills.push(import("@formatjs/intl-relativetimeformat/polyfill-force"));
@@ -100,4 +90,9 @@ const polyfillIntl = async () => {
);
};
await polyfillIntl();
// Each legacy entry bundle (core, app, authorize, onboarding) carries its own
// copy of this module, and the bundles load in parallel. A module-level flag
// only stops re-runs within one bundle, so a second bundle could probe
// Intl.* while the first was still loading locale data. Share one promise
// per page instead, so every bundle waits for the same run.
await (globalThis.__haIntlPolyfill ??= polyfillIntl());
+2
View File
@@ -41,6 +41,8 @@ declare global {
var __HASS_URL__: string;
// Read by intl-polyfill before a polyfill replaces Intl.DateTimeFormat
var __haNativeTimeZone: string | undefined;
// Shared by every legacy entry bundle so the Intl polyfills load once
var __haIntlPolyfill: Promise<void> | undefined;
// Handshake between the app entry and the extra modules in index.html
var __haScopedRegistryReady: boolean | undefined;
var __haLoadExtraModules: (() => void) | undefined;