From 6f33041494359a3c634f51bbf00131a7b0cb80e4 Mon Sep 17 00:00:00 2001 From: Agustin Delgado Date: Tue, 20 Oct 2026 14:02:46 -0300 Subject: [PATCH] fix(timepicker): hold typed and stepped times inside minValue/maxValue A time past the max becomes the max and one before the min becomes the min, as it changes, instead of being left invalid. A first digit waiting for its pair is held once it is completed or its segment is left. --- .changeset/timepicker-clamp-to-range.md | 5 ++ .../create-time-selection-state.svelte.ts | 50 +++++++++++++++- .../ui/src/lib/timepicker/root/context.ts | 6 +- .../timepicker/root/time-picker-root.svelte | 28 ++++----- .../timepicker/root/time-picker-root.test.ts | 58 ++++++++++++++++--- .../segment/time-picker-segment.svelte | 3 +- 6 files changed, 122 insertions(+), 28 deletions(-) create mode 100644 .changeset/timepicker-clamp-to-range.md diff --git a/.changeset/timepicker-clamp-to-range.md b/.changeset/timepicker-clamp-to-range.md new file mode 100644 index 00000000..cf0d2b82 --- /dev/null +++ b/.changeset/timepicker-clamp-to-range.md @@ -0,0 +1,5 @@ +--- +'@human-kit/ui': minor +--- + +TimePicker: typed and stepped times are held inside `minValue`/`maxValue` as they change. A time past the max becomes the max and one before the min becomes the min, at once rather than being left invalid; a first digit waiting for its pair is held once it is completed or its segment is left. diff --git a/packages/ui/src/lib/clock/root/create-time-selection-state.svelte.ts b/packages/ui/src/lib/clock/root/create-time-selection-state.svelte.ts index a167f8d1..248b41c3 100644 --- a/packages/ui/src/lib/clock/root/create-time-selection-state.svelte.ts +++ b/packages/ui/src/lib/clock/root/create-time-selection-state.svelte.ts @@ -106,7 +106,17 @@ export type TimeSelectionState = { */ readonly isInvalidDraft: boolean; getSegmentValue: (type: TimePickerEditableSegmentType) => string; - setSegmentValue: (type: TimePickerEditableSegmentType, nextValue: string) => void; + /** + * Writes one segment and commits. The draft is held inside min/max unless `clamp` is + * `false`: a first digit still waiting for its pair is not a time yet. + */ + setSegmentValue: ( + type: TimePickerEditableSegmentType, + nextValue: string, + options?: { clamp?: boolean } + ) => void; + /** Holds the draft inside min/max and commits it: a pending digit that was left alone. */ + clampDraft: () => void; /** Re-commits the current draft (publishes `null` when it is not committeable). */ commitDraft: () => void; /** @@ -280,12 +290,47 @@ export function createTimeSelectionState(options: TimeSelectionStateOptions): Ti return segmentDraft.dayPeriod; } - function setSegmentValue(type: TimePickerEditableSegmentType, nextValue: string) { + function setSegmentValue( + type: TimePickerEditableSegmentType, + nextValue: string, + { clamp = true }: { clamp?: boolean } = {} + ) { if (!options.isEditable()) return; writeSegmentValue(type, nextValue); + if (clamp) clampDraftToRange(); + commitFromDraft(); + } + + function clampDraft() { + if (!options.isEditable()) return; + clampDraftToRange(); commitFromDraft(); } + /** + * Holds a typed or stepped draft inside `minValue`/`maxValue`, the way React Aria's + * segments hold a number inside its bounds: a complete time past the max becomes the + * max, one before the min becomes the min, at once rather than on blur. A first digit + * that waits for its pair is skipped (`1` on the way to `19` under a `10:00` min), and + * held once it is completed or left alone. + */ + function clampDraftToRange() { + const granularity = options.granularity(); + const parts = buildTimePartsFromDraft(segmentDraft, granularity, resolvedHourCycle); + if (!parts) return; + + const candidate = formatTimePickerValue(parts, granularity); + let boundary: TimePickerTimeValue | undefined; + if (isTimeOutOfRange(candidate, undefined, normalizedMaxValue, granularity)) { + boundary = normalizedMaxValue; + } else if (isTimeOutOfRange(candidate, normalizedMinValue, undefined, granularity)) { + boundary = normalizedMinValue; + } + if (!boundary) return; + + segmentDraft = toDraftFromTimeValue(boundary, resolvedHourCycle); + } + /** Normalizes and writes a segment into the draft without committing it. */ function writeSegmentValue(type: TimePickerEditableSegmentType, nextValue: string) { options.onBeforeSegmentMutation?.(type); @@ -510,6 +555,7 @@ export function createTimeSelectionState(options: TimeSelectionStateOptions): Ti }, getSegmentValue, setSegmentValue, + clampDraft, commitDraft: commitFromDraft, setValue, selectWheelValue, diff --git a/packages/ui/src/lib/timepicker/root/context.ts b/packages/ui/src/lib/timepicker/root/context.ts index bcc4f4bf..a607dbb6 100644 --- a/packages/ui/src/lib/timepicker/root/context.ts +++ b/packages/ui/src/lib/timepicker/root/context.ts @@ -40,7 +40,11 @@ export type TimePickerContext = { setValue: (nextValue: TimePickerTimeValue | null) => void; getSegments: () => TimePickerSegmentPart[]; getSegmentValue: (type: Exclude) => string; - setSegmentValue: (type: Exclude, nextValue: string) => void; + setSegmentValue: ( + type: Exclude, + nextValue: string, + options?: { clamp?: boolean } + ) => void; typeSegmentDigit: (type: Exclude, digit: string) => boolean; adjustSegmentValue: (type: Exclude, step: number) => void; getSegmentLabel: (type: TimePickerEditableSegmentType) => string; diff --git a/packages/ui/src/lib/timepicker/root/time-picker-root.svelte b/packages/ui/src/lib/timepicker/root/time-picker-root.svelte index af377d1c..6e493f72 100644 --- a/packages/ui/src/lib/timepicker/root/time-picker-root.svelte +++ b/packages/ui/src/lib/timepicker/root/time-picker-root.svelte @@ -197,6 +197,7 @@ if (!nextWithin && activeSegment !== null) { activeSegment = null; segmentTypeBuffer = createEmptyTimePickerDraft(); + timeSelection.clampDraft(); } if (focusWithin === nextWithin) return; focusWithin = nextWithin; @@ -206,6 +207,8 @@ if (activeSegment === segment) return; activeSegment = segment; segmentTypeBuffer = createEmptyTimePickerDraft(); + // A digit left waiting, or one erased, is held to min/max once the segment is left. + timeSelection.clampDraft(); } function openPopover(reason: TimePickerOpenChangeReason = 'imperative-action', event?: Event) { @@ -246,14 +249,11 @@ const threshold = getTypingThreshold(type); if (candidate.length === 1) { - timeSelection.setSegmentValue(type, candidate); - segmentTypeBuffer[type] = candidate; - const numeric = Number(candidate); - if (Number.isFinite(numeric) && numeric >= threshold) { - segmentTypeBuffer[type] = ''; - return true; - } - return false; + // A digit that waits for its pair is not held to min/max yet: `1` on the way to `19`. + const completes = Number(candidate) >= threshold; + timeSelection.setSegmentValue(type, candidate, { clamp: completes }); + segmentTypeBuffer[type] = completes ? '' : candidate; + return completes; } const numericCandidate = Number(candidate); @@ -271,14 +271,10 @@ if (numericCandidate < min || numericCandidate > max) { candidate = digit; - timeSelection.setSegmentValue(type, candidate); - segmentTypeBuffer[type] = candidate; - const fallbackNumeric = Number(candidate); - if (fallbackNumeric >= threshold) { - segmentTypeBuffer[type] = ''; - return true; - } - return false; + const completes = Number(candidate) >= threshold; + timeSelection.setSegmentValue(type, candidate, { clamp: completes }); + segmentTypeBuffer[type] = completes ? '' : candidate; + return completes; } timeSelection.setSegmentValue(type, candidate); diff --git a/packages/ui/src/lib/timepicker/root/time-picker-root.test.ts b/packages/ui/src/lib/timepicker/root/time-picker-root.test.ts index 357f5630..7d05fca6 100644 --- a/packages/ui/src/lib/timepicker/root/time-picker-root.test.ts +++ b/packages/ui/src/lib/timepicker/root/time-picker-root.test.ts @@ -155,19 +155,61 @@ describe('TimePicker.Root', () => { .toBe('true'); }); - it('flags the input as invalid when a typed hour falls out of min/max', async () => { + it('holds a typed time inside min/max as it is typed', async () => { render(TimePickerTest, { minValue: '09:00', maxValue: '17:00' }); const input = () => document.querySelector('.time-picker-input'); - expect(input()?.getAttribute('data-invalid')).toBeNull(); + const value = () => document.querySelector('[data-testid="time-picker-value"]')?.textContent; - const hourSegment = getSegment('hour'); - hourSegment.element()?.focus(); + getSegment('hour').element()?.focus(); await userEvent.keyboard('22'); + // 22:30 is past the max: the max, at once and never flagged invalid. + await expect.poll(value).toBe('17:00'); + expect(getSegment('hour').element()?.textContent).toBe('17'); + expect(getSegment('minute').element()?.textContent).toBe('00'); + expect(input()?.getAttribute('data-invalid')).toBeNull(); + }); + + it('completes a two-digit hour that starts below the min', async () => { + render(TimePickerTest, { minValue: '10:00', maxValue: '20:00' }); + const value = () => document.querySelector('[data-testid="time-picker-value"]')?.textContent; + + getSegment('hour').element()?.focus(); + await userEvent.keyboard('19'); + await expect.poll(value).toBe('19:30'); + }); + + it('holds a digit left waiting for its pair once the segment is left', async () => { + render(TimePickerTest, { minValue: '09:00', maxValue: '17:00' }); + const value = () => document.querySelector('[data-testid="time-picker-value"]')?.textContent; + + getSegment('hour').element()?.focus(); + await userEvent.keyboard('1'); + getSegment('minute').element()?.focus(); + await expect.poll(value).toBe('09:00'); + }); + + it('stops the arrows at min and max', async () => { + render(TimePickerTest, { defaultValue: '16:30', minValue: '09:00', maxValue: '17:00' }); + const value = () => document.querySelector('[data-testid="time-picker-value"]')?.textContent; + + getSegment('hour').element()?.focus(); + await userEvent.keyboard('{ArrowUp}'); + await expect.poll(value).toBe('17:00'); + await userEvent.keyboard('{ArrowUp}'); + await expect.poll(value).toBe('17:00'); + + getSegment('minute').element()?.focus(); + await userEvent.keyboard('{ArrowUp}'); + await expect.poll(value).toBe('17:00'); + }); + + it('stops the arrows at the min', async () => { + render(TimePickerTest, { defaultValue: '09:30', minValue: '09:00', maxValue: '17:00' }); + const value = () => document.querySelector('[data-testid="time-picker-value"]')?.textContent; - // The draft (22:30) is complete but out of range: no value is committed - // and the input is flagged invalid. - await expect.poll(() => input()?.getAttribute('data-invalid')).toBe('true'); - expect(document.querySelector('[data-testid="time-picker-value"]')?.textContent).toBe(''); + getSegment('hour').element()?.focus(); + await userEvent.keyboard('{ArrowDown}'); + await expect.poll(value).toBe('09:00'); }); it('rebuilds segment draft when hourCycle changes with a committed value', async () => { diff --git a/packages/ui/src/lib/timepicker/segment/time-picker-segment.svelte b/packages/ui/src/lib/timepicker/segment/time-picker-segment.svelte index d548265b..b03529b7 100644 --- a/packages/ui/src/lib/timepicker/segment/time-picker-segment.svelte +++ b/packages/ui/src/lib/timepicker/segment/time-picker-segment.svelte @@ -308,7 +308,8 @@ } return; } - timePicker.setSegmentValue(segment.type, currentValue.slice(0, -1)); + // Erasing is on the way to typing: held to min/max once the segment is left. + timePicker.setSegmentValue(segment.type, currentValue.slice(0, -1), { clamp: false }); return; }