Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add autocomplete prop to rac datefield + datepicker #7773

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
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
14 changes: 9 additions & 5 deletions packages/@react-types/datepicker/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ interface DateFieldBase<T extends DateValue> extends InputBase, Validation<Mappe
* Whether to always show leading zeros in the month, day, and hour fields.
* By default, this is determined by the user's locale.
*/
shouldForceLeadingZeros?: boolean
shouldForceLeadingZeros?: boolean,
/**
* 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).
*/
autoComplete?: string
}

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

export type DateRange = RangeValue<DateValue>;
export interface DateRangePickerProps<T extends DateValue> extends Omit<DatePickerBase<T>, 'validate'>, Validation<RangeValue<MappedDateValue<T>>>, ValueBase<RangeValue<T> | null, RangeValue<MappedDateValue<T>> | null> {
export interface DateRangePickerProps<T extends DateValue> extends Omit<DatePickerBase<T>, 'validate' | 'autoComplete'>, Validation<RangeValue<MappedDateValue<T>>>, ValueBase<RangeValue<T> | null, RangeValue<MappedDateValue<T>> | null> {
/**
* When combined with `isDateUnavailable`, determines whether non-contiguous ranges,
* i.e. ranges containing unavailable dates, may be selected.
Expand All @@ -99,7 +103,7 @@ export interface DateRangePickerProps<T extends DateValue> extends Omit<DatePick
endName?: string
}

export interface AriaDateRangePickerProps<T extends DateValue> extends Omit<AriaDatePickerBaseProps<T>, 'validate'>, DateRangePickerProps<T> {}
export interface AriaDateRangePickerProps<T extends DateValue> extends Omit<AriaDatePickerBaseProps<T>, 'validate' | 'autoComplete'>, DateRangePickerProps<T> {}

interface SpectrumDateFieldBase<T extends DateValue> extends SpectrumLabelableProps, HelpTextProps, SpectrumFieldValidation<MappedDateValue<T>>, StyleProps {
/**
Expand Down Expand Up @@ -127,9 +131,9 @@ interface SpectrumDatePickerBase<T extends DateValue> extends SpectrumDateFieldB
shouldFlip?: boolean
}

export interface SpectrumDatePickerProps<T extends DateValue> extends Omit<AriaDatePickerProps<T>, 'isInvalid' | 'validationState'>, SpectrumDatePickerBase<T> {}
export interface SpectrumDatePickerProps<T extends DateValue> extends Omit<AriaDatePickerProps<T>, 'isInvalid' | 'validationState' | 'autoComplete'>, SpectrumDatePickerBase<T> {}
export interface SpectrumDateRangePickerProps<T extends DateValue> extends Omit<AriaDateRangePickerProps<T>, 'isInvalid' | 'validationState'>, Omit<SpectrumDatePickerBase<T>, 'validate'> {}
export interface SpectrumDateFieldProps<T extends DateValue> extends Omit<AriaDateFieldProps<T>, 'isInvalid' | 'validationState'>, SpectrumDateFieldBase<T> {}
export interface SpectrumDateFieldProps<T extends DateValue> extends Omit<AriaDateFieldProps<T>, 'isInvalid' | 'validationState' | 'autoComplete'>, SpectrumDateFieldBase<T> {}

export type TimeValue = Time | CalendarDateTime | ZonedDateTime;
type MappedTimeValue<T> =
Expand Down
7 changes: 6 additions & 1 deletion packages/react-aria-components/src/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {filterDOMProps, useObjectRef} from '@react-aria/utils';
import {FormContext} from './Form';
import {forwardRefType} from '@react-types/shared';
import {Group, GroupContext} from './Group';
import {HiddenDateInput} from './HiddenDateInput';
import {Input, InputContext} from './Input';
import {LabelContext} from './Label';
import React, {cloneElement, createContext, ForwardedRef, forwardRef, JSX, ReactElement, useContext, useRef} from 'react';
Expand Down Expand Up @@ -109,6 +110,11 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D
ref={ref}
slot={props.slot || undefined}
data-invalid={state.isInvalid || undefined} />
<HiddenDateInput
autoComplete={props.autoComplete}
name={props.name}
isDisabled={props.isDisabled}
state={state} />
</Provider>
);
});
Expand Down Expand Up @@ -343,7 +349,6 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function
defaultClassName: 'react-aria-DateSegment'
});


return (
<span
{...mergeProps(filterDOMProps(otherProps as any), segmentProps, focusProps, hoverProps)}
Expand Down
6 changes: 6 additions & 0 deletions packages/react-aria-components/src/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {filterDOMProps, useResizeObserver} from '@react-aria/utils';
import {FormContext} from './Form';
import {forwardRefType} from '@react-types/shared';
import {GroupContext} from './Group';
import {HiddenDateInput} from './HiddenDateInput';
import {LabelContext} from './Label';
import {PopoverContext} from './Popover';
import React, {createContext, ForwardedRef, forwardRef, useCallback, useRef, useState} from 'react';
Expand Down Expand Up @@ -170,6 +171,11 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function
data-focus-visible={isFocusVisible || undefined}
data-disabled={props.isDisabled || undefined}
data-open={state.isOpen || undefined} />
<HiddenDateInput
autoComplete={props.autoComplete}
name={props.name}
isDisabled={props.isDisabled}
state={state} />
</Provider>
);
});
Expand Down
140 changes: 140 additions & 0 deletions packages/react-aria-components/src/HiddenDateInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/


import {CalendarDate, CalendarDateTime, parseDate, parseDateTime} from '@internationalized/date';
import {DateFieldState, DatePickerState, DateSegmentType} from 'react-stately';
import React, {useEffect} from 'react';
import {useVisuallyHidden} from 'react-aria';

interface AriaHiddenDateInputProps {
/**
* 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).
*/
autoComplete?: string,
/** HTML form input name. */
name?: string,
/** Sets the disabled state of the input. */
isDisabled?: boolean
}

interface HiddenDateInputProps extends AriaHiddenDateInputProps {
/**
* State for the input.
*/
state: DateFieldState | DatePickerState
}

export interface HiddenDateAria {
/** Props for the container element. */
containerProps: React.HTMLAttributes<HTMLDivElement>,
/** Props for the hidden input element. */
inputProps: React.InputHTMLAttributes<HTMLInputElement>
}

export function useHiddenDateInput(props: HiddenDateInputProps, state: DateFieldState | DatePickerState) {
let {
autoComplete,
isDisabled,
name
} = props;
let [dateValue, setDateValue] = React.useState('');
let {visuallyHiddenProps} = useVisuallyHidden();

let inputStep = 60;
if (state.granularity === 'second') {
inputStep = 1;
} else if (state.granularity === 'hour') {
inputStep = 3600;
}

useEffect(() => {
if (state.value == null) {
setDateValue('');
} else {
setDateValue(state.value.toString());
}
}, [state.value]);

let inputType = state.granularity === 'day' ? 'date' : 'datetime-local';

let dateSegments = ['day', 'month', 'year'];
let timeSegments = ['hour', 'minute', 'second'];
let granularityMap = {'hour': 1, 'minute': 2, 'second': 3};

// Depending on the granularity, we only want to validate certain time segments
let end = 0;
if (timeSegments.includes(state.granularity)) {
end = granularityMap[state.granularity];
timeSegments = timeSegments.slice(0, end);
}

return {
containerProps: {
...visuallyHiddenProps,
'aria-hidden': true,
// @ts-ignore
['data-react-aria-prevent-focus']: true,
// @ts-ignore
['data-a11y-ignore']: 'aria-hidden-focus'
},
inputProps: {
tabIndex: -1,
autoComplete,
disabled: isDisabled,
type: inputType,
// We set the form prop to an empty string to prevent the hidden date input's value from being submitted
form: '',
name,
step: inputStep,
value: dateValue,
onChange: (e) => {
let targetString = e.target.value.toString();
if (targetString) {
let targetValue: CalendarDateTime | CalendarDate = parseDateTime(targetString);
if (state.granularity === 'day') {
targetValue = parseDate(targetString);
}
setDateValue(targetString);
// We check to to see if setSegment exists in the state since it only exists in DateFieldState and not DatePickerState.
// The setValue method has different behavior depending on if it's coming from DateFieldState or DatePickerState.
// In DateFieldState, setValue firsts checks to make sure that each segment is filled before committing the newValue
// which is why in the code below we first set each segment to validate it before committing the new value.
// However, in DatePickerState, since we have to be able to commit values from the Calendar popover, we are also able to
// set a new value when the field itself is empty.
if ('setSegment' in state) {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a little unsure if this is the direction we want to go. i left some comments in the code to explain what's going on but essentially the issue is that depending if the state is DateFieldState or DatePickerState, the setValue method behaves a bit differently.

i've tried validating each segment inside setValue in useDateFieldState but the problem is that we aren't able to differentiate between a newValue that is the placeholder date versus something external (like in this case). as a result, we end up committing the placeholder value as an actual value that we want to display. in any case, it causes some really strange behavior when trying to validate segments inside setValue so it worked better to do it here inside HiddenDateInput

for (let type in targetValue) {
if (dateSegments.includes(type)) {
state.setSegment(type as DateSegmentType, targetValue[type]);
}
if (timeSegments.includes(type)) {
state.setSegment(type as DateSegmentType, targetValue[type]);
}
}

}
state.setValue(targetValue);
}
}
}
};
}

export function HiddenDateInput(props: HiddenDateInputProps) {
let {state} = props;
let {containerProps, inputProps} = useHiddenDateInput({...props}, state);
return (
<div {...containerProps} data-testid="hidden-dateinput-container">
<input {...inputProps} />
</div>
);
}
28 changes: 27 additions & 1 deletion packages/react-aria-components/stories/DateField.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
*/

import {action} from '@storybook/addon-actions';
import {Button, DateField, DateInput, DateSegment, FieldError, Form, Input, Label, TextField} from 'react-aria-components';
import clsx from 'clsx';
import {DateField, DateInput, DateSegment, FieldError, Label} from 'react-aria-components';
import {fromAbsolute, getLocalTimeZone, parseAbsoluteToLocal} from '@internationalized/date';
import React from 'react';
import styles from '../example/index.css';
Expand Down Expand Up @@ -65,3 +65,29 @@ export const DateFieldExample = (props) => (
<FieldError style={{display: 'block'}} />
</DateField>
);

export const DateFieldAutoFill = (props) => (
<Form
// action={'#'}
onSubmit={e => {
action('onSubmit')(Object.fromEntries(new FormData(e.target as HTMLFormElement).entries()));
e.preventDefault();
}}>
<TextField>
<Label>Name</Label>
<Input name="name" type="text" id="name" autoComplete="name" />
</TextField>
<DateField
{...props}
name="bday"
autoComplete="bday"
data-testid="date-field-example">
<Label style={{display: 'block'}}>Date</Label>
<DateInput className={styles.field} data-testid2="date-input">
{segment => <DateSegment segment={segment} className={clsx(styles.segment, {[styles.placeholder]: segment.isPlaceholder})} />}
</DateInput>
<FieldError style={{display: 'block'}} />
</DateField>
<Button type="submit">Submit</Button>
</Form>
);
80 changes: 78 additions & 2 deletions packages/react-aria-components/stories/DatePicker.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,44 @@
* governing permissions and limitations under the License.
*/

import {Button, Calendar, CalendarCell, CalendarGrid, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, Group, Heading, Label, Popover, RangeCalendar} from 'react-aria-components';
import {action} from '@storybook/addon-actions';
import {Button, Calendar, CalendarCell, CalendarGrid, DateInput, DatePicker, DateRangePicker, DateSegment, Dialog, Form, Group, Heading, Input, Label, Popover, RangeCalendar, TextField} from 'react-aria-components';
import clsx from 'clsx';
import React from 'react';
import styles from '../example/index.css';

export default {
title: 'React Aria Components'
title: 'React Aria Components',
argTypes: {
onChange: {
table: {
disable: true
}
},
granularity: {
control: 'select',
options: ['day', 'hour', 'minute', 'second']
},
minValue: {
control: 'date'
},
maxValue: {
control: 'date'
},
isRequired: {
control: 'boolean'
},
isInvalid: {
control: 'boolean'
},
validationBehavior: {
control: 'select',
options: ['native', 'aria']
}
},
args: {
onChange: action('OnChange')
}
};

export const DatePickerExample = () => (
Expand Down Expand Up @@ -166,3 +197,48 @@ export const DateRangePickerTriggerWidthExample = () => (
</Popover>
</DateRangePicker>
);

export const DatePickerAutofill = (props) => (
<Form
// action={'#'}
onSubmit={e => {
action('onSubmit')(Object.fromEntries(new FormData(e.target as HTMLFormElement).entries()));
e.preventDefault();
}}>
<TextField>
<Label>Name</Label>
<Input name="firstName" type="name" id="name" autoComplete="name" />
</TextField>
<DatePicker data-testid="date-picker-example" name="bday" autoComplete="bday" {...props}>
<Label style={{display: 'block'}}>Date</Label>
<Group style={{display: 'inline-flex'}}>
<DateInput className={styles.field}>
{segment => <DateSegment segment={segment} className={clsx(styles.segment, {[styles.placeholder]: segment.isPlaceholder})} />}
</DateInput>
<Button>🗓</Button>
</Group>
<Popover
placement="bottom start"
style={{
background: 'Canvas',
color: 'CanvasText',
border: '1px solid gray',
padding: 20
}}>
<Dialog>
<Calendar style={{width: 220}}>
<div style={{display: 'flex', alignItems: 'center'}}>
<Button slot="previous">&lt;</Button>
<Heading style={{flex: 1, textAlign: 'center'}} />
<Button slot="next">&gt;</Button>
</div>
<CalendarGrid style={{width: '100%'}}>
{date => <CalendarCell date={date} style={({isSelected, isOutsideMonth}) => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />}
</CalendarGrid>
</Calendar>
</Dialog>
</Popover>
</DatePicker>
<Button type="submit">Submit</Button>
</Form>
);
16 changes: 16 additions & 0 deletions packages/react-aria-components/test/DateField.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -316,4 +316,20 @@ describe('DateField', () => {
await user.keyboard('{backspace}');
expect(document.activeElement).toBe(segments[0]);
});

it('should support autofill', async() => {
let {getByRole} = render(
<DateField>
<Label>Birth date</Label>
<DateInput>
{segment => <DateSegment segment={segment} />}
</DateInput>
</DateField>
);

let hiddenDateInput = document.querySelector('input[type=date]');
await user.type(hiddenDateInput, '2000-05-30');
let input = getByRole('group');
expect(input).toHaveTextContent('5/30/2000');
});
});
Loading