Skip to content

Commit 4e8d6fa

Browse files
committed
feat: Add focusableRef support to DateInput
1 parent 46eb0ef commit 4e8d6fa

File tree

3 files changed

+123
-5
lines changed

3 files changed

+123
-5
lines changed

packages/react-aria-components/src/DateField.tsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const DateFieldContext = createContext<ContextValue<DateFieldProps<any>,
7777
export const TimeFieldContext = createContext<ContextValue<TimeFieldProps<any>, HTMLDivElement>>(null);
7878
export const DateFieldStateContext = createContext<DateFieldState | null>(null);
7979
export const TimeFieldStateContext = createContext<TimeFieldState | null>(null);
80+
const DateInputFocusableRefContext = createContext<ForwardedRef<HTMLElement> | null>(null);
8081

8182
/**
8283
* A date field allows users to enter and edit date and time values using a keyboard.
@@ -255,6 +256,11 @@ export interface DateInputProps extends SlotProps, StyleRenderProps<DateInputRen
255256
* @default 'react-aria-DateInput'
256257
*/
257258
className?: ClassNameOrFunction<DateInputRenderProps>,
259+
/**
260+
* A ref for the first focusable date segment. Useful for programmatically focusing the input,
261+
* for example when using with react-hook-form.
262+
*/
263+
focusableRef?: ForwardedRef<HTMLElement>,
258264
children: (segment: IDateSegment) => ReactElement
259265
}
260266

@@ -296,15 +302,18 @@ const DateInputStandalone = forwardRef((props: DateInputProps, ref: ForwardedRef
296302
});
297303

298304
const DateInputInner = forwardRef((props: DateInputProps, ref: ForwardedRef<HTMLDivElement>) => {
299-
let {className, children} = props;
305+
let {className, children, focusableRef, ...otherProps} = props;
300306
let dateFieldState = useContext(DateFieldStateContext);
301307
let timeFieldState = useContext(TimeFieldStateContext);
302308
let state = dateFieldState ?? timeFieldState!;
303309

304310
return (
305-
<>
311+
<Provider
312+
values={[
313+
[DateInputFocusableRefContext, focusableRef || null]
314+
]}>
306315
<Group
307-
{...props}
316+
{...otherProps}
308317
ref={ref}
309318
slot={props.slot || undefined}
310319
className={className ?? 'react-aria-DateInput'}
@@ -314,7 +323,7 @@ const DateInputInner = forwardRef((props: DateInputProps, ref: ForwardedRef<HTML
314323
{state.segments.map((segment, i) => cloneElement(children(segment), {key: i}))}
315324
</Group>
316325
<Input />
317-
</>
326+
</Provider>
318327
);
319328
});
320329

@@ -378,7 +387,13 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function
378387
let dateFieldState = useContext(DateFieldStateContext);
379388
let timeFieldState = useContext(TimeFieldStateContext);
380389
let state = dateFieldState ?? timeFieldState!;
381-
let domRef = useObjectRef(ref);
390+
let focusableRef = useContext(DateInputFocusableRefContext);
391+
392+
// If this is the first editable segment and focusableRef is provided, use it
393+
let isFirstEditableSegment = segment.isEditable &&
394+
segment.type === state.segments.find(s => s.isEditable)?.type;
395+
396+
let domRef = useObjectRef((isFirstEditableSegment && focusableRef) ? focusableRef : ref);
382397
let {segmentProps} = useDateSegment(segment, state, domRef);
383398
let {focusProps, isFocused, isFocusVisible} = useFocusRing();
384399
let {hoverProps, isHovered} = useHover({...otherProps, isDisabled: state.isDisabled || segment.type === 'literal'});

packages/react-aria-components/test/DateField.test.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,4 +478,61 @@ describe('DateField', () => {
478478
expect(segements[1]).toHaveTextContent('dd');
479479
expect(segements[2]).toHaveTextContent('yyyy');
480480
});
481+
482+
it('should support focusableRef', () => {
483+
let focusableRef = React.createRef();
484+
let {getAllByRole} = render(
485+
<DateField>
486+
<Label>Birth date</Label>
487+
<DateInput focusableRef={focusableRef}>
488+
{segment => <DateSegment segment={segment} />}
489+
</DateInput>
490+
</DateField>
491+
);
492+
493+
let segments = getAllByRole('spinbutton');
494+
// focusableRef should point to the first editable segment (month in en-US)
495+
expect(focusableRef.current).toBe(segments[0]);
496+
});
497+
498+
it('should focus first segment when calling focus() on focusableRef', () => {
499+
let focusableRef = React.createRef();
500+
let {getAllByRole} = render(
501+
<DateField>
502+
<Label>Birth date</Label>
503+
<DateInput focusableRef={focusableRef}>
504+
{segment => <DateSegment segment={segment} />}
505+
</DateInput>
506+
</DateField>
507+
);
508+
509+
let segments = getAllByRole('spinbutton');
510+
expect(document.activeElement).not.toBe(segments[0]);
511+
512+
// Programmatically focus the first segment
513+
act(() => {
514+
focusableRef.current.focus();
515+
});
516+
517+
expect(document.activeElement).toBe(segments[0]);
518+
});
519+
520+
it('should support focusableRef with different locales', () => {
521+
let focusableRef = React.createRef();
522+
let {getAllByRole} = render(
523+
<I18nProvider locale="zh-CN">
524+
<DateField>
525+
<Label>Birth date</Label>
526+
<DateInput focusableRef={focusableRef}>
527+
{segment => <DateSegment segment={segment} />}
528+
</DateInput>
529+
</DateField>
530+
</I18nProvider>
531+
);
532+
533+
let segments = getAllByRole('spinbutton');
534+
// In zh-CN, year comes first
535+
expect(focusableRef.current).toBe(segments[0]);
536+
expect(segments[0]).toHaveAttribute('data-type', 'year');
537+
});
481538
});

packages/react-aria-components/test/DatePicker.test.js

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,4 +334,50 @@ describe('DatePicker', () => {
334334
let input = group.querySelector('.react-aria-DateInput');
335335
expect(input).toHaveTextContent('5/30/2000');
336336
});
337+
338+
it('should support focusableRef on DateInput', () => {
339+
let focusableRef = React.createRef();
340+
let {getByRole} = render(
341+
<DatePicker>
342+
<Label>Birth date</Label>
343+
<Group>
344+
<DateInput focusableRef={focusableRef}>
345+
{(segment) => <DateSegment segment={segment} />}
346+
</DateInput>
347+
<Button></Button>
348+
</Group>
349+
</DatePicker>
350+
);
351+
352+
let group = getByRole('group');
353+
let segments = within(group).getAllByRole('spinbutton');
354+
// focusableRef should point to the first editable segment
355+
expect(focusableRef.current).toBe(segments[0]);
356+
});
357+
358+
it('should focus first segment when calling focus() on focusableRef', () => {
359+
let focusableRef = React.createRef();
360+
let {getByRole} = render(
361+
<DatePicker>
362+
<Label>Birth date</Label>
363+
<Group>
364+
<DateInput focusableRef={focusableRef}>
365+
{(segment) => <DateSegment segment={segment} />}
366+
</DateInput>
367+
<Button></Button>
368+
</Group>
369+
</DatePicker>
370+
);
371+
372+
let group = getByRole('group');
373+
let segments = within(group).getAllByRole('spinbutton');
374+
expect(document.activeElement).not.toBe(segments[0]);
375+
376+
// Programmatically focus the first segment
377+
act(() => {
378+
focusableRef.current.focus();
379+
});
380+
381+
expect(document.activeElement).toBe(segments[0]);
382+
});
337383
});

0 commit comments

Comments
 (0)