Skip to content

Commit 77ea85e

Browse files
interacseanclaude
andcommitted
fix(date-picker): advance on leading-zero entry + align popover to field
- Segment auto-advance now also fires when the typed-digit count fills the field's width, so typing "02" completes the day and advances — while "2" then "9" still builds 29 (a leading zero caps the digit count). - Anchor the calendar popover to the field group (not the calendar icon) with align="start", so its left edge aligns to the field's left edge and Base UI collision handling shifts/flips it inward near a viewport edge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7835500 commit 77ea85e

4 files changed

Lines changed: 87 additions & 13 deletions

File tree

packages/core/src/components/date-picker-standalone.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useId, useState } from "react";
1+
import { useCallback, useId, useRef, useState } from "react";
22
import type { CalendarDate, DateValue } from "@internationalized/date";
33
import { cn } from "@/lib/utils";
44
import { buildLocaleResolver, type LocalizedString } from "@/lib/i18n";
@@ -240,6 +240,7 @@ function DatePicker<T extends DateValue = DateValue>({
240240
const derivedInvalid = !!errorText || !!isInvalid;
241241

242242
const [open, setOpen] = useState(false);
243+
const fieldRef = useRef<HTMLDivElement>(null);
243244
const [val, setVal] = useControlledState<DateValue | null>(
244245
value ?? undefined,
245246
defaultValue ?? null,
@@ -284,6 +285,7 @@ function DatePicker<T extends DateValue = DateValue>({
284285
open={open}
285286
onOpenChange={setOpen}
286287
ariaLabel={popoverAriaLabel}
288+
anchor={fieldRef}
287289
field={
288290
<DateInputGroup
289291
segments={fieldState.segments}
@@ -298,6 +300,7 @@ function DatePicker<T extends DateValue = DateValue>({
298300
labelId={labelText ? labelId : undefined}
299301
ariaLabel={ariaLabel}
300302
describedById={describedBy}
303+
groupRef={fieldRef}
301304
trigger={<DatePickerPopoverTrigger disabled={isDisabled} />}
302305
/>
303306
}

packages/core/src/components/date-picker.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,38 @@ describe("DateField", () => {
8383
expect(last?.toString()).toBe("2025-06-15");
8484
});
8585
});
86+
87+
it("auto-advances across segments as a full date is typed (no explicit tabbing)", async () => {
88+
const user = userEvent.setup();
89+
const onChange = vi.fn();
90+
render(<DateField label="Date" onChange={onChange} />);
91+
92+
// Locale here is "en" → MM/DD/YYYY. Typing carries across segments:
93+
// "02" fills+advances month, "15" fills+advances day, "2025" fills year.
94+
await user.click(screen.getByRole("spinbutton", { name: "month" }));
95+
await user.keyboard("02152025");
96+
97+
await waitFor(() => {
98+
expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-02-15");
99+
});
100+
});
101+
102+
it("accumulates a non-leading-zero entry (2 then 9 → 29, not 9)", async () => {
103+
const user = userEvent.setup();
104+
const onChange = vi.fn();
105+
render(<DateField label="Date" onChange={onChange} />);
106+
107+
await user.click(screen.getByRole("spinbutton", { name: "month" }));
108+
await user.keyboard("12");
109+
await user.click(screen.getByRole("spinbutton", { name: "day" }));
110+
await user.keyboard("29");
111+
await user.click(screen.getByRole("spinbutton", { name: "year" }));
112+
await user.keyboard("2025");
113+
114+
await waitFor(() => {
115+
expect(onChange.mock.calls.at(-1)?.[0]?.toString()).toBe("2025-12-29");
116+
});
117+
});
86118
});
87119

88120
// ─── DatePicker ───────────────────────────────────────────────────────────────

packages/core/src/components/date-picker.tsx

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ interface DateInputGroupProps {
141141
type: Exclude<Segment["type"], "literal">,
142142
digit: number,
143143
replace?: boolean,
144+
digitCount?: number,
144145
) => { advance: boolean };
145146
setDayPeriod: (pm: boolean) => void;
146147
clearSegment: (type: Exclude<Segment["type"], "literal">) => void;
@@ -154,6 +155,8 @@ interface DateInputGroupProps {
154155
describedById?: string;
155156
className?: string;
156157
trigger?: React.ReactNode;
158+
/** Ref to the group element — used to anchor the popover to the whole field. */
159+
groupRef?: React.Ref<HTMLDivElement>;
157160
}
158161

159162
export function DateInputGroup({
@@ -171,11 +174,13 @@ export function DateInputGroup({
171174
describedById,
172175
className,
173176
trigger,
177+
groupRef,
174178
}: DateInputGroupProps) {
175179
const editableRefs = React.useRef<(HTMLDivElement | null)[]>([]);
176-
// The segment that just gained focus and hasn't received a digit yet. Its
177-
// first digit replaces the current value rather than accumulating onto it.
178-
const freshSegmentRef = React.useRef<Segment["type"] | null>(null);
180+
// Digits typed into the currently-focused segment this session. Reset on
181+
// focus; the first digit (count 0) replaces, and the count decides when the
182+
// segment is "full" and should auto-advance.
183+
const typedCountRef = React.useRef(0);
179184

180185
React.useEffect(() => {
181186
if (autoFocus && !isDisabled) editableRefs.current[0]?.focus();
@@ -249,16 +254,17 @@ export function DateInputGroup({
249254

250255
if (/^\d$/.test(e.key)) {
251256
e.preventDefault();
252-
const replace = freshSegmentRef.current === type;
253-
freshSegmentRef.current = null;
254-
const { advance } = setDigit(type, Number(e.key), replace);
257+
const count = typedCountRef.current;
258+
typedCountRef.current = count + 1;
259+
const { advance } = setDigit(type, Number(e.key), count === 0, count + 1);
255260
if (advance) focusEditable(editableIndex + 1);
256261
}
257262
};
258263

259264
return (
260265
// Deliberate APG date-field pattern: a labelled group wrapping spinbutton segments.
261266
<div
267+
ref={groupRef}
262268
role="group"
263269
data-slot="date-picker-group"
264270
aria-labelledby={labelId}
@@ -309,7 +315,7 @@ export function DateInputGroup({
309315
aria-valuenow={segment.value}
310316
aria-valuetext={segment.isPlaceholder ? "Empty" : segment.text}
311317
onFocus={() => {
312-
freshSegmentRef.current = segment.type;
318+
typedCountRef.current = 0;
313319
}}
314320
onKeyDown={(e) => handleKeyDown(e, segment, editableIndex)}
315321
className={cn(
@@ -571,14 +577,27 @@ interface DatePopoverProps {
571577
field: React.ReactNode;
572578
children: React.ReactNode;
573579
ariaLabel?: string;
580+
/**
581+
* Element to position the calendar against. Defaults to the trigger; pass the
582+
* field group so the calendar aligns to the field's edge (not the icon),
583+
* overlapping it horizontally and shifting inward near the viewport edge.
584+
*/
585+
anchor?: React.RefObject<HTMLElement | null>;
574586
}
575587

576-
export function DatePopover({ open, onOpenChange, field, children, ariaLabel }: DatePopoverProps) {
588+
export function DatePopover({
589+
open,
590+
onOpenChange,
591+
field,
592+
children,
593+
ariaLabel,
594+
anchor,
595+
}: DatePopoverProps) {
577596
return (
578597
<Popover.Root open={open} onOpenChange={onOpenChange}>
579598
{field}
580599
<Popover.Portal>
581-
<Popover.Positioner sideOffset={4} side="bottom" align="start">
600+
<Popover.Positioner anchor={anchor} sideOffset={4} side="bottom" align="start">
582601
{/* APG date-picker dialog pattern — the popup is a labelled dialog. */}
583602
<Popover.Popup
584603
role="dialog"

packages/core/src/components/use-date-field-state.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ const TIME_BY_GRANULARITY: Record<Granularity, EditableSegmentType[]> = {
4343
second: ["hour", "minute", "second"],
4444
};
4545

46+
// Max digits a segment accepts before it's "full" and auto-advances. A leading
47+
// zero counts, so typing "02" (2 digits) completes the day and advances —
48+
// while typing "2" then "9" still builds 29.
49+
const SEGMENT_MAX_DIGITS: Record<EditableSegmentType, number> = {
50+
year: 4,
51+
month: 2,
52+
day: 2,
53+
hour: 2,
54+
minute: 2,
55+
second: 2,
56+
dayPeriod: 0,
57+
};
58+
4659
type Fields = Partial<Record<EditableSegmentType, number>>;
4760

4861
export interface Segment {
@@ -291,16 +304,23 @@ export function useDateFieldState(options: DateFieldStateOptions) {
291304
);
292305

293306
const setDigit = useCallback(
294-
(type: EditableSegmentType, digit: number, replace = false): { advance: boolean } => {
307+
(
308+
type: EditableSegmentType,
309+
digit: number,
310+
replace = false,
311+
digitCount = 1,
312+
): { advance: boolean } => {
295313
if (isReadOnly || type === "dayPeriod") return { advance: false };
296314
const { min, max } = getLimits(type);
297315
// `replace` (first digit after the segment gains focus) starts fresh
298316
// rather than accumulating onto the existing value.
299317
const current = replace ? undefined : fields[type];
300318
let next = current != null && current * 10 + digit <= max ? current * 10 + digit : digit;
301319
if (next < min) next = digit;
302-
// Auto-advance once the segment can't accept another digit.
303-
const advance = next * 10 > max;
320+
// Auto-advance when the segment can't accept another digit (value too
321+
// large) OR the field is digit-width-full (so a leading-zero entry like
322+
// "02" advances, while "2" still waits for a possible second digit).
323+
const advance = next * 10 > max || digitCount >= SEGMENT_MAX_DIGITS[type];
304324
commit({ ...fields, [type]: next });
305325
return { advance };
306326
},

0 commit comments

Comments
 (0)