Skip to content

Commit cf0b5e5

Browse files
authored
feat: add autocomplete prop to rac datefield + datepicker (#7773)
* add hidden date input component * add stories * fix lint * fix story * add comments, fix types * add test (not finished) * fix types * add test * fix lint * review comments * add try catch
1 parent cb6c7f2 commit cf0b5e5

File tree

9 files changed

+328
-8
lines changed

9 files changed

+328
-8
lines changed

packages/@react-types/datepicker/src/index.d.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,11 @@ interface DateFieldBase<T extends DateValue> extends InputBase, Validation<Mappe
5959
* Whether to always show leading zeros in the month, day, and hour fields.
6060
* By default, this is determined by the user's locale.
6161
*/
62-
shouldForceLeadingZeros?: boolean
62+
shouldForceLeadingZeros?: boolean,
63+
/**
64+
* Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete).
65+
*/
66+
autoComplete?: string
6367
}
6468

6569
interface AriaDateFieldBaseProps<T extends DateValue> extends DateFieldBase<T>, AriaLabelingProps, DOMProps {}
@@ -83,7 +87,7 @@ export interface DatePickerProps<T extends DateValue> extends DatePickerBase<T>,
8387
export interface AriaDatePickerProps<T extends DateValue> extends DatePickerProps<T>, AriaDatePickerBaseProps<T>, InputDOMProps {}
8488

8589
export type DateRange = RangeValue<DateValue>;
86-
export interface DateRangePickerProps<T extends DateValue> extends Omit<DatePickerBase<T>, 'validate'>, Validation<RangeValue<MappedDateValue<T>>>, ValueBase<RangeValue<T> | null, RangeValue<MappedDateValue<T>> | null> {
90+
export interface DateRangePickerProps<T extends DateValue> extends Omit<DatePickerBase<T>, 'validate' | 'autoComplete'>, Validation<RangeValue<MappedDateValue<T>>>, ValueBase<RangeValue<T> | null, RangeValue<MappedDateValue<T>> | null> {
8791
/**
8892
* When combined with `isDateUnavailable`, determines whether non-contiguous ranges,
8993
* i.e. ranges containing unavailable dates, may be selected.
@@ -105,7 +109,7 @@ export interface DateRangePickerProps<T extends DateValue> extends Omit<DatePick
105109
form?: string
106110
}
107111

108-
export interface AriaDateRangePickerProps<T extends DateValue> extends Omit<AriaDatePickerBaseProps<T>, 'validate'>, DateRangePickerProps<T> {}
112+
export interface AriaDateRangePickerProps<T extends DateValue> extends Omit<AriaDatePickerBaseProps<T>, 'validate' | 'autoComplete'>, DateRangePickerProps<T> {}
109113

110114
interface SpectrumDateFieldBase<T extends DateValue> extends SpectrumLabelableProps, HelpTextProps, SpectrumFieldValidation<MappedDateValue<T>>, StyleProps {
111115
/**
@@ -139,9 +143,9 @@ interface SpectrumDatePickerBase<T extends DateValue> extends SpectrumDateFieldB
139143
createCalendar?: (identifier: CalendarIdentifier) => ICalendar
140144
}
141145

142-
export interface SpectrumDatePickerProps<T extends DateValue> extends Omit<AriaDatePickerProps<T>, 'isInvalid' | 'validationState'>, SpectrumDatePickerBase<T> {}
146+
export interface SpectrumDatePickerProps<T extends DateValue> extends Omit<AriaDatePickerProps<T>, 'isInvalid' | 'validationState' | 'autoComplete'>, SpectrumDatePickerBase<T> {}
143147
export interface SpectrumDateRangePickerProps<T extends DateValue> extends Omit<AriaDateRangePickerProps<T>, 'isInvalid' | 'validationState'>, Omit<SpectrumDatePickerBase<T>, 'validate'> {}
144-
export interface SpectrumDateFieldProps<T extends DateValue> extends Omit<AriaDateFieldProps<T>, 'isInvalid' | 'validationState'>, SpectrumDateFieldBase<T> {}
148+
export interface SpectrumDateFieldProps<T extends DateValue> extends Omit<AriaDateFieldProps<T>, 'isInvalid' | 'validationState' | 'autoComplete'>, SpectrumDateFieldBase<T> {}
145149

146150
export type TimeValue = Time | CalendarDateTime | ZonedDateTime;
147151
type MappedTimeValue<T> =

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {filterDOMProps, useObjectRef} from '@react-aria/utils';
1818
import {FormContext} from './Form';
1919
import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared';
2020
import {Group, GroupContext} from './Group';
21+
import {HiddenDateInput} from './HiddenDateInput';
2122
import {Input, InputContext} from './Input';
2223
import {LabelContext} from './Label';
2324
import React, {cloneElement, createContext, ForwardedRef, forwardRef, JSX, ReactElement, useContext, useRef} from 'react';
@@ -110,6 +111,11 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D
110111
slot={props.slot || undefined}
111112
data-invalid={state.isInvalid || undefined}
112113
data-disabled={state.isDisabled || undefined} />
114+
<HiddenDateInput
115+
autoComplete={props.autoComplete}
116+
name={props.name}
117+
isDisabled={props.isDisabled}
118+
state={state} />
113119
</Provider>
114120
);
115121
});
@@ -347,7 +353,6 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function
347353
defaultClassName: 'react-aria-DateSegment'
348354
});
349355

350-
351356
return (
352357
<span
353358
{...mergeProps(filterDOMProps(otherProps, {global: true}), segmentProps, focusProps, hoverProps)}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {filterDOMProps, mergeProps, useResizeObserver} from '@react-aria/utils';
2121
import {FormContext} from './Form';
2222
import {forwardRefType, GlobalDOMAttributes} from '@react-types/shared';
2323
import {GroupContext} from './Group';
24+
import {HiddenDateInput} from './HiddenDateInput';
2425
import {LabelContext} from './Label';
2526
import {PopoverContext} from './Popover';
2627
import React, {createContext, ForwardedRef, forwardRef, useCallback, useRef, useState} from 'react';
@@ -172,6 +173,11 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function
172173
data-focus-visible={isFocusVisible || undefined}
173174
data-disabled={props.isDisabled || undefined}
174175
data-open={state.isOpen || undefined} />
176+
<HiddenDateInput
177+
autoComplete={props.autoComplete}
178+
name={props.name}
179+
isDisabled={props.isDisabled}
180+
state={state} />
175181
</Provider>
176182
);
177183
});
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
14+
import {CalendarDate, CalendarDateTime, parseDate, parseDateTime} from '@internationalized/date';
15+
import {DateFieldState, DatePickerState, DateSegmentType} from 'react-stately';
16+
import React, {ReactNode} from 'react';
17+
import {useVisuallyHidden} from 'react-aria';
18+
19+
interface AriaHiddenDateInputProps {
20+
/**
21+
* Describes the type of autocomplete functionality the input should provide if any. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete).
22+
*/
23+
autoComplete?: string,
24+
/** HTML form input name. */
25+
name?: string,
26+
/** Sets the disabled state of the input. */
27+
isDisabled?: boolean
28+
}
29+
30+
interface HiddenDateInputProps extends AriaHiddenDateInputProps {
31+
/**
32+
* State for the input.
33+
*/
34+
state: DateFieldState | DatePickerState
35+
}
36+
37+
export interface HiddenDateAria {
38+
/** Props for the container element. */
39+
containerProps: React.HTMLAttributes<HTMLDivElement>,
40+
/** Props for the hidden input element. */
41+
inputProps: React.InputHTMLAttributes<HTMLInputElement>
42+
}
43+
44+
const dateSegments = ['day', 'month', 'year'];
45+
const granularityMap = {'hour': 1, 'minute': 2, 'second': 3};
46+
47+
export function useHiddenDateInput(props: HiddenDateInputProps, state: DateFieldState | DatePickerState) : HiddenDateAria {
48+
let {
49+
autoComplete,
50+
isDisabled,
51+
name
52+
} = props;
53+
let {visuallyHiddenProps} = useVisuallyHidden();
54+
55+
let inputStep = 60;
56+
if (state.granularity === 'second') {
57+
inputStep = 1;
58+
} else if (state.granularity === 'hour') {
59+
inputStep = 3600;
60+
}
61+
62+
let dateValue = state.value == null ? '' : state.value.toString();
63+
64+
let inputType = state.granularity === 'day' ? 'date' : 'datetime-local';
65+
66+
let timeSegments = ['hour', 'minute', 'second'];
67+
// Depending on the granularity, we only want to validate certain time segments
68+
let end = 0;
69+
if (timeSegments.includes(state.granularity)) {
70+
end = granularityMap[state.granularity];
71+
timeSegments = timeSegments.slice(0, end);
72+
}
73+
74+
return {
75+
containerProps: {
76+
...visuallyHiddenProps,
77+
'aria-hidden': true,
78+
// @ts-ignore
79+
['data-react-aria-prevent-focus']: true,
80+
// @ts-ignore
81+
['data-a11y-ignore']: 'aria-hidden-focus'
82+
},
83+
inputProps: {
84+
tabIndex: -1,
85+
autoComplete,
86+
disabled: isDisabled,
87+
type: inputType,
88+
// We set the form prop to an empty string to prevent the hidden date input's value from being submitted
89+
form: '',
90+
name,
91+
step: inputStep,
92+
value: dateValue,
93+
onChange: (e) => {
94+
let targetString = e.target.value.toString();
95+
if (targetString) {
96+
try {
97+
let targetValue: CalendarDateTime | CalendarDate = parseDateTime(targetString);
98+
if (state.granularity === 'day') {
99+
targetValue = parseDate(targetString);
100+
}
101+
// We check to to see if setSegment exists in the state since it only exists in DateFieldState and not DatePickerState.
102+
// The setValue method has different behavior depending on if it's coming from DateFieldState or DatePickerState.
103+
// In DateFieldState, setValue firsts checks to make sure that each segment is filled before committing the newValue
104+
// which is why in the code below we first set each segment to validate it before committing the new value.
105+
// However, in DatePickerState, since we have to be able to commit values from the Calendar popover, we are also able to
106+
// set a new value when the field itself is empty.
107+
if ('setSegment' in state) {
108+
for (let type in targetValue) {
109+
if (dateSegments.includes(type)) {
110+
state.setSegment(type as DateSegmentType, targetValue[type]);
111+
}
112+
if (timeSegments.includes(type)) {
113+
state.setSegment(type as DateSegmentType, targetValue[type]);
114+
}
115+
}
116+
}
117+
state.setValue(targetValue);
118+
} catch {
119+
// ignore
120+
}
121+
}
122+
}
123+
}
124+
};
125+
}
126+
127+
export function HiddenDateInput(props: HiddenDateInputProps): ReactNode | null {
128+
let {state} = props;
129+
let {containerProps, inputProps} = useHiddenDateInput({...props}, state);
130+
return (
131+
<div {...containerProps} data-testid="hidden-dateinput-container">
132+
<input {...inputProps} />
133+
</div>
134+
);
135+
}

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
*/
1212

1313
import {action} from '@storybook/addon-actions';
14+
import {Button, DateField, DateInput, DateSegment, FieldError, Form, Input, Label, TextField} from 'react-aria-components';
1415
import clsx from 'clsx';
15-
import {DateField, DateInput, DateSegment, FieldError, Label} from 'react-aria-components';
1616
import {fromAbsolute, getLocalTimeZone, parseAbsoluteToLocal} from '@internationalized/date';
1717
import React from 'react';
1818
import styles from '../example/index.css';
@@ -65,3 +65,30 @@ export const DateFieldExample = (props) => (
6565
<FieldError style={{display: 'block'}} />
6666
</DateField>
6767
);
68+
69+
export const DateFieldAutoFill = (props) => (
70+
<Form
71+
onSubmit={e => {
72+
action('onSubmit')(Object.fromEntries(new FormData(e.target as HTMLFormElement).entries()));
73+
e.preventDefault();
74+
}}>
75+
<TextField>
76+
<Label>Name</Label>
77+
<Input name="name" type="text" id="name" autoComplete="name" />
78+
</TextField>
79+
<DateField
80+
{...props}
81+
name="bday"
82+
autoComplete="bday"
83+
defaultValue={parseAbsoluteToLocal('2021-04-07T18:45:22Z')
84+
}
85+
data-testid="date-field-example">
86+
<Label style={{display: 'block'}}>Date</Label>
87+
<DateInput className={styles.field} data-testid2="date-input">
88+
{segment => <DateSegment segment={segment} className={clsx(styles.segment, {[styles.placeholder]: segment.isPlaceholder})} />}
89+
</DateInput>
90+
<FieldError style={{display: 'block'}} />
91+
</DateField>
92+
<Button type="submit">Submit</Button>
93+
</Form>
94+
);

packages/react-aria-components/stories/DatePicker.stories.tsx

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {Button, Calendar, CalendarCell, CalendarGrid, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, Group, Heading, Label, Popover, RangeCalendar} from 'react-aria-components';
13+
import {action} from '@storybook/addon-actions';
14+
import {Button, Calendar, CalendarCell, CalendarGrid, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, Form, Group, Heading, Input, Label, Popover, RangeCalendar, TextField} from 'react-aria-components';
1415
import clsx from 'clsx';
1516
import React from 'react';
1617
import styles from '../example/index.css';
@@ -166,3 +167,47 @@ export const DateRangePickerTriggerWidthExample = () => (
166167
</Popover>
167168
</DateRangePicker>
168169
);
170+
171+
export const DatePickerAutofill = (props) => (
172+
<Form
173+
onSubmit={e => {
174+
action('onSubmit')(Object.fromEntries(new FormData(e.target as HTMLFormElement).entries()));
175+
e.preventDefault();
176+
}}>
177+
<TextField>
178+
<Label>Name</Label>
179+
<Input name="firstName" type="name" id="name" autoComplete="name" />
180+
</TextField>
181+
<DatePicker data-testid="date-picker-example" name="bday" autoComplete="bday" {...props}>
182+
<Label style={{display: 'block'}}>Date</Label>
183+
<Group style={{display: 'inline-flex'}}>
184+
<DateInput className={styles.field}>
185+
{segment => <DateSegment segment={segment} className={clsx(styles.segment, {[styles.placeholder]: segment.isPlaceholder})} />}
186+
</DateInput>
187+
<Button>🗓</Button>
188+
</Group>
189+
<Popover
190+
placement="bottom start"
191+
style={{
192+
background: 'Canvas',
193+
color: 'CanvasText',
194+
border: '1px solid gray',
195+
padding: 20
196+
}}>
197+
<Dialog>
198+
<Calendar style={{width: 220}}>
199+
<div style={{display: 'flex', alignItems: 'center'}}>
200+
<Button slot="previous">&lt;</Button>
201+
<Heading style={{flex: 1, textAlign: 'center'}} />
202+
<Button slot="next">&gt;</Button>
203+
</div>
204+
<CalendarGrid style={{width: '100%'}}>
205+
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
206+
</CalendarGrid>
207+
</Calendar>
208+
</Dialog>
209+
</Popover>
210+
</DatePicker>
211+
<Button type="submit">Submit</Button>
212+
</Form>
213+
);

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,4 +411,20 @@ describe('DateField', () => {
411411
await user.keyboard('002222');
412412
expect(yearsSegment).toHaveTextContent('2222');
413413
});
414+
415+
it('should support autofill', async() => {
416+
let {getByRole} = render(
417+
<DateField>
418+
<Label>Birth date</Label>
419+
<DateInput>
420+
{segment => <DateSegment segment={segment} />}
421+
</DateInput>
422+
</DateField>
423+
);
424+
425+
let hiddenDateInput = document.querySelector('input[type=date]');
426+
await user.type(hiddenDateInput, '2000-05-30');
427+
let input = getByRole('group');
428+
expect(input).toHaveTextContent('5/30/2000');
429+
});
414430
});

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,4 +324,14 @@ describe('DatePicker', () => {
324324
let text = popover.querySelector('.react-aria-Text');
325325
expect(text).not.toHaveAttribute('id');
326326
});
327+
328+
it('should support autofill', async() => {
329+
let {getByRole} = render(<TestDatePicker />);
330+
331+
let hiddenDateInput = document.querySelector('input[type=date]');
332+
await user.type(hiddenDateInput, '2000-05-30');
333+
let group = getByRole('group');
334+
let input = group.querySelector('.react-aria-DateInput');
335+
expect(input).toHaveTextContent('5/30/2000');
336+
});
327337
});

0 commit comments

Comments
 (0)