fix(time-picker): locale-aware range labels, inclusive whole-day chip, shared offset cache

This commit is contained in:
Bhargav
2026-11-04 16:08:04 +05:30
parent 28d7ff7566
commit bbd21b3559
5 changed files with 56 additions and 42 deletions
-1
View File
@@ -195,7 +195,6 @@ describe("DateTime rail", () => {
expect(el("datetime-timezone-select-trigger")?.textContent).toContain(
"Asia/Calcutta (UTC+05:30)",
);
expect(el("date-time-cancel-btn")).toBeNull();
expect(el("date-time-apply-btn")).not.toBeNull();
});
+3 -2
View File
@@ -485,6 +485,9 @@ interface ConsumableDateTime {
userChangedValue?: boolean;
}
// One offset lookup per zone for the whole app, not per picker instance.
const zoneOffsets = new Map<string, string>();
export default defineComponent({
components: {
OSeparator,
@@ -680,8 +683,6 @@ export default defineComponent({
const filteredTimezone: any = ref(timezoneOptions);
const isTimezoneSelectOpen = ref(false);
const zoneOffsets = new Map<string, string>();
// longOffset throws on engines that lack it, so the label falls back to no offset.
const zoneOffset = (zone: string): string => {
const cached = zoneOffsets.get(zone);
+1 -1
View File
@@ -258,9 +258,9 @@ const tabStop = computed(() =>
: focusOrder.value[0],
);
// The accessible name must contain the visible compact label.
const hasCalendar = computed(() => props.calendar !== null && props.calendar.length > 0);
// The accessible name must contain the visible compact label.
const rowName = (item: TimeRangeRailItem) =>
item.compact ? `${item.compact.label} (${item.label})` : item.label;
+22 -3
View File
@@ -120,12 +120,31 @@ describe("timeRangeRail", () => {
});
});
describe("locale", () => {
it("keeps each locale's word order for recent labels and hints", () => {
const start = us("2026-10-02T08:00:00Z");
const end = us("2026-10-03T09:00:00Z");
expect(
formatRecentRangeLabel(start, end, "UTC", Date.parse("2026-11-04T12:00:00Z"), "de-DE"),
).toBe("2.–3. Okt.");
expect(
formatCalendarRailHint(
"calendar:month:0",
"UTC",
Date.parse("2026-11-04T12:00:00Z"),
"fr-FR",
),
).toBe("nov.");
});
});
describe("formatRecentDuration", () => {
const dur = (seconds: number) => formatRecentDuration(0, seconds * 1_000_000);
it("reads a range ending at :59 as inclusive, so a whole day is 1d", () => {
expect(dur(86_399)).toBe("1d");
expect(dur(2 * 86_400 - 1)).toBe("2d");
expect(formatRecentDuration(30_000_000, 89_000_000)).toBe("59s");
});
it("keeps the two largest non-zero units", () => {
@@ -158,8 +177,8 @@ describe("timeRangeRail", () => {
});
it("shows the whole Monday-to-Sunday week, not the elapsed part", () => {
expect(hint("calendar:week:0")).toBe("Nov 2–8");
expect(hint("calendar:week:-1")).toBe("Oct 26–Nov 1");
expect(hint("calendar:week:0")).toBe("Nov 2 – 8");
expect(hint("calendar:week:-1")).toBe("Oct 26 – Nov 1");
});
it("shows the month name and the quarter number", () => {
@@ -173,7 +192,7 @@ describe("timeRangeRail", () => {
const jan = Date.parse("2026-01-07T12:00:00Z");
expect(hint("calendar:day:-1", "UTC", jan)).toBe("Jan 6");
expect(hint("calendar:week:-1", "UTC", Date.parse("2026-01-05T12:00:00Z"))).toBe(
"Dec 29, 2025–Jan 4, 2026",
"Dec 29, 2025 – Jan 4, 2026",
);
expect(hint("calendar:month:-1", "UTC", jan)).toBe("Dec 2025");
expect(hint("calendar:quarter:-1", "UTC", jan)).toBe("Q4 2025");
+30 -35
View File
@@ -52,7 +52,7 @@ export interface TimeRangeRailItem {
tooltip: I18nText | undefined;
selected: boolean;
disabled: boolean;
/** Recent rows only: the compact bold label and the duration chip shown instead of the full range. */
/** Recent rows only: the compact label and the duration chip shown instead of the full range. */
compact?: { label: string; duration: string };
/** Calendar rows only: the muted date hint shown beside the label. */
hint?: string;
@@ -96,17 +96,17 @@ export const formatRecentRange = (
return raw(`${start.date} ${start.time} - ${end.date} ${end.time}`);
};
const monthNames = new Map<string, string[]>();
// Month names follow the picker's locale, like the sibling older-period labels.
const monthName = (locale: string, month: number): string => {
let names = monthNames.get(locale);
if (!names) {
const format = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
names = Array.from({ length: 12 }, (_, m) => format.format(Date.UTC(2026, m, 1)));
monthNames.set(locale, names);
}
return names[month];
// Intl keeps each locale's word order, as the sibling older-period labels do; thin spaces become plain ones.
const formatCivilDays = (locale: string, first: Date, last: Date, withYear: boolean): string => {
const format = new Intl.DateTimeFormat(locale, {
month: "short",
day: "numeric",
...(withYear ? { year: "numeric" } : {}),
timeZone: "UTC",
});
const text =
first.getTime() === last.getTime() ? format.format(first) : format.formatRange(first, last);
return text.replace(/[\u2009\u202f]/g, " ");
};
const civilParts = (date: string): { year: number; month: number; day: number } => {
@@ -135,22 +135,22 @@ export const formatRecentRangeLabel = (
const { start, end } = splitRecentRange(startUs, endUs, zone);
const from = civilParts(start.date);
const to = civilParts(end.date);
const withYear =
from.year !== currentYearIn(nowMs, zone) || to.year !== currentYearIn(nowMs, zone);
const day = (p: typeof from) =>
`${monthName(locale, p.month)} ${p.day}${withYear ? `, ${p.year}` : ""}`;
if (from.year === to.year && from.month === to.month) {
if (from.day === to.day) return `${day(from)}${sameDayTimes(start.time, end.time)}`;
return `${monthName(locale, from.month)} ${from.day} – ${to.day}${withYear ? `, ${from.year}` : ""}`;
}
return `${day(from)} – ${day(to)}`;
const year = currentYearIn(nowMs, zone);
const days = formatCivilDays(
locale,
new Date(Date.UTC(from.year, from.month, from.day)),
new Date(Date.UTC(to.year, to.month, to.day)),
from.year !== year || to.year !== year,
);
const sameDay = from.year === to.year && from.month === to.month && from.day === to.day;
return sameDay ? `${days}${sameDayTimes(start.time, end.time)}` : days;
};
/** The two largest non-zero units of d/h/m/s in the span, e.g. "1d 15m", "45m", "30s". */
export const formatRecentDuration = (startUs: number, endUs: number): string => {
const spanSeconds = Math.max(0, Math.floor((endUs - startUs) / MICROS_PER_SECOND));
// A range ending at :59 is inclusive of that minute, so a whole day reads 1d, not 23h 59m.
const total = spanSeconds % 60 === 59 ? spanSeconds + 1 : spanSeconds;
const total = Math.floor(endUs / MICROS_PER_SECOND) % 60 === 59 ? spanSeconds + 1 : spanSeconds;
const units: [string, number][] = [
["d", Math.floor(total / 86_400)],
["h", Math.floor((total % 86_400) / 3600)],
@@ -174,21 +174,16 @@ export const formatCalendarRailHint = (
const { unit, first, last, today } = span;
const currentYear = today.getUTCFullYear();
const withYear = first.getUTCFullYear() !== currentYear || last.getUTCFullYear() !== currentYear;
const day = (d: Date) =>
`${monthName(locale, d.getUTCMonth())} ${d.getUTCDate()}${withYear ? `, ${d.getUTCFullYear()}` : ""}`;
const yearSuffix = withYear ? ` ${first.getUTCFullYear()}` : "";
if (unit === "day") return day(first);
if (unit === "month") return `${monthName(locale, first.getUTCMonth())}${yearSuffix}`;
if (unit === "quarter")
return `${quarterText(Math.floor(first.getUTCMonth() / 3) + 1)}${yearSuffix}`;
if (unit === "year") return String(first.getUTCFullYear());
if (
first.getUTCFullYear() === last.getUTCFullYear() &&
first.getUTCMonth() === last.getUTCMonth()
) {
return `${monthName(locale, first.getUTCMonth())} ${first.getUTCDate()}–${last.getUTCDate()}${withYear ? `, ${first.getUTCFullYear()}` : ""}`;
if (unit === "day" || unit === "week") return formatCivilDays(locale, first, last, withYear);
if (unit === "month") {
const month = new Intl.DateTimeFormat(locale, { month: "short", timeZone: "UTC" });
return `${month.format(first)}${yearSuffix}`;
}
return `${day(first)}–${day(last)}`;
if (unit === "quarter") {
return `${quarterText(Math.floor(first.getUTCMonth() / 3) + 1)}${yearSuffix}`;
}
return String(first.getUTCFullYear());
};
/** Whether a range is longer than the page's query-range limit; 0 means no limit. */