Compare commits

..

5 Commits

Author SHA1 Message Date
Bram Kragten 7ac98b7fdd Bumped version to 20260729.5 2026-08-05 09:56:45 +02:00
karwosts 5c1c92e8ca Fix missing label in solar forecast dialog (#53506) 2026-08-05 09:56:31 +02:00
Paul Bottein bf9765d557 Fix back navigation loop on the cloud page (#53497) 2026-08-05 09:56:30 +02:00
Petar Petrov b013b1f929 Keep numeric slider and buttons within min and max when the step overshoots (#53489)
* Clamp numeric controls after snapping to the step

* Cover the pointer path, display and arrow keys at the bounds
2026-08-05 09:53:55 +02:00
Petar Petrov 8026a59e70 Fix wrong month in date picker calendar header (#53453) 2026-08-05 09:50:37 +02:00
11 changed files with 333 additions and 103 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260729.4"
version = "20260729.5"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+23
View File
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
return `${startDate}/${endDate}`;
};
/**
* August 2021, for calendar days coming out of cally.
*
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
* formats its own headings in UTC too. Anything derived from a cally event has
* to be formatted the same way: the resolved time zone shifts the day back for
* negative UTC offsets, which becomes a wrong month — and in January a wrong
* year — whenever the day is the 1st.
*/
export const formatCallyMonthYear = (
dateObj: Date,
locale: FrontendLocaleData
) => formatCallyMonthYearMem(locale.language).format(dateObj);
const formatCallyMonthYearMem = memoizeOne(
(language: string) =>
new Intl.DateTimeFormat(language, {
month: "long",
year: "numeric",
timeZone: "UTC",
})
);
+18 -36
View File
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatCallyMonthYear,
formatDateMonthYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
@@ -56,13 +56,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-range header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
/**
* used for today to navigate focus in calendar-range
*
* Always mirrors the day calendar-range has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected range and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@@ -88,12 +91,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -163,9 +161,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
@@ -229,12 +225,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -308,24 +299,15 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = dateElement.focusedDate;
}
private _handleChange(ev: CustomEvent) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
this._focusDate = dateElement.focusedDate;
}
private _clickDateRangeChip(ev: Event) {
@@ -6,7 +6,8 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatDateMonth,
formatCallyMonthYear,
formatDateMonthYear,
formatDateShort,
formatDateYear,
formatISODateOnly,
@@ -56,13 +57,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
dateString: string;
};
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-date header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
/**
* used for today to navigate focus in cally-calendar-date
*
* Always mirrors the day calendar-date has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected value and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
public connectedCallback() {
@@ -71,12 +75,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (this.params) {
const date = valueToDate(this.params.value);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -84,7 +83,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = this.params.value
? {
year: this._pickerYear,
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
@@ -140,9 +139,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
@@ -185,12 +182,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -199,18 +191,9 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
private _focusChanged(ev: CustomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
const dateElement = ev.target as CalendarDate;
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = dateElement.focusedDate;
}
private _clear() {
+5 -6
View File
@@ -53,8 +53,10 @@ export class HaControlNumberButton extends LitElement {
});
private _boundedValue(value: number) {
const clamped = conditionalClamp(value, this.min, this.max);
return Math.round(clamped / this._step) * this._step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
const stepped = Math.round(value / this._step) * this._step;
return conditionalClamp(stepped, this.min, this.max);
}
private get _step() {
@@ -67,10 +69,7 @@ export class HaControlNumberButton extends LitElement {
private get _tenPercentStep() {
if (this.max == null || this.min == null) return this._step;
const range = this.max - this.min / 10;
if (range <= this._step) return this._step;
return Math.max(range / 10);
return Math.max((this.max - this.min) / 10, this._step);
}
private _handlePlusButton() {
+5 -7
View File
@@ -115,7 +115,9 @@ export class HaControlSlider extends LitElement {
}
steppedValue(value: number) {
return Math.round(value / this.step) * this.step;
// Clamp after snapping: when the step does not divide the range evenly,
// snapping alone rounds past the bounds (min 1, max 99, step 10 → 0 / 100).
return this.boundedValue(Math.round(value / this.step) * this.step);
}
private _displayedValue(value: number) {
@@ -254,13 +256,9 @@ export class HaControlSlider extends LitElement {
} else if (e.code === "End") {
this.value = this.max;
} else if (e.code === "PageUp") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) + this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) + this._tenPercentStep);
} else if (e.code === "PageDown") {
this.value = this.steppedValue(
this.boundedValue((this.value ?? 0) - this._tenPercentStep)
);
this.value = this.steppedValue((this.value ?? 0) - this._tenPercentStep);
} else {
const isRtl = mainWindow.document.dir === "rtl";
let multiplier = 1;
@@ -68,6 +68,7 @@ export class CloudAccount extends SubscribeMixin(LitElement) {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
@@ -12,6 +12,7 @@ import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-svg-icon";
import "../../../../components/radio/ha-radio-group";
import "../../../../components/input/ha-input";
import { domainToName } from "../../../../data/integration";
import type { HaRadioGroup } from "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import type { ConfigEntry } from "../../../../data/config_entries";
@@ -234,7 +235,7 @@ export class DialogEnergySolarSettings
},
this.hass.auth.data.hassUrl
)}
/>${entry.title}
/>${entry.title || domainToName(this.hass.localize, entry.domain)}
</div>
</ha-checkbox>`
)}
+40
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatCallyMonthYear,
formatDate,
formatDateWeekdayDay,
formatDateShort,
@@ -263,4 +264,43 @@ describe("formatDate", () => {
).toBe("Sat");
});
});
describe("formatCallyMonthYear", () => {
// How cally hands out a calendar day: a Date anchored to UTC midnight
const julyFirst = new Date(Date.UTC(2026, 6, 1));
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
// negative UTC offset whatever zone the tests run in
const locale = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
it("Formats in UTC rather than the resolved time zone", () => {
// A time zone aware formatter reads UTC midnight on the 1st as the
// previous month, which desyncs the header from the calendar grid
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
"June 2026"
);
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
});
it("Keeps the year on a January page", () => {
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
"December 2025"
);
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
});
it("Orders month and year by locale", () => {
expect(
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
).toBe("2026年7月");
});
});
});
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import "../../src/components/ha-control-number-buttons";
import type { HaControlNumberButton } from "../../src/components/ha-control-number-buttons";
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
describe("ha-control-number-buttons step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
let buttons: HaControlNumberButton[] = [];
const mountButtons = async (
props: Partial<HaControlNumberButton>
): Promise<HaControlNumberButton> => {
const el = document.createElement(
"ha-control-number-buttons"
) as HaControlNumberButton;
Object.assign(el, props);
document.body.appendChild(el);
buttons.push(el);
await el.updateComplete;
return el;
};
const stepButton = (el: HaControlNumberButton, label: string) =>
el.shadowRoot!.querySelector<HTMLButtonElement>(`[aria-label="${label}"]`)!;
const changedValues = (el: HaControlNumberButton) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
const pressKey = async (el: HaControlNumberButton, code: string) => {
el.shadowRoot!.querySelector('[role="spinbutton"]')!.dispatchEvent(
new KeyboardEvent("keydown", {
code,
bubbles: true,
composed: true,
cancelable: true,
})
);
await el.updateComplete;
};
beforeEach(() => {
vi.stubGlobal("ResizeObserver", MockResizeObserver);
});
afterEach(() => {
buttons.forEach((el) => el.remove());
buttons = [];
vi.unstubAllGlobals();
});
it("stops at the maximum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([99]);
expect(stepButton(el, "increment").disabled).toBe(true);
});
it("stops at the minimum instead of stepping past it", async () => {
const el = await mountButtons({ ...RANGE, value: 11 });
const values = changedValues(el);
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([1]);
expect(stepButton(el, "decrement").disabled).toBe(true);
});
it("still snaps to the step grid away from the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 44 });
const values = changedValues(el);
stepButton(el, "increment").click();
stepButton(el, "decrement").click();
await el.updateComplete;
expect(values).toEqual([50, 40]);
});
it("steps without bounds when min and max are not set", async () => {
const el = await mountButtons({ step: 10, value: 50 });
const values = changedValues(el);
stepButton(el, "increment").click();
await el.updateComplete;
expect(values).toEqual([60]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountButtons({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 11;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
it("pages by ten percent of the range, but at least one step", async () => {
const wide = await mountButtons({ min: 10, max: 20, step: 1, value: 15 });
const wideValues = changedValues(wide);
await pressKey(wide, "PageUp");
expect(wideValues).toEqual([16]);
// A range narrower than ten steps must still page by a full step.
const narrow = await mountButtons({ min: 0, max: 5, step: 2, value: 0 });
const narrowValues = changedValues(narrow);
await pressKey(narrow, "PageUp");
expect(narrowValues).toEqual([2]);
});
});
+97 -17
View File
@@ -8,6 +8,23 @@ const createSlider = (props: Partial<HaControlSlider>): HaControlSlider => {
return el;
};
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
describe("ha-control-slider value mapping", () => {
afterEach(() => {
document.dir = "ltr";
@@ -54,18 +71,6 @@ describe("ha-control-slider display rounding", () => {
// stepped percentage such as 29 snaps to 26 * step = 28.5714…
const FAN_STEP = 100 / 91;
let sliders: HaControlSlider[] = [];
const mountSlider = async (
props: Partial<HaControlSlider>
): Promise<HaControlSlider> => {
const el = createSlider(props);
document.body.appendChild(el);
sliders.push(el);
await el.updateComplete;
return el;
};
const ariaValueNow = (el: HaControlSlider) =>
el
.shadowRoot!.querySelector('[role="slider"]')!
@@ -74,11 +79,6 @@ describe("ha-control-slider display rounding", () => {
const tooltipText = (el: HaControlSlider) =>
el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim();
afterEach(() => {
sliders.forEach((el) => el.remove());
sliders = [];
});
it("shows the fractional stepped value by default", async () => {
const el = await mountSlider({ step: FAN_STEP, value: 29 });
expect(tooltipText(el)).toBe("28.57");
@@ -113,3 +113,83 @@ describe("ha-control-slider display rounding", () => {
expect(ariaValueNow(el)).toBe("21.5");
});
});
describe("ha-control-slider step bounds", () => {
// An input_number with min 1, max 99 and step 10: the step grid does not
// divide the range, so snapping used to round past both bounds.
const RANGE = { min: 1, max: 99, step: 10 };
const pressKey = async (el: HaControlSlider, code: string) => {
const slider = el.shadowRoot!.querySelector('[role="slider"]')!;
const init = { code, bubbles: true, composed: true, cancelable: true };
slider.dispatchEvent(new KeyboardEvent("keydown", init));
slider.dispatchEvent(new KeyboardEvent("keyup", init));
await el.updateComplete;
};
const changedValues = (el: HaControlSlider) => {
const values: (number | undefined)[] = [];
el.addEventListener("value-changed", (ev) => {
values.push((ev as CustomEvent).detail.value);
});
return values;
};
it("snaps within the bounds instead of rounding past them", () => {
const el = createSlider(RANGE);
expect(el.steppedValue(99)).toBe(99);
expect(el.steppedValue(1)).toBe(1);
// Values away from the bounds still snap to the step grid.
expect(el.steppedValue(44)).toBe(40);
expect(el.steppedValue(46)).toBe(50);
});
it("stays inside the bounds along the whole pointer path", () => {
// The pointer handlers compose these two, so they have to hold together.
const el = createSlider(RANGE);
expect(el.steppedValue(el.percentageToValue(1))).toBe(99);
expect(el.steppedValue(el.percentageToValue(0))).toBe(1);
});
it("does not overshoot the maximum with a fractional step", () => {
// A 91-speed fan: 91 * (100 / 91) used to land on 100.00000000000001.
const el = createSlider({ min: 0, max: 100, step: 100 / 91 });
expect(el.steppedValue(el.percentageToValue(1))).toBe(100);
});
it("shows and announces the bound, not the overshoot", async () => {
const el = await mountSlider({
...RANGE,
value: 99,
tooltipMode: "always",
});
expect(el.shadowRoot!.querySelector(".tooltip")!.textContent!.trim()).toBe(
"99"
);
expect(
el
.shadowRoot!.querySelector('[role="slider"]')!
.getAttribute("aria-valuenow")
).toBe("99");
});
it("keeps paging inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "PageUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "PageDown");
expect(values).toEqual([99, 1]);
});
it("keeps arrow keys inside the bounds", async () => {
const el = await mountSlider({ ...RANGE, value: 91 });
const values = changedValues(el);
await pressKey(el, "ArrowUp");
expect(values).toEqual([99]);
el.value = 1;
await pressKey(el, "ArrowDown");
expect(values).toEqual([99, 1]);
});
});