diff --git a/packages/@react-types/datepicker/src/index.d.ts b/packages/@react-types/datepicker/src/index.d.ts index 7149d8096f8..90de41686bf 100644 --- a/packages/@react-types/datepicker/src/index.d.ts +++ b/packages/@react-types/datepicker/src/index.d.ts @@ -59,7 +59,11 @@ interface DateFieldBase extends InputBase, Validation extends DateFieldBase, AriaLabelingProps, DOMProps {} @@ -83,7 +87,7 @@ export interface DatePickerProps extends DatePickerBase, export interface AriaDatePickerProps extends DatePickerProps, AriaDatePickerBaseProps, InputDOMProps {} export type DateRange = RangeValue; -export interface DateRangePickerProps extends Omit, 'validate'>, Validation>>, ValueBase | null, RangeValue> | null> { +export interface DateRangePickerProps extends Omit, 'validate' | 'autoComplete'>, Validation>>, ValueBase | null, RangeValue> | null> { /** * When combined with `isDateUnavailable`, determines whether non-contiguous ranges, * i.e. ranges containing unavailable dates, may be selected. @@ -99,7 +103,7 @@ export interface DateRangePickerProps extends Omit extends Omit, 'validate'>, DateRangePickerProps {} +export interface AriaDateRangePickerProps extends Omit, 'validate' | 'autoComplete'>, DateRangePickerProps {} interface SpectrumDateFieldBase extends SpectrumLabelableProps, HelpTextProps, SpectrumFieldValidation>, StyleProps { /** @@ -127,9 +131,9 @@ interface SpectrumDatePickerBase extends SpectrumDateFieldB shouldFlip?: boolean } -export interface SpectrumDatePickerProps extends Omit, 'isInvalid' | 'validationState'>, SpectrumDatePickerBase {} +export interface SpectrumDatePickerProps extends Omit, 'isInvalid' | 'validationState' | 'autoComplete'>, SpectrumDatePickerBase {} export interface SpectrumDateRangePickerProps extends Omit, 'isInvalid' | 'validationState'>, Omit, 'validate'> {} -export interface SpectrumDateFieldProps extends Omit, 'isInvalid' | 'validationState'>, SpectrumDateFieldBase {} +export interface SpectrumDateFieldProps extends Omit, 'isInvalid' | 'validationState' | 'autoComplete'>, SpectrumDateFieldBase {} export type TimeValue = Time | CalendarDateTime | ZonedDateTime; type MappedTimeValue = diff --git a/packages/react-aria-components/src/DateField.tsx b/packages/react-aria-components/src/DateField.tsx index df81cb40008..11ccf1907c6 100644 --- a/packages/react-aria-components/src/DateField.tsx +++ b/packages/react-aria-components/src/DateField.tsx @@ -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'; @@ -109,6 +110,11 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D ref={ref} slot={props.slot || undefined} data-invalid={state.isInvalid || undefined} /> + ); }); @@ -343,7 +349,6 @@ export const DateSegment = /*#__PURE__*/ (forwardRef as forwardRefType)(function defaultClassName: 'react-aria-DateSegment' }); - return ( + ); }); diff --git a/packages/react-aria-components/src/HiddenDateInput.tsx b/packages/react-aria-components/src/HiddenDateInput.tsx new file mode 100644 index 00000000000..46d10c978a5 --- /dev/null +++ b/packages/react-aria-components/src/HiddenDateInput.tsx @@ -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, + /** Props for the hidden input element. */ + inputProps: React.InputHTMLAttributes +} + +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) { + 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 ( +
+ +
+ ); +} diff --git a/packages/react-aria-components/stories/DateField.stories.tsx b/packages/react-aria-components/stories/DateField.stories.tsx index f056e1da964..5493edb356c 100644 --- a/packages/react-aria-components/stories/DateField.stories.tsx +++ b/packages/react-aria-components/stories/DateField.stories.tsx @@ -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'; @@ -65,3 +65,29 @@ export const DateFieldExample = (props) => ( ); + +export const DateFieldAutoFill = (props) => ( +
{ + action('onSubmit')(Object.fromEntries(new FormData(e.target as HTMLFormElement).entries())); + e.preventDefault(); + }}> + + + + + + + + {segment => } + + + + +
+); diff --git a/packages/react-aria-components/stories/DatePicker.stories.tsx b/packages/react-aria-components/stories/DatePicker.stories.tsx index d2fbeaf976f..ca24a92604a 100644 --- a/packages/react-aria-components/stories/DatePicker.stories.tsx +++ b/packages/react-aria-components/stories/DatePicker.stories.tsx @@ -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 = () => ( @@ -166,3 +197,48 @@ export const DateRangePickerTriggerWidthExample = () => ( ); + +export const DatePickerAutofill = (props) => ( +
{ + action('onSubmit')(Object.fromEntries(new FormData(e.target as HTMLFormElement).entries())); + e.preventDefault(); + }}> + + + + + + + + + {segment => } + + + + + + +
+ + + +
+ + {date => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />} + +
+
+
+
+ +
+); diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index 6f467435f4b..960f6052f87 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -316,4 +316,20 @@ describe('DateField', () => { await user.keyboard('{backspace}'); expect(document.activeElement).toBe(segments[0]); }); + + it('should support autofill', async() => { + let {getByRole} = render( + + + + {segment => } + + + ); + + let hiddenDateInput = document.querySelector('input[type=date]'); + await user.type(hiddenDateInput, '2000-05-30'); + let input = getByRole('group'); + expect(input).toHaveTextContent('5/30/2000'); + }); }); diff --git a/packages/react-aria-components/test/DatePicker.test.js b/packages/react-aria-components/test/DatePicker.test.js index ccd2f03bb1d..be3972ad9bb 100644 --- a/packages/react-aria-components/test/DatePicker.test.js +++ b/packages/react-aria-components/test/DatePicker.test.js @@ -274,4 +274,14 @@ describe('DatePicker', () => { let hiddenInput = getByRole('textbox', {hidden: true}); expect(hiddenInput).toHaveAttribute('disabled'); }); + + it('should support autofill', async() => { + let {getByRole} = render(); + + let hiddenDateInput = document.querySelector('input[type=date]'); + await user.type(hiddenDateInput, '2000-05-30'); + let group = getByRole('group'); + let input = group.querySelector('.react-aria-DateInput'); + expect(input).toHaveTextContent('5/30/2000'); + }); }); diff --git a/packages/react-aria-components/test/HiddenDateInput.test.js b/packages/react-aria-components/test/HiddenDateInput.test.js new file mode 100644 index 00000000000..1c593a65fc1 --- /dev/null +++ b/packages/react-aria-components/test/HiddenDateInput.test.js @@ -0,0 +1,72 @@ +/* + * 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 {createCalendar, parseDate} from '@internationalized/date'; +import {HiddenDateInput} from '../src/HiddenDateInput'; +import {pointerMap, render} from '@react-spectrum/test-utils-internal'; +import React from 'react'; +import {useDateFieldState} from 'react-stately'; +import {useLocale} from 'react-aria'; +import userEvent from '@testing-library/user-event'; + +const HiddenDateInputExample = (props) => { + let {locale} = useLocale(); + const state = useDateFieldState({ + ...props, + locale, + createCalendar + }); + + return ( + + ); +}; + +describe('', () => { + let user; + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + + it('should trigger onChange when input onchange is triggered (autofill)', async () => { + const onChange = jest.fn(); + render( + + ); + + let input = document.querySelector('input[type=date]'); + await user.type(input, '2000-05-30'); + let dateValue = parseDate('2000-05-30'); + expect(onChange).toBeCalledWith(dateValue); + }); + + it('should always add a data attribute data-a11y-ignore="aria-hidden-focus"', () => { + let {getByTestId} = render( + + ); + + expect(getByTestId('hidden-dateinput-container')).toHaveAttribute('data-a11y-ignore', 'aria-hidden-focus'); + }); + + it('should always add a data attribute data-react-aria-prevent-focus', () => { + let {getByTestId} = render( + + ); + + expect(getByTestId('hidden-dateinput-container')).toHaveAttribute('data-react-aria-prevent-focus'); + }); +});