Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/timepicker-clamp-to-range.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -510,6 +555,7 @@ export function createTimeSelectionState(options: TimeSelectionStateOptions): Ti
},
getSegmentValue,
setSegmentValue,
clampDraft,
commitDraft: commitFromDraft,
setValue,
selectWheelValue,
Expand Down
6 changes: 5 additions & 1 deletion packages/ui/src/lib/timepicker/root/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,11 @@ export type TimePickerContext = {
setValue: (nextValue: TimePickerTimeValue | null) => void;
getSegments: () => TimePickerSegmentPart[];
getSegmentValue: (type: Exclude<TimePickerSegmentType, 'literal'>) => string;
setSegmentValue: (type: Exclude<TimePickerSegmentType, 'literal'>, nextValue: string) => void;
setSegmentValue: (
type: Exclude<TimePickerSegmentType, 'literal'>,
nextValue: string,
options?: { clamp?: boolean }
) => void;
typeSegmentDigit: (type: Exclude<TimePickerSegmentType, 'literal'>, digit: string) => boolean;
adjustSegmentValue: (type: Exclude<TimePickerSegmentType, 'literal'>, step: number) => void;
getSegmentLabel: (type: TimePickerEditableSegmentType) => string;
Expand Down
28 changes: 12 additions & 16 deletions packages/ui/src/lib/timepicker/root/time-picker-root.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@
if (!nextWithin && activeSegment !== null) {
activeSegment = null;
segmentTypeBuffer = createEmptyTimePickerDraft();
timeSelection.clampDraft();
}
if (focusWithin === nextWithin) return;
focusWithin = nextWithin;
Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
58 changes: 50 additions & 8 deletions packages/ui/src/lib/timepicker/root/time-picker-root.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Loading