diff --git a/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts b/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts index 1da2a23ea..7e673ca51 100644 --- a/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts +++ b/packages/bits-ui/src/lib/bits/date-field/date-field.svelte.ts @@ -48,6 +48,7 @@ import { areAllSegmentsFilled, createContent, getDefaultHourCycle, + get24HourValueFromTypedHour, getValueFromSegments, inferGranularity, initSegmentStates, @@ -212,6 +213,7 @@ export class DateFieldRootState { validationNode = $state(null); states = initSegmentStates(); dayPeriodNode = $state(null); + hourInputDayPeriodHint: DateAndTimeSegmentObj["dayPeriod"] = null; rangeRoot: DateRangeFieldRootState | undefined = undefined; name = $state(""); domContext: DOMContext = new DOMContext(() => null); @@ -617,12 +619,16 @@ export class DateFieldRootState { const next = castCb(pVal) as DateAndTimeSegmentObj["hour"]; this.states.hour.updating = next; if (next !== null && prev.dayPeriod !== null) { - const dayPeriod = this.formatter.dayPeriod( - toDate(dateRef.set({ hour: Number.parseInt(next) })), - this.hourCycle.current - ); - if (dayPeriod === "AM" || dayPeriod === "PM") { - prev.dayPeriod = dayPeriod; + if (this.hourCycle.current !== 24 && this.hourInputDayPeriodHint !== null) { + prev.dayPeriod = this.hourInputDayPeriodHint; + } else { + const dayPeriod = this.formatter.dayPeriod( + toDate(dateRef.set({ hour: Number.parseInt(next) })), + this.hourCycle.current + ); + if (dayPeriod === "AM" || dayPeriod === "PM") { + prev.dayPeriod = dayPeriod; + } } } newSegmentValues = { ...prev, [part]: next }; @@ -672,9 +678,22 @@ export class DateFieldRootState { } this.segmentValues = newSegmentValues; if (areAllSegmentsFilled(newSegmentValues, this.#fieldNode)) { + const segmentObjForValue = + "hour" in newSegmentValues && + this.hourCycle.current !== 24 && + this.hourInputDayPeriodHint !== null && + newSegmentValues.hour !== null + ? { + ...newSegmentValues, + hour: get24HourValueFromTypedHour( + newSegmentValues.hour, + this.hourInputDayPeriodHint as "AM" | "PM" + ), + } + : newSegmentValues; this.setValue( getValueFromSegments({ - segmentObj: newSegmentValues, + segmentObj: segmentObjForValue, fieldNode: this.#fieldNode, dateRef: this.placeholder.current, }) @@ -1322,12 +1341,19 @@ class DateFieldHourSegmentState extends BaseNumericSegmentState { // Override to handle special hour logic onkeydown(e: BitsKeyboardEvent) { + const oldUpdateSegment = this.root.updateSegment.bind(this.root); + // Add special handling for hour display with dayPeriod if (isNumberString(e.key)) { - const oldUpdateSegment = this.root.updateSegment.bind(this.root); + this.root.hourInputDayPeriodHint = + this.root.hourCycle.current === 24 + ? null + : isDateAndTimeSegmentObj(this.root.segmentValues) + ? this.root.segmentValues.dayPeriod + : null; // oxlint-disable-next-line no-explicit-any this.root.updateSegment = (part: any, cb: any) => { - const result = oldUpdateSegment(part, cb); + oldUpdateSegment(part, cb); // After updating hour, check if we need to display "12" instead of "0" if (part === "hour" && "hour" in this.root.segmentValues) { @@ -1340,15 +1366,14 @@ class DateFieldHourSegmentState extends BaseNumericSegmentState { this.root.segmentValues.hour = "12"; } } - - return result; }; } super.onkeydown(e); // Restore original updateSegment - this.root.updateSegment = this.root.updateSegment.bind(this.root); + this.root.updateSegment = oldUpdateSegment; + this.root.hourInputDayPeriodHint = null; } } diff --git a/packages/bits-ui/src/lib/internal/date-time/field/helpers.ts b/packages/bits-ui/src/lib/internal/date-time/field/helpers.ts index 4b581e495..fb8b4ccb7 100644 --- a/packages/bits-ui/src/lib/internal/date-time/field/helpers.ts +++ b/packages/bits-ui/src/lib/internal/date-time/field/helpers.ts @@ -290,6 +290,21 @@ function getUsedSegments(fieldNode: HTMLElement | null) { return usedSegments; } +export function get24HourValueFromTypedHour( + hour: string, + dayPeriod: "AM" | "PM" +): string { + const parsedHour = Number.parseInt(hour); + if (Number.isNaN(parsedHour)) return hour; + if (dayPeriod === "AM") { + return parsedHour === 12 ? "0" : `${parsedHour}`; + } + if (dayPeriod === "PM") { + return parsedHour < 12 ? `${parsedHour + 12}` : `${parsedHour}`; + } + return hour; +} + type GetValueFromSegments = { segmentObj: SegmentValueObj; fieldNode: HTMLElement | null; @@ -305,6 +320,15 @@ export function getValueFromSegments(props: GetValueFromSegments) { if ("hour" in segmentObj) { const value = segmentObj[part]; if (isNull(value)) continue; + if (part === "dayPeriod") continue; + if (part === "hour" && !isNull(segmentObj.dayPeriod)) { + const hour24 = get24HourValueFromTypedHour( + value, + segmentObj.dayPeriod as "AM" | "PM" + ); + date = date.set({ hour: Number.parseInt(hour24) }); + continue; + } date = date.set({ [part]: segmentObj[part] }); } else if (isDateSegmentPart(part)) { const value = segmentObj[part]; diff --git a/tests/src/tests/date-field/date-field.browser.test.ts b/tests/src/tests/date-field/date-field.browser.test.ts index 616d5de16..8bfb5ce0f 100644 --- a/tests/src/tests/date-field/date-field.browser.test.ts +++ b/tests/src/tests/date-field/date-field.browser.test.ts @@ -665,6 +665,38 @@ describe("date field", () => { await expect.element(getDayPeriod()).toHaveTextContent("AM"); }); + it("should preserve the PM day period when typing the hour in 12h mode", async () => { + setup({ + value: new CalendarDateTime(2026, 3, 11, 14, 0, 0, 0), + }); + const { getHour, getDayPeriod } = getTimeSegments(page.getByTestId); + + await expect.element(getDayPeriod()).toHaveTextContent("PM"); + await expect.element(page.getByTestId("value")).toHaveTextContent("2026-03-11T14:00"); + + await getHour().click(); + await userEvent.keyboard(`{1}`); + + await expect.element(getDayPeriod()).toHaveTextContent("PM"); + await expect.element(page.getByTestId("value")).toHaveTextContent("2026-03-11T13:00"); + }); + + it("should preserve the AM day period when typing the hour in 12h mode", async () => { + setup({ + value: new CalendarDateTime(2026, 3, 11, 2, 0, 0, 0), + }); + const { getHour, getDayPeriod } = getTimeSegments(page.getByTestId); + + await expect.element(getDayPeriod()).toHaveTextContent("AM"); + await expect.element(page.getByTestId("value")).toHaveTextContent("2026-03-11T02:00"); + + await getHour().click(); + await userEvent.keyboard(`{1}`); + + await expect.element(getDayPeriod()).toHaveTextContent("AM"); + await expect.element(page.getByTestId("value")).toHaveTextContent("2026-03-11T01:00"); + }); + it("should never allow the hour to be 0 when in a 12 hour cycle", async () => { setup({ value: new CalendarDateTime(2023, 10, 12, 12, 30, 0, 0),