diff --git a/jest.config.js b/jest.config.js index 259debb58ac..ab28b33581e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -94,7 +94,8 @@ module.exports = { '^bundle-text:.*\\.svg$': '/__mocks__/fileMock.js', '\\.svg$': '/__mocks__/svg.js', '\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '/__mocks__/fileMock.js', - '\\.(css|styl)$': 'identity-obj-proxy' + '\\.(css|styl)$': 'identity-obj-proxy', + 'vanilla-starter/(.*)': '/starters/docs/src/$1' }, // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader diff --git a/packages/@adobe/react-spectrum/src/button/Button.tsx b/packages/@adobe/react-spectrum/src/button/Button.tsx index 22b286ea967..ec0bde08eb7 100644 --- a/packages/@adobe/react-spectrum/src/button/Button.tsx +++ b/packages/@adobe/react-spectrum/src/button/Button.tsx @@ -90,7 +90,10 @@ export const Button = React.forwardRef(function Button, HoverEvents, SlotProps, RenderProps, Omit, 'onClick'> { @@ -71,12 +73,7 @@ export interface ButtonProps extends Omit, - /** - * Whether the button is in a pending state. This disables press and hover events - * while retaining focusability, and announces the pending state to screen readers. - */ - isPending?: boolean + className?: ClassNameOrFunction } interface ButtonContextValue extends ButtonProps { @@ -91,9 +88,7 @@ export const ButtonContext = createContext) { [props, ref] = useContextProps(props, ref, ButtonContext); let ctx = props as ButtonContextValue; - let {isPending} = ctx; - let {buttonProps, isPressed} = useButton(props, ref); - buttonProps = useDisableInteractions(buttonProps, isPending); + let {buttonProps, progressBarProps, isPressed, isPending, actionError} = useButton(props, ref); let {focusProps, isFocused, isFocusVisible} = useFocusRing(props); let {hoverProps, isHovered} = useHover({ ...props, @@ -105,7 +100,8 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop isFocused, isFocusVisible, isDisabled: props.isDisabled || false, - isPending: isPending ?? false + isPending, + actionError }; let renderProps = useRenderProps({ @@ -114,70 +110,24 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop defaultClassName: 'react-aria-Button' }); - let buttonId = useId(buttonProps.id); - let progressId = useId(); - - let ariaLabelledby = buttonProps['aria-labelledby']; - if (isPending) { - // aria-labelledby wins over aria-label - // https://www.w3.org/TR/accname-1.2/#computation-steps - if (ariaLabelledby) { - ariaLabelledby = `${ariaLabelledby} ${progressId}`; - } else if (buttonProps['aria-label']) { - ariaLabelledby = `${buttonId} ${progressId}`; - } - } - - let wasPending = useRef(isPending); - useEffect(() => { - let message = {'aria-labelledby': ariaLabelledby || buttonId}; - if (!wasPending.current && isFocused && isPending) { - announce(message, 'assertive'); - } else if (wasPending.current && isFocused && !isPending) { - announce(message, 'assertive'); - } - wasPending.current = isPending; - }, [isPending, isFocused, ariaLabelledby, buttonId]); - let DOMProps = filterDOMProps(props, {global: true}); delete DOMProps.onClick; return ( - + data-focus-visible={isFocusVisible || undefined} + data-action-error={actionError || undefined}> + {renderProps.children} ); }); - -// Events to preserve when isPending is true (for tooltips and other overlays) -const PRESERVED_EVENT_PATTERN = /Focus|Blur|Hover|Pointer(Enter|Leave|Over|Out)|Mouse(Enter|Leave|Over|Out)/; - -function useDisableInteractions(props, isPending) { - if (isPending) { - for (const key in props) { - if (key.startsWith('on') && !PRESERVED_EVENT_PATTERN.test(key)) { - props[key] = undefined; - } - } - props.href = undefined; - props.target = undefined; - } - return props; -} diff --git a/packages/react-aria-components/src/ColorField.tsx b/packages/react-aria-components/src/ColorField.tsx index 57fb859a07e..eba8ee41ede 100644 --- a/packages/react-aria-components/src/ColorField.tsx +++ b/packages/react-aria-components/src/ColorField.tsx @@ -11,7 +11,6 @@ */ import {AriaColorFieldProps, useColorChannelField, useColorField} from 'react-aria/useColorField'; - import { ClassNameOrFunction, ContextValue, @@ -26,13 +25,14 @@ import { useSlot } from './utils'; import {ColorChannel, ColorSpace} from 'react-stately/Color'; -import {ColorFieldState, useColorChannelFieldState, useColorFieldState} from 'react-stately/useColorFieldState'; +import {ColorChannelFieldState, ColorFieldState, useColorChannelFieldState, useColorFieldState} from 'react-stately/useColorFieldState'; +import {DOMProps, GlobalDOMAttributes, InputDOMProps, ValidationResult} from '@react-types/shared'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps} from 'react-aria/filterDOMProps'; -import {GlobalDOMAttributes, InputDOMProps, ValidationResult} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; +import {ProgressBarContext} from './ProgressBar'; import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, InputHTMLAttributes, LabelHTMLAttributes, Ref, useRef} from 'react'; import {TextContext} from './Text'; import {useLocale} from 'react-aria/I18nProvider'; @@ -63,10 +63,15 @@ export interface ColorFieldRenderProps { * @selector [data-channel="hex | hue | saturation | ..."] */ channel: ColorChannel | 'hex', + /** + * Whether the color field is currently in a pending state. + * @selector [data-pending] + */ + isPending: boolean, /** * State of the color field. */ - state: ColorFieldState + state: ColorFieldState | ColorChannelFieldState } export interface ColorFieldProps extends Omit, RACValidation, InputDOMProps, RenderProps, SlotProps, GlobalDOMAttributes { @@ -121,6 +126,7 @@ function ColorChannelField(props: ColorChannelFieldProps) { let { labelProps, inputProps, + progressBarProps, descriptionProps, errorMessageProps, ...validation @@ -140,6 +146,7 @@ function ColorChannelField(props: ColorChannelFieldProps) { inputRef, labelProps, labelRef, + progressBarProps, descriptionProps, errorMessageProps, validation @@ -166,6 +173,7 @@ function HexColorField(props: HexColorFieldProps) { let { labelProps, inputProps, + progressBarProps, descriptionProps, errorMessageProps, ...validation @@ -183,6 +191,7 @@ function HexColorField(props: HexColorFieldProps) { inputRef, labelProps, labelRef, + progressBarProps, descriptionProps, errorMessageProps, validation @@ -191,12 +200,13 @@ function HexColorField(props: HexColorFieldProps) { function useChildren( props: ColorFieldProps, - state: ColorFieldState, + state: ColorFieldState | ColorChannelFieldState, ref: ForwardedRef, inputProps: InputHTMLAttributes, inputRef: Ref, labelProps: LabelHTMLAttributes, labelRef: Ref, + progressBarProps: DOMProps, descriptionProps: HTMLAttributes, errorMessageProps: HTMLAttributes, validation: ValidationResult @@ -208,6 +218,7 @@ function useChildren( channel: props.channel || 'hex', isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, + isPending: state.isPending, isReadOnly: props.isReadOnly || false, isRequired: props.isRequired || false }, @@ -230,7 +241,8 @@ function useChildren( errorMessage: errorMessageProps } }], - [FieldErrorContext, validation] + [FieldErrorContext, validation], + [ProgressBarContext, progressBarProps] ]}> diff --git a/packages/react-aria-components/src/DateField.tsx b/packages/react-aria-components/src/DateField.tsx index 3f9b00bcf3f..6b0ac21aa30 100644 --- a/packages/react-aria-components/src/DateField.tsx +++ b/packages/react-aria-components/src/DateField.tsx @@ -48,6 +48,7 @@ import {HoverEvents} from '@react-types/shared'; import {Input, InputContext} from './Input'; import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; +import {ProgressBarContext} from './ProgressBar'; import React, {cloneElement, createContext, ForwardedRef, forwardRef, JSX, ReactElement, useContext, useRef} from 'react'; import {TextContext} from './Text'; import {TimeFieldState, useTimeFieldState} from 'react-stately/useTimeFieldState'; @@ -81,7 +82,12 @@ export interface DateFieldRenderProps { * Whether the date field is required. * @selector [data-required] */ - isRequired: boolean + isRequired: boolean, + /** + * Whether the date field is currently in a pending state. + * @selector [data-pending] + */ + isPending: boolean } export interface DateFieldProps extends Omit, 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps, SlotProps, GlobalDOMAttributes { /** @@ -124,7 +130,7 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D !props['aria-label'] && !props['aria-labelledby'] ); let inputRef = useRef(null); - let {labelProps, fieldProps, inputProps, descriptionProps, errorMessageProps, ...validation} = useDateField({ + let {labelProps, fieldProps, inputProps, progressBarProps, descriptionProps, errorMessageProps, ...validation} = useDateField({ ...removeDataAttributes(props), label, inputRef, @@ -137,6 +143,7 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D state, isInvalid: state.isInvalid, isDisabled: state.isDisabled, + isPending: state.isPending, isReadOnly: state.isReadOnly, isRequired: props.isRequired || false }, @@ -159,7 +166,8 @@ export const DateField = /*#__PURE__*/ (forwardRef as forwardRefType)(function D errorMessage: errorMessageProps } }], - [FieldErrorContext, validation] + [FieldErrorContext, validation], + [ProgressBarContext, progressBarProps] ]}> (null); - let {labelProps, fieldProps, inputProps, descriptionProps, errorMessageProps, ...validation} = useTimeField({ + let {labelProps, fieldProps, inputProps, progressBarProps, descriptionProps, errorMessageProps, ...validation} = useTimeField({ ...removeDataAttributes(props), label, inputRef, @@ -212,6 +221,7 @@ export const TimeField = /*#__PURE__*/ (forwardRef as forwardRefType)(function T state, isInvalid: state.isInvalid, isDisabled: state.isDisabled, + isPending: state.isPending, isReadOnly: state.isReadOnly, isRequired: props.isRequired || false }, @@ -234,7 +244,8 @@ export const TimeField = /*#__PURE__*/ (forwardRef as forwardRefType)(function T errorMessage: errorMessageProps } }], - [FieldErrorContext, validation] + [FieldErrorContext, validation], + [ProgressBarContext, progressBarProps] ]}> diff --git a/packages/react-aria-components/src/DatePicker.tsx b/packages/react-aria-components/src/DatePicker.tsx index 71bbd55f79e..88aeacae538 100644 --- a/packages/react-aria-components/src/DatePicker.tsx +++ b/packages/react-aria-components/src/DatePicker.tsx @@ -40,6 +40,7 @@ import {HiddenDateInput} from './HiddenDateInput'; import {LabelContext} from './Label'; import {mergeProps} from 'react-aria/mergeProps'; import {PopoverContext} from './Popover'; +import {ProgressBarContext} from './ProgressBar'; import React, {createContext, ForwardedRef, forwardRef, useCallback, useRef, useState} from 'react'; import {TextContext} from './Text'; import {useFocusRing} from 'react-aria/useFocusRing'; @@ -76,6 +77,11 @@ export interface DatePickerRenderProps { * @selector [data-required] */ isRequired: boolean, + /** + * Whether the date picker is currently in a pending state. + * @selector [data-pending] + */ + isPending: boolean, /** * Whether the date picker's popover is currently open. * @selector [data-open] @@ -139,6 +145,7 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function buttonProps, dialogProps, calendarProps, + progressBarProps, descriptionProps, errorMessageProps, ...validation @@ -170,6 +177,7 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function isFocusVisible, isDisabled: props.isDisabled || false, isInvalid: state.isInvalid, + isPending: state.isPending, isOpen: state.isOpen, isReadOnly: props.isReadOnly || false, isRequired: props.isRequired || false @@ -204,7 +212,8 @@ export const DatePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(function errorMessage: errorMessageProps } }], - [FieldErrorContext, validation] + [FieldErrorContext, validation], + [ProgressBarContext, progressBarProps] ]}> @@ -251,6 +261,7 @@ export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(func buttonProps, dialogProps, calendarProps, + progressBarProps, descriptionProps, errorMessageProps, ...validation @@ -282,6 +293,7 @@ export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(func isFocusVisible, isDisabled: props.isDisabled || false, isInvalid: state.isInvalid, + isPending: state.isPending, isOpen: state.isOpen, isReadOnly: props.isReadOnly || false, isRequired: props.isRequired || false @@ -321,7 +333,8 @@ export const DateRangePicker = /*#__PURE__*/ (forwardRef as forwardRefType)(func errorMessage: errorMessageProps } }], - [FieldErrorContext, validation] + [FieldErrorContext, validation], + [ProgressBarContext, progressBarProps] ]}> diff --git a/packages/react-aria-components/src/NumberField.tsx b/packages/react-aria-components/src/NumberField.tsx index e799d970fad..6bbc5c2bac2 100644 --- a/packages/react-aria-components/src/NumberField.tsx +++ b/packages/react-aria-components/src/NumberField.tsx @@ -35,6 +35,7 @@ import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; import {NumberFieldState, useNumberFieldState} from 'react-stately/useNumberFieldState'; +import {ProgressBarContext} from './ProgressBar'; import React, {createContext, ForwardedRef, forwardRef, useRef} from 'react'; import {TextContext} from './Text'; import {useLocale} from 'react-aria/I18nProvider'; @@ -55,6 +56,11 @@ export interface NumberFieldRenderProps { * @selector [data-required] */ isRequired: boolean, + /** + * Whether the number field is currently in a pending state. + * @selector [data-pending] + */ + isPending: boolean, /** * State of the number field. */ @@ -96,6 +102,7 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function inputProps, incrementButtonProps, decrementButtonProps, + progressBarProps, descriptionProps, errorMessageProps, ...validation @@ -111,6 +118,7 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function state, isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid || false, + isPending: state.isPending, isRequired: props.isRequired || false }, defaultClassName: 'react-aria-NumberField' @@ -138,7 +146,8 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function errorMessage: errorMessageProps } }], - [FieldErrorContext, validation] + [FieldErrorContext, validation], + [ProgressBarContext, progressBarProps] ]}> {props.name && } diff --git a/packages/react-aria-components/src/SearchField.tsx b/packages/react-aria-components/src/SearchField.tsx index ab246763de5..089bde9f5d5 100644 --- a/packages/react-aria-components/src/SearchField.tsx +++ b/packages/react-aria-components/src/SearchField.tsx @@ -35,6 +35,7 @@ import {GlobalDOMAttributes} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; +import {ProgressBarContext} from './ProgressBar'; import React, {createContext, ForwardedRef, useRef} from 'react'; import {SearchFieldState, useSearchFieldState} from 'react-stately/useSearchFieldState'; import {TextContext} from './Text'; @@ -65,6 +66,11 @@ export interface SearchFieldRenderProps { * @selector [data-required] */ isRequired: boolean, + /** + * Whether the search field is currently in a pending state. + * @selector [data-pending] + */ + isPending: boolean, /** * State of the search field. */ @@ -98,7 +104,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search validationBehavior }); - let {labelProps, inputProps, clearButtonProps, descriptionProps, errorMessageProps, ...validation} = useSearchField({ + let {labelProps, inputProps, clearButtonProps, progressBarProps, descriptionProps, errorMessageProps, ...validation} = useSearchField({ ...removeDataAttributes(props), label, validationBehavior @@ -112,6 +118,7 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search isInvalid: validation.isInvalid || false, isReadOnly: props.isReadOnly || false, isRequired: props.isRequired || false, + isPending: state.isPending, state }, defaultClassName: 'react-aria-SearchField' @@ -130,7 +137,8 @@ export const SearchField = /*#__PURE__*/ createHideableComponent(function Search data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} data-readonly={props.isReadOnly || undefined} - data-required={props.isRequired || undefined}> + data-required={props.isRequired || undefined} + data-pending={state.isPending || undefined}> {renderProps.children} diff --git a/packages/react-aria-components/src/TextField.tsx b/packages/react-aria-components/src/TextField.tsx index 93091790cbb..cc18c95ec7d 100644 --- a/packages/react-aria-components/src/TextField.tsx +++ b/packages/react-aria-components/src/TextField.tsx @@ -35,9 +35,11 @@ import {GlobalDOMAttributes} from '@react-types/shared'; import {GroupContext} from './Group'; import {InputContext} from './Input'; import {LabelContext} from './Label'; +import {ProgressBarContext} from './ProgressBar'; import React, {createContext, ForwardedRef, useCallback, useRef, useState} from 'react'; import {TextAreaContext} from './TextArea'; import {TextContext} from './Text'; +import {useTextFieldState} from 'react-stately/useTextFieldState'; export interface TextFieldRenderProps { /** @@ -59,7 +61,12 @@ export interface TextFieldRenderProps { * Whether the text field is required. * @selector [data-required] */ - isRequired: boolean + isRequired: boolean, + /** + * Whether the text field is currently in a pending state. + * @selector [data-pending] + */ + isPending: boolean } export interface TextFieldProps extends Omit, RACValidation, Omit, SlotProps, RenderProps, GlobalDOMAttributes { @@ -87,12 +94,16 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel !props['aria-label'] && !props['aria-labelledby'] ); let [inputElementType, setInputElementType] = useState('input'); - let {labelProps, inputProps, descriptionProps, errorMessageProps, ...validation} = useTextField({ + let state = useTextFieldState({ + ...props, + validationBehavior + }); + let {labelProps, inputProps, descriptionProps, errorMessageProps, progressBarProps, ...validation} = useTextField({ ...removeDataAttributes(props), inputElementType, label, validationBehavior - }, inputRef); + }, state, inputRef); // Intercept setting the input ref so we can determine what kind of element we have. // useTextField uses this to determine what props to include. @@ -109,7 +120,8 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel isDisabled: props.isDisabled || false, isInvalid: validation.isInvalid, isReadOnly: props.isReadOnly || false, - isRequired: props.isRequired || false + isRequired: props.isRequired || false, + isPending: state.isPending }, defaultClassName: 'react-aria-TextField' }); @@ -126,7 +138,8 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel data-disabled={props.isDisabled || undefined} data-invalid={validation.isInvalid || undefined} data-readonly={props.isReadOnly || undefined} - data-required={props.isRequired || undefined}> + data-required={props.isRequired || undefined} + data-pending={state.isPending || undefined}> {renderProps.children} diff --git a/packages/react-aria-components/src/ToggleButton.tsx b/packages/react-aria-components/src/ToggleButton.tsx index 8d11fc913e3..4b98b2000e3 100644 --- a/packages/react-aria-components/src/ToggleButton.tsx +++ b/packages/react-aria-components/src/ToggleButton.tsx @@ -11,7 +11,6 @@ */ import {AriaToggleButtonProps, useToggleButton} from 'react-aria/useToggleButton'; - import {ButtonRenderProps} from './Button'; import { ClassNameOrFunction, @@ -26,6 +25,7 @@ import {filterDOMProps} from 'react-aria/filterDOMProps'; import {forwardRefType, GlobalDOMAttributes, Key} from '@react-types/shared'; import {HoverEvents} from '@react-types/shared'; import {mergeProps} from 'react-aria/mergeProps'; +import {ProgressBarContext} from './ProgressBar'; import React, {createContext, ForwardedRef, forwardRef, useContext} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {ToggleGroupStateContext} from './ToggleButtonGroup'; @@ -34,7 +34,7 @@ import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; import {useToggleButtonGroupItem} from 'react-aria/useToggleButtonGroup'; -export interface ToggleButtonRenderProps extends Omit { +export interface ToggleButtonRenderProps extends ButtonRenderProps { /** * Whether the button is currently selected. * @selector [data-selected] @@ -71,7 +71,7 @@ export const ToggleButton = /*#__PURE__*/ (forwardRef as forwardRefType)(functio } } : props); - let {buttonProps, isPressed, isSelected, isDisabled} = groupState && props.id != null + let {buttonProps, progressBarProps, isPressed, isSelected, isDisabled, isPending} = groupState && props.id != null // eslint-disable-next-line react-hooks/rules-of-hooks ? useToggleButtonGroupItem({...props, id: props.id}, groupState, ref) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -82,7 +82,7 @@ export const ToggleButton = /*#__PURE__*/ (forwardRef as forwardRefType)(functio let renderProps = useRenderProps({ ...props, id: undefined, - values: {isHovered, isPressed, isFocused, isSelected: state.isSelected, isFocusVisible, isDisabled, state}, + values: {isHovered, isPressed, isFocused, isSelected: state.isSelected, isFocusVisible, isDisabled, isPending, state}, defaultClassName: 'react-aria-ToggleButton' }); @@ -100,9 +100,12 @@ export const ToggleButton = /*#__PURE__*/ (forwardRef as forwardRefType)(functio data-pressed={isPressed || undefined} data-selected={isSelected || undefined} data-hovered={isHovered || undefined} - data-focus-visible={isFocusVisible || undefined}> + data-focus-visible={isFocusVisible || undefined} + data-pending={state.isPending || undefined}> - {renderProps.children} + + {renderProps.children} + ); diff --git a/packages/react-aria-components/src/Tooltip.tsx b/packages/react-aria-components/src/Tooltip.tsx index 42f3fb78669..bd5172428cc 100644 --- a/packages/react-aria-components/src/Tooltip.tsx +++ b/packages/react-aria-components/src/Tooltip.tsx @@ -136,11 +136,21 @@ export const Tooltip = /*#__PURE__*/ (forwardRef as forwardRefType)(function Too return null; } - return ( + let res = ( ); + + if (!contextState) { + res = ( + + {res} + + ); + } + + return res; }); function TooltipInner(props: TooltipProps & {isExiting: boolean, tooltipRef: RefObject}) { diff --git a/packages/react-aria-components/stories/Button.stories.tsx b/packages/react-aria-components/stories/Button.stories.tsx index a3e7df7b98b..b56d5d6866d 100644 --- a/packages/react-aria-components/stories/Button.stories.tsx +++ b/packages/react-aria-components/stories/Button.stories.tsx @@ -49,6 +49,15 @@ export const PendingButtonTooltip: ButtonStory = { } }; +export const ReactAction: ButtonStory = { + render: (args) => , + args: { + children: 'Press me', + // @ts-ignore + error: false + } +}; + function PendingButtonExample(props) { let [isPending, setPending] = useState(false); @@ -113,6 +122,57 @@ function PendingButtonTooltipExample(props) { ); } +function ReactActionExample(props) { + let ref = useRef(null); + return ( + + ); +} + export const RippleButtonExample: ButtonStory = { render: () => ( Press me diff --git a/packages/react-aria-components/stories/ColorField.stories.tsx b/packages/react-aria-components/stories/ColorField.stories.tsx index 342f03e0a5b..9c5feeb6ceb 100644 --- a/packages/react-aria-components/stories/ColorField.stories.tsx +++ b/packages/react-aria-components/stories/ColorField.stories.tsx @@ -10,13 +10,15 @@ * governing permissions and limitations under the License. */ -import {ColorField, ColorFieldProps} from '../src/ColorField'; +import {Color, parseColor} from 'react-stately/Color'; +import {ColorField, ColorFieldProps} from '../src/ColorField'; import {FieldError} from '../src/FieldError'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {Meta, StoryObj} from '@storybook/react'; -import React from 'react'; +import {ProgressCircle} from 'vanilla-starter/ProgressCircle'; +import React, {useState} from 'react'; import './styles.css'; export default { @@ -49,3 +51,49 @@ export const ColorFieldExample: ColorFieldStory = { defaultValue: '#f00' } }; + +let colorActionCache = new Map>(); + +function ColorActionResults({colorKey}: {colorKey: string}) { + let promise = colorActionCache.get(colorKey); + if (!promise) { + colorActionCache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + colorActionCache.set(colorKey, promise); + } + React.use(promise); + return
Results for: {colorKey || '(empty)'}
; +} + +function ColorFieldReactActionExample(args) { + let [color, setColor] = useState(() => parseColor('#ff0000')); + let colorKey = color?.toString('hex') ?? ''; + return ( +
+ { + setColor(c); + }}> + {({isPending}) => ( + <> + + + {isPending && } + + )} + + + + +
+ ); +} + +export const ReactAction: ColorFieldStory = { + render: (args) => , + args: { + label: 'Color' + } +}; diff --git a/packages/react-aria-components/stories/DateField.stories.tsx b/packages/react-aria-components/stories/DateField.stories.tsx index eac8d6cde07..8febb81a883 100644 --- a/packages/react-aria-components/stories/DateField.stories.tsx +++ b/packages/react-aria-components/stories/DateField.stories.tsx @@ -14,13 +14,14 @@ import {action} from 'storybook/actions'; import {Button} from '../src/Button'; import clsx from 'clsx'; import {DateField, DateInput, DateSegment} from '../src/DateField'; +import {DateValue, fromAbsolute, getLocalTimeZone, parseAbsoluteToLocal} from '@internationalized/date'; import {FieldError} from '../src/FieldError'; import {Form} from '../src/Form'; -import {fromAbsolute, getLocalTimeZone, parseAbsoluteToLocal} from '@internationalized/date'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {Meta, StoryFn} from '@storybook/react'; -import React from 'react'; +import {ProgressCircle} from 'vanilla-starter/ProgressCircle'; +import React, {useState} from 'react'; import styles from '../example/index.css'; import {TextField} from '../src/TextField'; import './styles.css'; @@ -111,3 +112,44 @@ export const DateFieldAutoFill = (props) => ( ); + +let dateActionCache = new Map>(); + +function DateActionResults({dateKey}: {dateKey: string}) { + let promise = dateActionCache.get(dateKey); + if (!promise) { + dateActionCache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + dateActionCache.set(dateKey, promise); + } + React.use(promise); + return
Results for: {dateKey || '(empty)'}
; +} + +export const ReactAction: DateFieldStory = (args) => { + let [value, setValue] = useState(() => parseAbsoluteToLocal('2024-01-01T01:01:00Z')); + let dateKey = value?.toString() ?? ''; + return ( +
+ { + setValue(v); + }}> + {({isPending}) => ( + <> + + + {segment => } + + {isPending && } + + )} + + + + +
+ ); +}; diff --git a/packages/react-aria-components/stories/DatePicker.stories.tsx b/packages/react-aria-components/stories/DatePicker.stories.tsx index f2674f42260..8823726fb5c 100644 --- a/packages/react-aria-components/stories/DatePicker.stories.tsx +++ b/packages/react-aria-components/stories/DatePicker.stories.tsx @@ -16,6 +16,8 @@ import {Calendar, CalendarCell, CalendarGrid, RangeCalendar} from '../src/Calend import clsx from 'clsx'; import {DateInput, DateSegment} from '../src/DateField'; import {DatePicker, DateRangePicker} from '../src/DatePicker'; +import {DateRange} from 'react-stately/useDateRangePickerState'; +import {DateValue, parseAbsoluteToLocal} from '@internationalized/date'; import {Dialog} from '../src/Dialog'; import {Form} from '../src/Form'; import {Group} from '../src/Group'; @@ -23,9 +25,9 @@ import {Heading} from '../src/Heading'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {Meta, StoryFn} from '@storybook/react'; -import {parseAbsoluteToLocal} from '@internationalized/date'; import {Popover} from '../src/Popover'; -import React from 'react'; +import {ProgressCircle} from 'vanilla-starter/ProgressCircle'; +import React, {useState} from 'react'; import styles from '../example/index.css'; import {TextField} from '../src/TextField'; import './styles.css'; @@ -256,3 +258,134 @@ export const DatePickerAutofill = (props) => ( ); + +let datePickerActionCache = new Map>(); + +function DatePickerActionResults({dateKey}: {dateKey: string}) { + let promise = datePickerActionCache.get(dateKey); + if (!promise) { + datePickerActionCache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + datePickerActionCache.set(dateKey, promise); + } + React.use(promise); + return
Results for: {dateKey || '(empty)'}
; +} + +export const ReactAction: DatePickerStory = (args) => { + let [value, setValue] = useState(() => parseAbsoluteToLocal('2024-01-01T00:00:00Z')); + let dateKey = value?.toString() ?? ''; + return ( +
+ setValue(v)}> + {({isPending}) => ( + <> + + + + {segment => } + + + {isPending && } + + + + +
+ + + +
+ + {date => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />} + +
+
+
+ + )} +
+ + + +
+ ); +}; + +let dateRangePickerActionCache = new Map>(); + +function DateRangePickerActionResults({rangeKey}: {rangeKey: string}) { + let promise = dateRangePickerActionCache.get(rangeKey); + if (!promise) { + dateRangePickerActionCache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + dateRangePickerActionCache.set(rangeKey, promise); + } + React.use(promise); + return
Results for: {rangeKey || '(empty)'}
; +} + +export const RangeReactAction: DateRangePickerStory = (args) => { + let [value, setValue] = useState < DateRange | null>(() => ({ + start: parseAbsoluteToLocal('2024-01-01T00:00:00Z'), + end: parseAbsoluteToLocal('2024-01-07T00:00:00Z') + })); + let rangeKey = value?.start != null && value?.end != null + ? `${value.start.toString()}–${value.end.toString()}` + : ''; + return ( +
+ setValue(v)}> + {({isPending}) => ( + <> + + +
+ + {segment => } + + + + {segment => } + +
+ + {isPending && } +
+ + + +
+ + + +
+ + {date => ({display: isOutsideMonth ? 'none' : '', textAlign: 'center', cursor: 'default', background: isSelected ? 'blue' : ''})} />} + +
+
+
+ + )} +
+ + + +
+ ); +}; diff --git a/packages/react-aria-components/stories/NumberField.stories.tsx b/packages/react-aria-components/stories/NumberField.stories.tsx index ca3e54c33c1..d53d9d5f077 100644 --- a/packages/react-aria-components/stories/NumberField.stories.tsx +++ b/packages/react-aria-components/stories/NumberField.stories.tsx @@ -19,6 +19,7 @@ import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {Meta, StoryObj} from '@storybook/react'; import {NumberField, NumberFieldProps} from '../src/NumberField'; +import {ProgressCircle} from 'vanilla-starter/ProgressCircle'; import React, {useState} from 'react'; import './styles.css'; @@ -101,3 +102,54 @@ export const ArabicNumberFieldExample = { ) }; + +let numberActionCache = new Map>(); + +function NumberActionResults({valueKey}: {valueKey: string}) { + let promise = numberActionCache.get(valueKey); + if (!promise) { + numberActionCache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + numberActionCache.set(valueKey, promise); + } + React.use(promise); + return
Results for: {valueKey}
; +} + +function NumberFieldReactActionExample() { + let [value, setValue] = useState(42); + let valueKey = String(value); + return ( +
+ { + setValue(v); + }}> + {({isPending}) => ( + <> + + + + + + + {isPending && } + + )} + + + + +
+ ); +} + +export const ReactAction: NumberFieldStory = { + render: () => +}; diff --git a/packages/react-aria-components/stories/SearchField.stories.tsx b/packages/react-aria-components/stories/SearchField.stories.tsx index 7bb51e259d9..b987c2120ad 100644 --- a/packages/react-aria-components/stories/SearchField.stories.tsx +++ b/packages/react-aria-components/stories/SearchField.stories.tsx @@ -16,7 +16,8 @@ import {classNames} from '@adobe/react-spectrum/private/utils/classNames'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {Meta, StoryFn} from '@storybook/react'; -import React from 'react'; +import {ProgressCircle} from 'vanilla-starter/ProgressCircle'; +import React, {useState} from 'react'; import {SearchField} from '../src/SearchField'; import styles from '../example/index.css'; import './styles.css'; @@ -37,3 +38,42 @@ export const SearchFieldExample: SearchFieldStory = () => { ); }; + +export const ReactAction: SearchFieldStory = () => { + let [search, setSearch] = useState(''); + return ( +
+ { + setSearch(value); + }}> + {({isPending}) => (<> + + + + {isPending && } + )} + + + + +
+ ); +}; + +let cache = new Map(); + +function Results({search}) { + let promise = cache.get(search); + if (!promise) { + cache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + cache.set(search, promise); + } + + React.use(promise); + return
Results for: {search}
; +} diff --git a/packages/react-aria-components/stories/TextField.stories.tsx b/packages/react-aria-components/stories/TextField.stories.tsx index e2a225417b6..047bc08f9e2 100644 --- a/packages/react-aria-components/stories/TextField.stories.tsx +++ b/packages/react-aria-components/stories/TextField.stories.tsx @@ -18,10 +18,11 @@ import {Form} from '../src/Form'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {Meta, StoryFn} from '@storybook/react'; -import React from 'react'; +import React, {useState} from 'react'; import styles from '../example/index.css'; import {TextField} from '../src/TextField'; import './styles.css'; +import {ProgressCircle} from 'vanilla-starter/ProgressCircle'; export default { title: 'React Aria Components/TextField', @@ -67,3 +68,46 @@ TextFieldSubmitExample.story = { } } }; + +export const ReactAction: TextFieldStory = () => { + let [search, setSearch] = useState(''); + return ( +
+ { + if (value === 'error') { + throw new Error('Error in action'); + } else { + setSearch(value); + } + }}> + {({isPending}) => ( +
+ + + {isPending && } + +
+ )} +
+ + + +
+ ); +}; + +let cache = new Map(); + +function Results({search}) { + let promise = cache.get(search); + if (!promise) { + cache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + cache.set(search, promise); + } + + React.use(promise); + return
Results for: {search}
; +} diff --git a/packages/react-aria-components/stories/TimeField.stories.tsx b/packages/react-aria-components/stories/TimeField.stories.tsx index bf6917294e2..2d31f4395ce 100644 --- a/packages/react-aria-components/stories/TimeField.stories.tsx +++ b/packages/react-aria-components/stories/TimeField.stories.tsx @@ -14,8 +14,11 @@ import clsx from 'clsx'; import {DateInput, DateSegment, TimeField} from '../src/DateField'; import {Label} from '../src/Label'; import {Meta, StoryFn} from '@storybook/react'; -import React from 'react'; +import {ProgressCircle} from 'vanilla-starter/ProgressCircle'; +import React, {useState} from 'react'; import styles from '../example/index.css'; +import {Time} from '@internationalized/date'; +import {TimeValue} from 'react-stately/useTimeFieldState'; import './styles.css'; export default { @@ -33,3 +36,44 @@ export const TimeFieldExample: TimeFieldStory = () => ( ); + +let timeActionCache = new Map>(); + +function TimeActionResults({timeKey}: {timeKey: string}) { + let promise = timeActionCache.get(timeKey); + if (!promise) { + timeActionCache.clear(); + promise = new Promise(resolve => setTimeout(resolve, 2000)); + timeActionCache.set(timeKey, promise); + } + React.use(promise); + return
Results for: {timeKey || '(empty)'}
; +} + +export const ReactAction: TimeFieldStory = (args) => { + let [value, setValue] = useState(() => new Time(9, 0)); + let timeKey = value?.toString() ?? ''; + return ( +
+ { + setValue(v); + }}> + {({isPending}) => ( + <> + + + {segment => } + + {isPending && } + + )} + + + + +
+ ); +}; diff --git a/packages/react-aria-components/stories/ToggleButton.stories.tsx b/packages/react-aria-components/stories/ToggleButton.stories.tsx index d05c1c3874d..0ae68e0433f 100644 --- a/packages/react-aria-components/stories/ToggleButton.stories.tsx +++ b/packages/react-aria-components/stories/ToggleButton.stories.tsx @@ -13,8 +13,11 @@ import {action} from 'storybook/actions'; import {classNames} from '@adobe/react-spectrum/private/utils/classNames'; import {Meta, StoryFn} from '@storybook/react'; +import {ProgressBar} from '../src/ProgressBar'; import React, {useState} from 'react'; import styles from '../example/index.css'; +import * as styles2 from './button-pending.css'; +import {Text} from '../src/Text'; import {ToggleButton} from '../src/ToggleButton'; import './styles.css'; @@ -42,3 +45,31 @@ export const ToggleButtonExample: ToggleButtonStory = () => { ); }; + +export const ReactAction: ToggleButtonStory = (props) => { + return ( + { + await new Promise(resolve => setTimeout(resolve, 3000)); + }}> + {({isPending}) => ( + <> + Toggle + + + + + + + + + + )} + + ); +}; diff --git a/packages/react-aria-components/stories/button-pending.css b/packages/react-aria-components/stories/button-pending.css index 4c3689bb013..509063d2202 100644 --- a/packages/react-aria-components/stories/button-pending.css +++ b/packages/react-aria-components/stories/button-pending.css @@ -5,6 +5,10 @@ align-items: center; box-sizing: border-box; outline: none; + + &[data-selected] { + filter: invert(); + } } .spinner { @@ -25,3 +29,9 @@ .pending { opacity: 0; } + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(5px); } +} \ No newline at end of file diff --git a/packages/react-aria-components/test/ColorField.test.js b/packages/react-aria-components/test/ColorField.test.js index 40e28129f0c..d0338cdb2ce 100644 --- a/packages/react-aria-components/test/ColorField.test.js +++ b/packages/react-aria-components/test/ColorField.test.js @@ -10,12 +10,16 @@ * governing permissions and limitations under the License. */ +jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); + import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {ColorField, ColorFieldContext} from '../src/ColorField'; import {FieldError} from '../src/FieldError'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {parseColor} from 'react-stately/Color'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {Text} from '../src/Text'; import userEvent from '@testing-library/user-event'; @@ -32,6 +36,7 @@ let TestColorField = (props) => ( describe('ColorField', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); @@ -189,4 +194,54 @@ describe('ColorField', () => { let input = getByRole('textbox'); expect(input).toHaveAttribute('form', 'test'); }); + + if (parseInt(React.version, 10) >= 19) { + describe('changeAction', () => { + function ColorFieldChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + {isPending ? : null} + + )} + + ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let input = getByRole('textbox'); + let field = input.closest('.react-aria-ColorField'); + + await user.click(input); + await user.clear(input); + await user.keyboard('00FF00'); + await user.tab(); + + expect(field).toHaveAttribute('data-pending'); + expect(input).toHaveValue('#00FF00'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-describedby', progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': input.id}, 'assertive'); + }); + }); + } }); diff --git a/packages/react-aria-components/test/DateField.test.js b/packages/react-aria-components/test/DateField.test.js index ee37826279b..04c86048ab9 100644 --- a/packages/react-aria-components/test/DateField.test.js +++ b/packages/react-aria-components/test/DateField.test.js @@ -10,12 +10,16 @@ * governing permissions and limitations under the License. */ +jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); + import {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {CalendarDate} from '@internationalized/date'; import {DateField, DateFieldContext, DateInput, DateSegment} from '../src/DateField'; import {FieldError} from '../src/FieldError'; import {I18nProvider} from 'react-aria/I18nProvider'; import {Label} from '../src/Label'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {Text} from '../src/Text'; import userEvent from '@testing-library/user-event'; @@ -25,6 +29,7 @@ describe('DateField', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); @@ -541,4 +546,55 @@ describe('DateField', () => { expect(segements[1]).toHaveTextContent('dd'); expect(segements[2]).toHaveTextContent('yyyy'); }); + + if (parseInt(React.version, 10) >= 19) { + describe('changeAction', () => { + function DateFieldChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + {segment => } + + {isPending ? : null} + + )} + + ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let group = getByRole('group'); + let field = group.closest('.react-aria-DateField'); + let segments = within(group).getAllByRole('spinbutton'); + + await user.click(segments[0]); + await user.keyboard('{ArrowUp}'); + + expect(field).toHaveAttribute('data-pending'); + expect(segments[0]).toHaveTextContent('7'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(group.getAttribute('aria-describedby')).toContain(progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': group.id}, 'assertive'); + }); + }); + } }); diff --git a/packages/react-aria-components/test/DatePicker.test.js b/packages/react-aria-components/test/DatePicker.test.js index 5182c99221d..9c388b05d07 100644 --- a/packages/react-aria-components/test/DatePicker.test.js +++ b/packages/react-aria-components/test/DatePicker.test.js @@ -10,7 +10,10 @@ * governing permissions and limitations under the License. */ +jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); + import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {Button} from '../src/Button'; import {Calendar, CalendarCell, CalendarGrid} from '../src/Calendar'; import {CalendarDate} from '@internationalized/date'; @@ -22,6 +25,7 @@ import {Group} from '../src/Group'; import {Heading} from '../src/Heading'; import {Label} from '../src/Label'; import {Popover} from '../src/Popover'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {Text} from '../src/Text'; import userEvent from '@testing-library/user-event'; @@ -57,6 +61,7 @@ let TestDatePicker = (props) => ( describe('DatePicker', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); it('provides slots', async () => { @@ -393,4 +398,72 @@ describe('DatePicker', () => { let input = group.querySelector('.react-aria-DateInput'); expect(input).toHaveTextContent('5/30/2000'); }); + + if (parseInt(React.version, 10) >= 19) { + describe('changeAction', () => { + function DatePickerChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + + {(segment) => } + + + {isPending ? : null} + + + + +
+ + + +
+ + {(date) => } + +
+
+
+ + )} +
+ ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let group = getByRole('group'); + let field = group.closest('.react-aria-DatePicker'); + let segments = within(group).getAllByRole('spinbutton'); + + await user.click(segments[0]); + await user.keyboard('{ArrowUp}'); + + expect(field).toHaveAttribute('data-pending'); + expect(segments[0]).toHaveTextContent('7'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(group.getAttribute('aria-describedby')).toContain(progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': group.id}, 'assertive'); + }); + }); + } }); diff --git a/packages/react-aria-components/test/DateRangePicker.test.js b/packages/react-aria-components/test/DateRangePicker.test.js index 91ccb3746f7..af6bb4fa0d3 100644 --- a/packages/react-aria-components/test/DateRangePicker.test.js +++ b/packages/react-aria-components/test/DateRangePicker.test.js @@ -10,7 +10,10 @@ * governing permissions and limitations under the License. */ +jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); + import {act, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {Button} from '../src/Button'; import {CalendarCell, CalendarGrid, RangeCalendar} from '../src/Calendar'; import {CalendarDate} from '@internationalized/date'; @@ -22,6 +25,7 @@ import {Group} from '../src/Group'; import {Heading} from '../src/Heading'; import {Label} from '../src/Label'; import {Popover} from '../src/Popover'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {Text} from '../src/Text'; import userEvent from '@testing-library/user-event'; @@ -61,6 +65,7 @@ let TestDateRangePicker = (props) => ( describe('DateRangePicker', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); @@ -432,4 +437,78 @@ describe('DateRangePicker', () => { let text = popover.querySelector('.react-aria-Text'); expect(text).not.toHaveAttribute('id'); }); + + if (parseInt(React.version, 10) >= 19) { + describe('changeAction', () => { + function DateRangePickerChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + + {(segment) => } + + + {(segment) => } + + + {isPending ? : null} + + + + +
+ + + +
+ + {(date) => } + +
+
+
+ + )} +
+ ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let group = getByRole('group'); + let field = group.closest('.react-aria-DateRangePicker'); + let segments = within(group).getAllByRole('spinbutton'); + + await user.click(segments[0]); + await user.keyboard('{ArrowUp}'); + + expect(field).toHaveAttribute('data-pending'); + expect(segments[0]).toHaveTextContent('2'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(group.getAttribute('aria-describedby')).toContain(progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': group.id}, 'assertive'); + }); + }); + } }); diff --git a/packages/react-aria-components/test/NumberField.test.js b/packages/react-aria-components/test/NumberField.test.js index 7dc4c0e6d01..e80c1c56425 100644 --- a/packages/react-aria-components/test/NumberField.test.js +++ b/packages/react-aria-components/test/NumberField.test.js @@ -21,6 +21,7 @@ import {I18nProvider} from 'react-aria/I18nProvider'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; import {NumberField, NumberFieldContext} from '../src/NumberField'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {Text} from '../src/Text'; import userEvent from '@testing-library/user-event'; @@ -41,8 +42,10 @@ let TestNumberField = (props) => ( describe('NumberField', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); + it('provides slots', () => { let {getByRole, getAllByRole} = render(); @@ -496,4 +499,55 @@ describe('NumberField', () => { expect(input.validity.valid).toBe(true); expect(input).not.toHaveAttribute('aria-describedby'); }); + + if (parseInt(React.version, 10) >= 19) { + describe('changeAction', () => { + function NumberFieldChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + + + + + {isPending ? : null} + + )} + + ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let input = getByRole('textbox'); + let field = input.closest('.react-aria-NumberField'); + + await user.click(getByRole('button', {name: 'Increase Amount'})); + + expect(field).toHaveAttribute('data-pending'); + expect(input).toHaveValue('2'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-describedby', progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': input.id}, 'assertive'); + }); + }); + } }); diff --git a/packages/react-aria-components/test/SearchField.test.js b/packages/react-aria-components/test/SearchField.test.js index b448aa1115c..991a9062ac0 100644 --- a/packages/react-aria-components/test/SearchField.test.js +++ b/packages/react-aria-components/test/SearchField.test.js @@ -9,12 +9,15 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {Button} from '../src/Button'; import {FieldError} from '../src/FieldError'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {SearchField, SearchFieldContext} from '../src/SearchField'; import {Text} from '../src/Text'; @@ -33,6 +36,7 @@ let TestSearchField = (props) => ( describe('SearchField', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); @@ -165,4 +169,52 @@ describe('SearchField', () => { let input = getByRole('searchbox'); expect(input).toHaveAttribute('form', 'test'); }); + + if (parseInt(React.version, 10) >= 19) { + describe('changeAction', () => { + function SearchFieldChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + {isPending ? : null} + + )} + + ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let input = getByRole('searchbox'); + let field = input.closest('.react-aria-SearchField'); + + await user.click(input); + await user.clear(input); + await user.keyboard('h'); + + expect(field).toHaveAttribute('data-pending'); + expect(input).toHaveValue('h'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-describedby', progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': input.id}, 'assertive'); + }); + }); + } }); diff --git a/packages/react-aria-components/test/TextField.test.js b/packages/react-aria-components/test/TextField.test.js index 006539026d1..a2ae4e96483 100644 --- a/packages/react-aria-components/test/TextField.test.js +++ b/packages/react-aria-components/test/TextField.test.js @@ -9,11 +9,14 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {FieldError} from '../src/FieldError'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {Text} from '../src/Text'; import {TextArea} from '../src/TextArea'; @@ -32,6 +35,7 @@ let TestTextField = (props) => ( describe('TextField', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); @@ -306,6 +310,52 @@ describe('TextField', () => { await user.click(button); expect(input).toHaveValue('Devon'); }); + + describe('changeAction', () => { + function TextFieldChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + {isPending ? : null} + + )} + + ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let input = getByRole('textbox'); + let field = input.closest('.react-aria-TextField'); + + await user.click(input); + await user.clear(input); + await user.keyboard('h'); + + expect(field).toHaveAttribute('data-pending'); + expect(input).toHaveValue('h'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(input).toHaveAttribute('aria-describedby', progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': input.id}, 'assertive'); + }); + }); } }); }); diff --git a/packages/react-aria-components/test/TimeField.test.js b/packages/react-aria-components/test/TimeField.test.js index 46202fc200a..c4e619a8d4c 100644 --- a/packages/react-aria-components/test/TimeField.test.js +++ b/packages/react-aria-components/test/TimeField.test.js @@ -10,10 +10,14 @@ * governing permissions and limitations under the License. */ +jest.mock('react-aria/src/live-announcer/LiveAnnouncer'); + import {act, installPointerEvent, pointerMap, render, within} from '@react-spectrum/test-utils-internal'; +import {announce} from 'react-aria/private/live-announcer/LiveAnnouncer'; import {DateInput, DateSegment, TimeField, TimeFieldContext} from '../src/DateField'; import {FieldError} from '../src/FieldError'; import {Label} from '../src/Label'; +import {ProgressBar} from '../src/ProgressBar'; import React from 'react'; import {Text} from '../src/Text'; import {Time} from '@internationalized/date'; @@ -24,6 +28,7 @@ describe('TimeField', () => { let user; beforeAll(() => { + jest.useFakeTimers(); user = userEvent.setup({delay: null, pointerMap}); }); @@ -242,4 +247,55 @@ describe('TimeField', () => { expect(getDescription()).not.toContain('Constraints not satisfied'); }); + + if (parseInt(React.version, 10) >= 19) { + describe('changeAction', () => { + function DateFieldChangeActionExample() { + return ( + { + await new Promise(resolve => setTimeout(resolve, 500)); + }}> + {({isPending}) => ( + <> + + + {segment => } + + {isPending ? : null} + + )} + + ); + } + + it('shows ProgressBar while pending', async () => { + let {getByRole, queryByRole} = render(); + let group = getByRole('group'); + let field = group.closest('.react-aria-TimeField'); + let segments = within(group).getAllByRole('spinbutton'); + + await user.click(segments[0]); + await user.keyboard('{ArrowUp}'); + + expect(field).toHaveAttribute('data-pending'); + expect(segments[0]).toHaveTextContent('9'); + + let progressbar = getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(group.getAttribute('aria-describedby')).toContain(progressbar.id); + + expect(announce).toHaveBeenCalledWith({'aria-labelledby': progressbar.id}, 'assertive'); + + await act(async () => { + jest.runAllTimers(); + }); + + expect(field).not.toHaveAttribute('data-pending'); + expect(queryByRole('progressbar')).not.toBeInTheDocument(); + expect(announce).toHaveBeenCalledWith({'aria-labelledby': group.id}, 'assertive'); + }); + }); + } }); diff --git a/packages/react-aria/src/autocomplete/useSearchAutocomplete.ts b/packages/react-aria/src/autocomplete/useSearchAutocomplete.ts index 059d4c5a1f1..9eea35711be 100644 --- a/packages/react-aria/src/autocomplete/useSearchAutocomplete.ts +++ b/packages/react-aria/src/autocomplete/useSearchAutocomplete.ts @@ -17,7 +17,7 @@ import {AriaSearchFieldProps, useSearchField} from '../searchfield/useSearchFiel import {ComboBoxState, MenuTriggerAction} from 'react-stately/useComboBoxState'; import {InputHTMLAttributes} from 'react'; import {mergeProps} from '../utils/mergeProps'; -import {SearchFieldProps} from 'react-stately/useSearchFieldState'; +import {SearchFieldProps, useSearchFieldState} from 'react-stately/useSearchFieldState'; import {useComboBox} from '../combobox/useComboBox'; export interface SearchAutocompleteProps extends CollectionBase, Omit { @@ -107,11 +107,9 @@ export function useSearchAutocomplete(props: AriaSearchAutocompleteOptions ...otherProps } = props; - let {inputProps, clearButtonProps} = useSearchField({ - ...otherProps, + let searchState = useSearchFieldState({ value: state.inputValue, onChange: state.setInputValue, - autoComplete: 'off', onClear: () => { state.setInputValue(''); if (onClear) { @@ -123,13 +121,15 @@ export function useSearchAutocomplete(props: AriaSearchAutocompleteOptions if (state.selectionManager.focusedKey === null) { onSubmit(value, null); } - }, + } + }); + + let {inputProps, clearButtonProps} = useSearchField({ + ...otherProps, + autoComplete: 'off', onKeyDown, onKeyUp - }, { - value: state.inputValue, - setValue: state.setInputValue - }, inputRef); + }, searchState, inputRef); let {listBoxProps, labelProps, inputProps: comboBoxInputProps, ...validation} = useComboBox( diff --git a/packages/react-aria/src/button/useButton.ts b/packages/react-aria/src/button/useButton.ts index b80ad891f5d..6bca4d1f71d 100644 --- a/packages/react-aria/src/button/useButton.ts +++ b/packages/react-aria/src/button/useButton.ts @@ -18,19 +18,37 @@ import { InputHTMLAttributes, JSXElementConstructor, ReactNode, - RefObject + RefObject, + useEffect, + useRef } from 'react'; -import {AriaLabelingProps, DOMAttributes, FocusableDOMProps, FocusableProps, PressEvents} from '@react-types/shared'; +import {announce} from '../live-announcer/LiveAnnouncer'; +import {AriaLabelingProps, DOMAttributes, DOMProps, FocusableDOMProps, FocusableProps, PressEvents} from '@react-types/shared'; +import {chain} from '../utils/chain'; import {filterDOMProps} from '../utils/filterDOMProps'; +import {getActiveElement} from '../utils/shadowdom/DOMFunctions'; +import {getOwnerDocument} from '../utils/domHelpers'; import {mergeProps} from '../utils/mergeProps'; +import {useAction} from 'react-stately/private/utils/useAction'; import {useFocusable} from '../interactions/useFocusable'; +import {useId} from '../utils/useId'; import {usePress} from '../interactions/usePress'; export interface ButtonProps extends PressEvents, FocusableProps { /** Whether the button is disabled. */ isDisabled?: boolean, /** The content to display in the button. */ - children?: ReactNode + children?: ReactNode, + /** + * Async action that is called when the button is pressed. During the action, the button is in a pending state. + * Only supported in React 19 and later. + */ + action?: () => Promise | void, + /** + * Whether the button is in a pending state. This disables press and hover events + * while retaining focusability, and announces the pending state to screen readers. + */ + isPending?: boolean } export interface AriaBaseButtonProps extends FocusableDOMProps, AriaLabelingProps { @@ -107,8 +125,14 @@ export interface AriaButtonOptions extends Omit { /** Props for the button element. */ buttonProps: T, + /** Props for the progress bar element shown when the button is pending. */ + progressBarProps: DOMProps, /** Whether the button is currently pressed. */ - isPressed: boolean + isPressed: boolean, + /** Whether the button action is pending. */ + isPending: boolean, + /** The last error that occurred within the button's action. */ + actionError: unknown | null } // Order with overrides is important: 'button' should be default @@ -168,11 +192,14 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< }; } + let [onAction, isActionPending, actionError] = useAction(props.action); + let isPending = props.isPending || isActionPending; + let {pressProps, isPressed} = usePress({ onPressStart, onPressEnd, onPressChange, - onPress, + onPress: chain(onPress, onAction), onPressUp, onClick, isDisabled, @@ -184,17 +211,74 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< if (allowFocusWhenDisabled) { focusableProps.tabIndex = isDisabled ? -1 : focusableProps.tabIndex; } - let buttonProps = mergeProps(focusableProps, pressProps, filterDOMProps(props, {labelable: true})); + let buttonProps = mergeProps(additionalProps, focusableProps, pressProps, filterDOMProps(props, {labelable: true})); + buttonProps = useDisableInteractions(buttonProps, isPending); + + let buttonId = useId(buttonProps.id); + let progressId = useId(); + + let ariaLabelledby = buttonProps['aria-labelledby']; + if (isPending) { + // aria-labelledby wins over aria-label + // https://www.w3.org/TR/accname-1.2/#computation-steps + if (ariaLabelledby) { + ariaLabelledby = `${ariaLabelledby} ${progressId}`; + } else if (buttonProps['aria-label']) { + ariaLabelledby = `${buttonId} ${progressId}`; + } + } + + let wasPending = useRef(isPending); + useEffect(() => { + if (!ref.current) { + return; + } + + let message = {'aria-labelledby': ariaLabelledby || buttonId}; + let isFocused = getActiveElement(getOwnerDocument(ref.current)) === ref.current; + if (!wasPending.current && isFocused && isPending) { + announce(message, 'assertive'); + } else if (wasPending.current && isFocused && !isPending) { + announce(message, 'assertive'); + } + wasPending.current = isPending; + }, [isPending, ref, ariaLabelledby, buttonId]); return { isPressed, // Used to indicate press state for visual - buttonProps: mergeProps(additionalProps, buttonProps, { + buttonProps: mergeProps(buttonProps, { + id: buttonId, 'aria-haspopup': props['aria-haspopup'], 'aria-expanded': props['aria-expanded'], 'aria-controls': props['aria-controls'], 'aria-pressed': props['aria-pressed'], 'aria-current': props['aria-current'], - 'aria-disabled': props['aria-disabled'] - }) + 'aria-disabled': isPending ? 'true' : props['aria-disabled'], + 'aria-labelledby': ariaLabelledby, + // When the button is in a pending state, we want to stop implicit form submission (ie. when the user presses enter on a text input). + // We do this by changing the button's type to button. + type: buttonProps.type === 'submit' && isPending ? 'button' : buttonProps.type + }), + progressBarProps: { + id: progressId + }, + isPending, + actionError }; } + +// Events to preserve when isPending is true (for tooltips and other overlays) +const PRESERVED_EVENT_PATTERN = /Focus|Blur|Hover|Pointer(Enter|Leave|Over|Out)|Mouse(Enter|Leave|Over|Out)/; + +function useDisableInteractions(props, isPending) { + if (isPending) { + for (const key in props) { + if (key.startsWith('on') && !PRESERVED_EVENT_PATTERN.test(key)) { + props[key] = undefined; + } + } + props.href = undefined; + props.target = undefined; + } + return props; +} diff --git a/packages/react-aria/src/button/useToggleButton.ts b/packages/react-aria/src/button/useToggleButton.ts index bf42e4511b3..4450683fdec 100644 --- a/packages/react-aria/src/button/useToggleButton.ts +++ b/packages/react-aria/src/button/useToggleButton.ts @@ -24,13 +24,19 @@ import {DOMAttributes} from '@react-types/shared'; import {mergeProps} from '../utils/mergeProps'; import {ToggleState} from 'react-stately/useToggleState'; -export interface ToggleButtonProps extends ButtonProps { +export interface ToggleButtonProps extends Omit { /** Whether the element should be selected (controlled). */ isSelected?: boolean, /** Whether the element should be selected (uncontrolled). */ defaultSelected?: boolean, /** Handler that is called when the element's selection state changes. */ - onChange?: (isSelected: boolean) => void + onChange?: (isSelected: boolean) => void, + /** + * Async action that is called when the toggle button's state changes. + * During the action, the button is in a pending state. + * Only supported in React 19 and later. + */ + changeAction?: (isSelected: boolean) => void | Promise } export interface AriaToggleButtonProps extends ToggleButtonProps, Omit, AriaButtonElementTypeProps {} @@ -57,8 +63,9 @@ export function useToggleButton(props: AriaToggleButtonOptions, sta */ export function useToggleButton(props: AriaToggleButtonOptions, state: ToggleState, ref: RefObject): ToggleButtonAria> { const {isSelected} = state; - const {isPressed, buttonProps} = useButton({ + const {isPressed, buttonProps, progressBarProps, isPending} = useButton({ ...props, + isPending: props.isPending || state.isPending, onPress: chain(state.toggle, props.onPress) }, ref); @@ -68,6 +75,8 @@ export function useToggleButton(props: AriaToggleButtonOptions, sta isDisabled: props.isDisabled || false, buttonProps: mergeProps(buttonProps, { 'aria-pressed': isSelected - }) + }), + progressBarProps, + isPending }; } diff --git a/packages/react-aria/src/button/useToggleButtonGroup.ts b/packages/react-aria/src/button/useToggleButtonGroup.ts index 1751df1ad1a..cb82fd4adae 100644 --- a/packages/react-aria/src/button/useToggleButtonGroup.ts +++ b/packages/react-aria/src/button/useToggleButtonGroup.ts @@ -51,7 +51,7 @@ export function useToggleButtonGroup(props: AriaToggleButtonGroupProps, state: T }; } -export interface AriaToggleButtonGroupItemProps extends Omit, 'id' | 'isSelected' | 'defaultSelected' | 'onChange'> { +export interface AriaToggleButtonGroupItemProps extends Omit, 'id' | 'isSelected' | 'defaultSelected' | 'onChange' | 'changeAction' | 'isPending'> { /** An identifier for the item in the `selectedKeys` of a ToggleButtonGroup. */ id: Key } @@ -73,6 +73,7 @@ export function useToggleButtonGroupItem(props: AriaToggleButtonGroupItemOptions let toggleState: ToggleState = { isSelected: state.selectedKeys.has(props.id), defaultSelected: false, + isPending: false, // ??? setSelected(isSelected) { state.setSelected(props.id, isSelected); }, @@ -81,7 +82,7 @@ export function useToggleButtonGroupItem(props: AriaToggleButtonGroupItemOptions } }; - let {isPressed, isSelected, isDisabled, buttonProps} = useToggleButton({ + let {isPressed, isSelected, isDisabled, isPending, buttonProps, progressBarProps} = useToggleButton({ ...props, id: undefined, isDisabled: props.isDisabled || state.isDisabled @@ -96,6 +97,8 @@ export function useToggleButtonGroupItem(props: AriaToggleButtonGroupItemOptions isPressed, isSelected, isDisabled, - buttonProps + buttonProps, + progressBarProps, + isPending }; } diff --git a/packages/react-aria/src/color/useColorChannelField.ts b/packages/react-aria/src/color/useColorChannelField.ts index 93eefbdf0d9..f04f710f720 100644 --- a/packages/react-aria/src/color/useColorChannelField.ts +++ b/packages/react-aria/src/color/useColorChannelField.ts @@ -26,6 +26,7 @@ export function useColorChannelField(props: AriaColorChannelFieldProps, state: C let {locale} = useLocale(); return useNumberField({ ...props, + changeAction: undefined, value: undefined, defaultValue: undefined, onChange: undefined, diff --git a/packages/react-aria/src/color/useColorField.ts b/packages/react-aria/src/color/useColorField.ts index 3f78ca7bb4d..6d3417e6392 100644 --- a/packages/react-aria/src/color/useColorField.ts +++ b/packages/react-aria/src/color/useColorField.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaLabelingProps, AriaValidationProps, DOMAttributes, FocusableDOMProps, TextInputDOMProps, ValidationResult} from '@react-types/shared'; +import {AriaLabelingProps, AriaValidationProps, DOMAttributes, DOMProps, FocusableDOMProps, TextInputDOMProps, ValidationResult} from '@react-types/shared'; import {ColorFieldProps, ColorFieldState} from 'react-stately/useColorFieldState'; import { InputHTMLAttributes, @@ -41,7 +41,9 @@ export interface ColorFieldAria extends ValidationResult { /** Props for the text field's description element, if any. */ descriptionProps: DOMAttributes, /** Props for the text field's error message element, if any. */ - errorMessageProps: DOMAttributes + errorMessageProps: DOMAttributes, + /** Props for the progress bar element shown when the action is pending. */ + progressBarProps: DOMProps } /** @@ -113,6 +115,7 @@ export function useColorField( let {inputProps, ...otherProps} = useFormattedTextField({ ...props, + changeAction: undefined, id: inputId, value: inputValue, // Intentionally invalid value that will be ignored by onChange during form reset diff --git a/packages/react-aria/src/datepicker/useDateField.ts b/packages/react-aria/src/datepicker/useDateField.ts index 06307d555bb..1b8f594178e 100644 --- a/packages/react-aria/src/datepicker/useDateField.ts +++ b/packages/react-aria/src/datepicker/useDateField.ts @@ -15,11 +15,11 @@ import {createFocusManager, FocusManager} from '../focus/FocusScope'; import {DateFieldProps, DateFieldState, DateValue} from 'react-stately/useDateFieldState'; import {filterDOMProps} from '../utils/filterDOMProps'; import {InputHTMLAttributes, useEffect, useMemo, useRef} from 'react'; +// @ts-ignore import intlMessages from '../../intl/datepicker/*.json'; import {mergeProps} from '../utils/mergeProps'; import {TimeFieldState, TimePickerProps, TimeValue} from 'react-stately/useTimeFieldState'; import {useDatePickerGroup} from './useDatePickerGroup'; -// @ts-ignore import {useDescription} from '../utils/useDescription'; import {useField} from '../label/useField'; import {useFocusWithin} from '../interactions/useFocusWithin'; @@ -50,7 +50,9 @@ export interface DateFieldAria extends ValidationResult { /** Props for the description element, if any. */ descriptionProps: DOMAttributes, /** Props for the error message element, if any. */ - errorMessageProps: DOMAttributes + errorMessageProps: DOMAttributes, + /** Props for the progress bar element shown when the action is pending. */ + progressBarProps: DOMProps } // Data that is passed between useDateField and useDateSegment. @@ -76,9 +78,10 @@ export const focusManagerSymbol: string = '__reactAriaDateFieldFocusManager'; */ export function useDateField(props: AriaDateFieldOptions, state: DateFieldState, ref: RefObject): DateFieldAria { let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ + let {labelProps, fieldProps, descriptionProps, errorMessageProps, progressBarProps} = useField({ ...props, labelElementType: 'span', + isPending: state.isPending, isInvalid, errorMessage: props.errorMessage || validationErrors }); @@ -201,6 +204,7 @@ export function useDateField(props: AriaDateFieldOptions inputProps, descriptionProps, errorMessageProps, + progressBarProps, isInvalid, validationErrors, validationDetails @@ -220,7 +224,7 @@ export interface AriaTimeFieldOptions extends AriaTimeField * Each part of a time value is displayed in an individually editable segment. */ export function useTimeField(props: AriaTimeFieldOptions, state: TimeFieldState, ref: RefObject): DateFieldAria { - let res = useDateField(props, state, ref); + let res = useDateField({...props, changeAction: undefined}, state, ref); res.inputProps.value = state.timeValue?.toString() || ''; return res; } diff --git a/packages/react-aria/src/datepicker/useDatePicker.ts b/packages/react-aria/src/datepicker/useDatePicker.ts index 8d233d4cf9e..3e8543eb029 100644 --- a/packages/react-aria/src/datepicker/useDatePicker.ts +++ b/packages/react-aria/src/datepicker/useDatePicker.ts @@ -53,6 +53,8 @@ export interface DatePickerAria extends ValidationResult { descriptionProps: DOMAttributes, /** Props for the error message element, if any. */ errorMessageProps: DOMAttributes, + /** Props for the progress bar element shown when the action is pending. */ + progressBarProps: DOMProps, /** Props for the popover dialog. */ dialogProps: AriaDialogProps, /** Props for the calendar within the popover dialog. */ @@ -70,9 +72,10 @@ export function useDatePicker(props: AriaDatePickerProps let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/datepicker'); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ + let {labelProps, fieldProps, descriptionProps, errorMessageProps, progressBarProps} = useField({ ...props, labelElementType: 'span', + isPending: state.isPending, isInvalid, errorMessage: props.errorMessage || validationErrors }); @@ -167,6 +170,7 @@ export function useDatePicker(props: AriaDatePickerProps }, descriptionProps, errorMessageProps, + progressBarProps, buttonProps: { ...descProps, id: buttonId, diff --git a/packages/react-aria/src/datepicker/useDateRangePicker.ts b/packages/react-aria/src/datepicker/useDateRangePicker.ts index 91c35cc455a..867bdacefce 100644 --- a/packages/react-aria/src/datepicker/useDateRangePicker.ts +++ b/packages/react-aria/src/datepicker/useDateRangePicker.ts @@ -51,6 +51,8 @@ export interface DateRangePickerAria extends ValidationResult { descriptionProps: DOMAttributes, /** Props for the error message element, if any. */ errorMessageProps: DOMAttributes, + /** Props for the progress bar element shown when the action is pending. */ + progressBarProps: DOMProps, /** Props for the popover dialog. */ dialogProps: AriaDialogProps, /** Props for the range calendar within the popover dialog. */ @@ -65,9 +67,10 @@ export interface DateRangePickerAria extends ValidationResult { export function useDateRangePicker(props: AriaDateRangePickerProps, state: DateRangePickerState, ref: RefObject): DateRangePickerAria { let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/datepicker'); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ + let {labelProps, fieldProps, descriptionProps, errorMessageProps, progressBarProps} = useField({ ...props, labelElementType: 'span', + isPending: state.isPending, isInvalid, errorMessage: props.errorMessage || validationErrors }); @@ -228,6 +231,7 @@ export function useDateRangePicker(props: AriaDateRangePick }, descriptionProps, errorMessageProps, + progressBarProps, calendarProps: { autoFocus: true, value: state.dateRange?.start && state.dateRange.end ? state.dateRange as DateRange : null, diff --git a/packages/react-aria/src/label/useField.ts b/packages/react-aria/src/label/useField.ts index 1aa928b24e9..304c59c5c20 100644 --- a/packages/react-aria/src/label/useField.ts +++ b/packages/react-aria/src/label/useField.ts @@ -10,18 +10,25 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, HelpTextProps, Validation} from '@react-types/shared'; +import {announce} from '../live-announcer/LiveAnnouncer'; +import {DOMAttributes, DOMProps, HelpTextProps, Validation} from '@react-types/shared'; import {LabelAria, LabelAriaProps, useLabel} from './useLabel'; import {mergeProps} from '../utils/mergeProps'; -import {useSlotId} from '../utils/useId'; +import {useEffect, useRef} from 'react'; +import {useId, useSlotId} from '../utils/useId'; -export interface AriaFieldProps extends LabelAriaProps, HelpTextProps, Omit, 'isRequired'> {} +export interface AriaFieldProps extends LabelAriaProps, HelpTextProps, Omit, 'isRequired'> { + /** Whether the field action is pending. */ + isPending?: boolean +} export interface FieldAria extends LabelAria { /** Props for the description element, if any. */ descriptionProps: DOMAttributes, /** Props for the error message element, if any. */ - errorMessageProps: DOMAttributes + errorMessageProps: DOMAttributes, + /** Props for the progress bar element shown when the action is pending. */ + progressBarProps: DOMProps } /** @@ -35,16 +42,31 @@ export function useField(props: AriaFieldProps): FieldAria { let descriptionId = useSlotId([Boolean(description), Boolean(errorMessage), isInvalid, validationState]); let errorMessageId = useSlotId([Boolean(description), Boolean(errorMessage), isInvalid, validationState]); + let progressId = useId(); fieldProps = mergeProps(fieldProps, { 'aria-describedby': [ descriptionId, // Use aria-describedby for error message because aria-errormessage is unsupported using VoiceOver or NVDA. See https://github.com/adobe/react-spectrum/issues/1346#issuecomment-740136268 errorMessageId, - props['aria-describedby'] + props['aria-describedby'], + props.isPending ? progressId : undefined ].filter(Boolean).join(' ') || undefined }); + let wasPending = useRef(props.isPending); + useEffect(() => { + // Announce the progressbar when the field enters the pending state, and the field itself when it leaves the pending state. + if (!wasPending.current && props.isPending && document.getElementById(progressId)) { + let message = {'aria-labelledby': progressId}; + announce(message, 'assertive'); + } else if (wasPending.current && !props.isPending && fieldProps.id && document.getElementById(fieldProps.id)) { + let message = {'aria-labelledby': fieldProps.id}; + announce(message, 'assertive'); + } + wasPending.current = props.isPending; + }, [props.isPending, progressId, fieldProps]); + return { labelProps, fieldProps, @@ -53,6 +75,9 @@ export function useField(props: AriaFieldProps): FieldAria { }, errorMessageProps: { id: errorMessageId + }, + progressBarProps: { + id: progressId } }; } diff --git a/packages/react-aria/src/numberfield/useNumberField.ts b/packages/react-aria/src/numberfield/useNumberField.ts index cc5897ca799..f0e81ce96f4 100644 --- a/packages/react-aria/src/numberfield/useNumberField.ts +++ b/packages/react-aria/src/numberfield/useNumberField.ts @@ -28,12 +28,12 @@ import { import {filterDOMProps} from '../utils/filterDOMProps'; import {flushSync} from 'react-dom'; import {getActiveElement, getEventTarget} from '../utils/shadowdom/DOMFunctions'; +// @ts-ignore import intlMessages from '../../intl/numberfield/*.json'; import {isAndroid, isIOS, isIPhone} from '../utils/platform'; import {mergeProps} from '../utils/mergeProps'; import {NumberFieldProps, NumberFieldState} from 'react-stately/useNumberFieldState'; import {privateValidationStateProp} from 'react-stately/private/form/useFormValidationState'; -// @ts-ignore import {useFocus} from '../interactions/useFocus'; import {useFocusWithin} from '../interactions/useFocusWithin'; import {useFormattedTextField} from '../textfield/useFormattedTextField'; @@ -70,7 +70,9 @@ export interface NumberFieldAria extends ValidationResult { /** Props for the number field's description element, if any. */ descriptionProps: DOMAttributes, /** Props for the number field's error message element, if any. */ - errorMessageProps: DOMAttributes + errorMessageProps: DOMAttributes, + /** Props for the progress bar element shown when the action is pending. */ + progressBarProps: DOMProps } /** @@ -247,9 +249,10 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt }, [commit, commitValidation]); let {isInvalid, validationErrors, validationDetails} = state.displayValidation; - let {labelProps, inputProps: textFieldProps, descriptionProps, errorMessageProps} = useFormattedTextField({ + let {labelProps, inputProps: textFieldProps, descriptionProps, errorMessageProps, progressBarProps} = useFormattedTextField({ ...otherProps, ...domProps, + changeAction: undefined, // These props are added to a hidden input rather than the formatted textfield. name: undefined, form: undefined, @@ -377,6 +380,7 @@ export function useNumberField(props: AriaNumberFieldProps, state: NumberFieldSt decrementButtonProps, errorMessageProps, descriptionProps, + progressBarProps, isInvalid, validationErrors, validationDetails diff --git a/packages/react-aria/src/searchfield/useSearchField.ts b/packages/react-aria/src/searchfield/useSearchField.ts index a5e076a27f9..31146da98e2 100644 --- a/packages/react-aria/src/searchfield/useSearchField.ts +++ b/packages/react-aria/src/searchfield/useSearchField.ts @@ -13,7 +13,7 @@ import {AriaButtonProps} from '../button/useButton'; import {AriaTextFieldProps, useTextField} from '../textfield/useTextField'; import {chain} from '../utils/chain'; -import {DOMAttributes, RefObject, ValidationResult} from '@react-types/shared'; +import {DOMAttributes, DOMProps, RefObject, ValidationResult} from '@react-types/shared'; import {InputHTMLAttributes, LabelHTMLAttributes} from 'react'; // @ts-ignore import intlMessages from '../../intl/searchfield/*.json'; @@ -39,6 +39,8 @@ export interface SearchFieldAria extends ValidationResult { inputProps: InputHTMLAttributes, /** Props for the clear button. */ clearButtonProps: AriaButtonProps, + /** Props for the progress bar element shown when the action is pending. */ + progressBarProps: DOMProps, /** Props for the searchfield's description element, if any. */ descriptionProps: DOMAttributes, /** Props for the searchfield's error message element, if any. */ @@ -61,7 +63,7 @@ export function useSearchField( isDisabled, isReadOnly, onSubmit, - onClear, + submitAction, type = 'search' } = props; @@ -78,9 +80,9 @@ export function useSearchField( // for backward compatibility; // otherwise, "Enter" on an input would trigger a form submit, the default browser behavior - if (key === 'Enter' && onSubmit) { + if (key === 'Enter' && (onSubmit || submitAction)) { e.preventDefault(); - onSubmit(state.value); + state.submit(); } if (key === 'Escape') { @@ -90,20 +92,13 @@ export function useSearchField( e.continuePropagation(); } else { e.preventDefault(); - state.setValue(''); - if (onClear) { - onClear(); - } + state.clear(); } } }; let onClearButtonClick = () => { - state.setValue(''); - - if (onClear) { - onClear(); - } + state.clear(); }; let onPressStart = () => { @@ -112,13 +107,11 @@ export function useSearchField( inputRef.current?.focus(); }; - let {labelProps, inputProps, descriptionProps, errorMessageProps, ...validation} = useTextField({ + let {labelProps, inputProps, descriptionProps, errorMessageProps, progressBarProps, ...validation} = useTextField({ ...props, - value: state.value, - onChange: state.setValue, onKeyDown: !isReadOnly ? chain(onKeyDown, props.onKeyDown) : props.onKeyDown, type - }, inputRef); + }, state, inputRef); return { labelProps, @@ -135,6 +128,7 @@ export function useSearchField( onPress: onClearButtonClick, onPressStart }, + progressBarProps, descriptionProps, errorMessageProps, ...validation diff --git a/packages/react-aria/src/textfield/useFormattedTextField.ts b/packages/react-aria/src/textfield/useFormattedTextField.ts index d9cdc07b1bc..aaf6565de06 100644 --- a/packages/react-aria/src/textfield/useFormattedTextField.ts +++ b/packages/react-aria/src/textfield/useFormattedTextField.ts @@ -19,7 +19,8 @@ import {useEffectEvent} from '../utils/useEffectEvent'; interface FormattedTextFieldState { validate: (val: string) => boolean, - setInputValue: (val: string) => void + setInputValue: (val: string) => void, + isPending?: boolean } @@ -120,7 +121,10 @@ export function useFormattedTextField(props: AriaTextFieldProps, state: Formatte } : null; - let {labelProps, inputProps: textFieldProps, descriptionProps, errorMessageProps, ...validation} = useTextField(props, inputRef); + let {labelProps, inputProps: textFieldProps, descriptionProps, errorMessageProps, progressBarProps, ...validation} = useTextField({ + ...props, + isPending: state.isPending + }, inputRef); let compositionStartState = useRef<{value: string, selectionStart: number | null, selectionEnd: number | null} | null>(null); return { @@ -159,6 +163,7 @@ export function useFormattedTextField(props: AriaTextFieldProps, state: Formatte labelProps, descriptionProps, errorMessageProps, + progressBarProps, ...validation }; } diff --git a/packages/react-aria/src/textfield/useTextField.ts b/packages/react-aria/src/textfield/useTextField.ts index c9afd3dfb7e..387fb3e5548 100644 --- a/packages/react-aria/src/textfield/useTextField.ts +++ b/packages/react-aria/src/textfield/useTextField.ts @@ -14,16 +14,10 @@ import { AriaLabelingProps, AriaValidationProps, DOMAttributes, + DOMProps, FocusableDOMProps, - FocusableProps, - HelpTextProps, - InputBase, - LabelableProps, - TextInputBase, TextInputDOMProps, - Validation, - ValidationResult, - ValueBase + ValidationResult } from '@react-types/shared'; import {filterDOMProps} from '../utils/filterDOMProps'; import {getEventTarget} from '../utils/shadowdom/DOMFunctions'; @@ -36,12 +30,11 @@ import React, { RefObject, useState } from 'react'; -import {useControlledState} from 'react-stately/useControlledState'; +import {TextFieldProps, TextFieldState, useTextFieldState} from 'react-stately/useTextFieldState'; import {useField} from '../label/useField'; import {useFocusable} from '../interactions/useFocusable'; import {useFormReset} from '../utils/useFormReset'; import {useFormValidation} from '../form/useFormValidation'; -import {useFormValidationState} from 'react-stately/private/form/useFormValidationState'; /** * A map of HTML element names and their interface types. @@ -85,7 +78,7 @@ type TextFieldHTMLAttributesType = Pick = TextFieldHTMLAttributesType[T]; -export interface TextFieldProps extends InputBase, Validation, HelpTextProps, FocusableProps, TextInputBase, ValueBase, LabelableProps {} +export type {TextFieldProps}; export interface AriaTextFieldProps extends TextFieldProps, AriaLabelingProps, FocusableDOMProps, TextInputDOMProps, AriaValidationProps { // https://www.w3.org/TR/wai-aria-1.2/#textbox @@ -122,7 +115,9 @@ export interface AriaTextFieldOptions exte /** * An enumerated attribute that defines what action label or icon to preset for the enter key on virtual keyboards. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint). */ - enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send' + enterKeyHint?: 'enter' | 'done' | 'go' | 'next' | 'previous' | 'search' | 'send', + /** Whether an action is pending. */ + isPending?: boolean } /** @@ -140,7 +135,9 @@ export interface TextFieldAria( - props: AriaTextFieldOptions, - ref: TextFieldRefObject -): TextFieldAria { +export function useTextField(props: AriaTextFieldOptions, ref: TextFieldRefObject): TextFieldAria +export function useTextField(props: AriaTextFieldOptions, state: TextFieldState, ref: TextFieldRefObject): TextFieldAria +export function useTextField(props: AriaTextFieldOptions): TextFieldAria { let { inputElementType = 'input', isDisabled = false, @@ -160,15 +156,15 @@ export function useTextField(props.value, props.defaultValue || '', props.onChange); + // Backward compatibility - we used to not require the state argument. + // eslint-disable-next-line react-hooks/rules-of-hooks + let state: TextFieldState = arguments.length === 3 ? arguments[1] : useTextFieldState(props); + let ref: TextFieldRefObject = arguments.length === 3 ? arguments[2] : arguments[1]; let {focusableProps} = useFocusable(props, ref); - let validationState = useFormValidationState({ - ...props, - value - }); - let {isInvalid, validationErrors, validationDetails} = validationState.displayValidation; - let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ + let {isInvalid, validationErrors, validationDetails} = state.displayValidation; + let {labelProps, fieldProps, descriptionProps, errorMessageProps, progressBarProps} = useField({ ...props, + isPending: state.isPending || props.isPending, isInvalid, errorMessage: props.errorMessage || validationErrors }); @@ -179,9 +175,9 @@ export function useTextField) => setValue(getEventTarget(e).value), + value: state.value, + onChange: (e: ChangeEvent) => state.setValue(getEventTarget(e).value), autoComplete: props.autoComplete, autoCapitalize: props.autoCapitalize, maxLength: props.maxLength, @@ -235,6 +231,7 @@ export function useTextField useButton(props)); - expect(typeof result.current.buttonProps.onClick).toBe('function'); - }); - it('handles elements other than button', function () { - let props = {elementType: 'a'}; - let {result} = renderHook(() => useButton(props)); - expect(result.current.buttonProps.role).toBe('button'); - expect(result.current.buttonProps.tabIndex).toBe(0); - expect(result.current.buttonProps['aria-disabled']).toBeUndefined(); - expect(result.current.buttonProps.href).toBeUndefined(); - expect(typeof result.current.buttonProps.onKeyDown).toBe('function'); - expect(result.current.buttonProps.rel).toBeUndefined(); - }); - it('handles elements other than button disabled', function () { - let props = {elementType: 'a', isDisabled: true}; - let {result} = renderHook(() => useButton(props)); - expect(result.current.buttonProps.role).toBe('button'); - expect(result.current.buttonProps.tabIndex).toBeUndefined(); - expect(result.current.buttonProps['aria-disabled']).toBeTruthy(); - expect(result.current.buttonProps.href).toBeUndefined(); - expect(typeof result.current.buttonProps.onKeyDown).toBe('function'); - expect(result.current.buttonProps.rel).toBeUndefined(); - }); - it('handles the rel attribute on anchors', function () { - let props = {elementType: 'a', rel: 'noopener noreferrer'}; - let {result} = renderHook(() => useButton(props)); - expect(result.current.buttonProps.rel).toBe('noopener noreferrer'); - }); - it('handles the rel attribute as a string on anchors', function () { - let props = {elementType: 'a', rel: 'search'}; - let {result} = renderHook(() => useButton(props)); - expect(result.current.buttonProps.rel).toBe('search'); - }); - it('handles input elements', function () { - let props = {elementType: 'input', isDisabled: true}; - let {result} = renderHook(() => useButton(props)); - expect(result.current.buttonProps.role).toBe('button'); - expect(result.current.buttonProps.tabIndex).toBeUndefined(); - expect(result.current.buttonProps['aria-disabled']).toBeUndefined(); - expect(result.current.buttonProps.disabled).toBeTruthy(); - expect(result.current.buttonProps.href).toBeUndefined(); - expect(typeof result.current.buttonProps.onKeyDown).toBe('function'); - expect(result.current.buttonProps.rel).toBeUndefined(); - }); - - it('handles aria-disabled passthrough for button elements', function () { - let props = {'aria-disabled': 'true'}; - let {result} = renderHook(() => useButton(props)); - expect(result.current.buttonProps['aria-disabled']).toBeTruthy(); - expect(result.current.buttonProps['disabled']).toBeUndefined(); - }); -}); diff --git a/packages/react-aria/test/searchfield/useSearchField.test.js b/packages/react-aria/test/searchfield/useSearchField.test.js deleted file mode 100644 index 0abae7e7e11..00000000000 --- a/packages/react-aria/test/searchfield/useSearchField.test.js +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright 2020 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. - */ - -// @ts-ignore -import intlMessages from '../../intl/searchfield/*.json'; -import {Provider} from '@adobe/react-spectrum/Provider'; -import React from 'react'; -import {renderHook} from '@react-spectrum/test-utils-internal'; -import {defaultTheme as theme} from '@adobe/react-spectrum/defaultTheme'; -import {useSearchField} from '../../src/searchfield/useSearchField'; - -describe('useSearchField hook', () => { - let state = {}; - let setValue = jest.fn(); - let ref = React.createRef(); - let focus = jest.fn(); - let onClear = jest.fn(); - - let renderSearchHook = (props, wrapper) => { - let {result} = renderHook(() => useSearchField({...props, 'aria-label': 'testLabel'}, state, ref), {wrapper}); - return result.current; - }; - - beforeEach(() => { - state.value = ''; - state.setValue = setValue; - ref.current = document.createElement('input'); - focus = jest.spyOn(ref.current, 'focus'); - }); - - afterEach(() => { - setValue.mockClear(); - focus.mockClear(); - onClear.mockClear(); - }); - - describe('should return inputProps', () => { - it('with base props and value equal to state.value', () => { - let {inputProps} = renderSearchHook({}); - expect(inputProps.type).toBe('search'); - expect(inputProps.value).toBe(state.value); - expect(typeof inputProps.onKeyDown).toBe('function'); - }); - - describe('with specific onKeyDown behavior', () => { - let preventDefault = jest.fn(); - let stopPropagation = jest.fn(); - let onSubmit = jest.fn(); - let onKeyDown = jest.fn(); - let event = (key) => ({ - key, - preventDefault, - stopPropagation - }); - - afterEach(() => { - preventDefault.mockClear(); - onSubmit.mockClear(); - }); - - it('preventDefault and stopPropagation are not called for Escape', () => { - let {inputProps} = renderSearchHook({}); - inputProps.onKeyDown(event('Escape')); - expect(preventDefault).toHaveBeenCalledTimes(0); - expect(stopPropagation).toHaveBeenCalledTimes(0); - }); - - it('preventDefault is not called for Enter if onSubmit is not provided', () => { - let {inputProps} = renderSearchHook(); - inputProps.onKeyDown(event('Enter')); - expect(preventDefault).toHaveBeenCalledTimes(0); - }); - - it('preventDefault and onSubmit are called for Enter if submit is provided', () => { - let {inputProps} = renderSearchHook({onSubmit}); - inputProps.onKeyDown(event('Enter')); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith(state.value); - }); - - it('pressing the Escape key sets the state value to "", if state.value is not empty, and calls onClear if provided and will not call onClear if escape pressed again', () => { - let {inputProps} = renderSearchHook({onClear}); - expect(inputProps.type).toBe('search'); - expect(inputProps.value).toBe(state.value); // this is a false positive because of fake state - - // manually updating fake state - state.value = 'search'; - - inputProps.onKeyDown(event('Escape')); - expect(state.setValue).toHaveBeenCalledTimes(1); - expect(state.setValue).toHaveBeenCalledWith(''); - expect(onClear).toHaveBeenCalledTimes(1); - - // manually updating fake state - state.value = ''; - - inputProps.onKeyDown(event('Escape')); - expect(state.setValue).toHaveBeenCalledTimes(1); - expect(onClear).toHaveBeenCalledTimes(1); - }); - - it('does not return an onKeyDown prop if isDisabled is true', () => { - let {inputProps} = renderSearchHook({isDisabled: true, onClear, onSubmit}); - expect(inputProps.onKeyDown).not.toBeDefined(); - }); - - it('does not return an defaultValue prop', () => { - let {inputProps} = renderSearchHook({onClear, onSubmit, defaultValue: 'ABC'}); - expect(inputProps.defaultValue).not.toBeDefined(); - }); - - it('onKeyDown prop is called', () => { - let {inputProps} = renderSearchHook({onKeyDown}); - inputProps.onKeyDown(event('Enter')); - expect(onKeyDown).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('should return clearButtonProps', () => { - it('with a localized aria-label', () => { - let locale = 'de-DE'; - let wrapper = ({children}) => {children}; - let expectedIntl = intlMessages[locale]['Clear search']; - let {clearButtonProps} = renderSearchHook({}, wrapper); - expect(clearButtonProps['aria-label']).toBe(expectedIntl); - }); - - it('clear button should not be tabbable', () => { - let {clearButtonProps} = renderSearchHook({}); - expect(clearButtonProps.excludeFromTabOrder).toBe(true); - }); - - describe('with specific onPress behavior', () => { - let mockEvent = {blah: 1}; - it('sets the state to "" and focuses the search field', () => { - let {clearButtonProps} = renderSearchHook({}); - clearButtonProps.onPressStart(mockEvent); - clearButtonProps.onPress(mockEvent); - expect(state.setValue).toHaveBeenCalledTimes(1); - expect(state.setValue).toHaveBeenCalledWith(''); - expect(ref.current.focus).toHaveBeenCalledTimes(1); - }); - - it('calls the user provided onClear if provided', () => { - let {clearButtonProps} = renderSearchHook({onClear}); - clearButtonProps.onPressStart(mockEvent); - clearButtonProps.onPress(mockEvent); - // Verify that onClearButtonClick stuff still triggers - expect(state.setValue).toHaveBeenCalledTimes(1); - expect(state.setValue).toHaveBeenCalledWith(''); - expect(ref.current.focus).toHaveBeenCalledTimes(1); - // Verify that props.onClear is triggered as well with the same event - expect(onClear).toHaveBeenCalledTimes(1); - expect(onClear).toHaveBeenCalledWith(); - }); - }); - }); -}); diff --git a/packages/react-stately/exports/private/utils/useAction.ts b/packages/react-stately/exports/private/utils/useAction.ts new file mode 100644 index 00000000000..d454464aa46 --- /dev/null +++ b/packages/react-stately/exports/private/utils/useAction.ts @@ -0,0 +1 @@ +export {useAction} from '../../../src/utils/useAction'; diff --git a/packages/react-stately/exports/private/utils/useControlledStateAction.ts b/packages/react-stately/exports/private/utils/useControlledStateAction.ts new file mode 100644 index 00000000000..f965297dbeb --- /dev/null +++ b/packages/react-stately/exports/private/utils/useControlledStateAction.ts @@ -0,0 +1 @@ +export {useControlledStateAction} from '../../../src/utils/useControlledStateAction'; diff --git a/packages/react-stately/exports/useTextFieldState.ts b/packages/react-stately/exports/useTextFieldState.ts new file mode 100644 index 00000000000..e858fd52275 --- /dev/null +++ b/packages/react-stately/exports/useTextFieldState.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2026 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. + */ + +export {useTextFieldState} from '../src/textfield/useTextFieldState'; +export type {TextFieldProps, TextFieldState} from '../src/textfield/useTextFieldState'; diff --git a/packages/react-stately/src/color/useColorChannelFieldState.ts b/packages/react-stately/src/color/useColorChannelFieldState.ts index 4e11ec6a6ca..14497270018 100644 --- a/packages/react-stately/src/color/useColorChannelFieldState.ts +++ b/packages/react-stately/src/color/useColorChannelFieldState.ts @@ -2,7 +2,7 @@ import {Color, ColorChannel, ColorSpace} from './types'; import {ColorFieldProps} from './useColorFieldState'; import {NumberFieldState, useNumberFieldState} from '../numberfield/useNumberFieldState'; import {useColor} from './useColor'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; import {useMemo, useState} from 'react'; export interface ColorChannelFieldProps extends ColorFieldProps { @@ -31,7 +31,7 @@ export function useColorChannelFieldState(props: ColorChannelFieldStateOptions): let {channel, colorSpace, locale} = props; let initialValue = useColor(props.value); let initialDefaultValue = useColor(props.defaultValue); - let [colorValue, setColor] = useControlledState(initialValue, initialDefaultValue ?? null, props.onChange); + let [colorValue, isPending, setColor] = useControlledStateAction(initialValue, initialDefaultValue ?? null, props.onChange, props.changeAction); let color = useConvertColor(colorValue, colorSpace); let [initialColorValue] = useState(colorValue); let defaultColorValue = initialDefaultValue ?? initialColorValue; @@ -60,6 +60,7 @@ export function useColorChannelFieldState(props: ColorChannelFieldStateOptions): return { ...numberFieldState, + isPending, colorValue: color, defaultColorValue, setColorValue: setColor diff --git a/packages/react-stately/src/color/useColorFieldState.ts b/packages/react-stately/src/color/useColorFieldState.ts index 1e18cac2267..9145487b741 100644 --- a/packages/react-stately/src/color/useColorFieldState.ts +++ b/packages/react-stately/src/color/useColorFieldState.ts @@ -15,12 +15,18 @@ import {FocusableProps, HelpTextProps, InputBase, LabelableProps, TextInputBase, import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; import {parseColor} from './Color'; import {useColor} from './useColor'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; import {useMemo, useState} from 'react'; export interface ColorFieldProps extends Omit, 'onChange'>, InputBase, Validation, FocusableProps, TextInputBase, LabelableProps, HelpTextProps { /** Handler that is called when the value changes. */ - onChange?: (color: Color | null) => void + onChange?: (color: Color | null) => void, + /** + * Async action that is called when the color changes. + * During the action, the field is in a pending state. + * Only supported in React 19 and later. + */ + changeAction?: (color: Color | null) => void | Promise } export interface ColorFieldState extends FormValidationState { @@ -34,6 +40,8 @@ export interface ColorFieldState extends FormValidationState { * Updated based on the `inputValue` as the user types. */ readonly colorValue: Color | null, + /** Whether the change action is pending. */ + readonly isPending: boolean, /** The default value of the color field. */ readonly defaultColorValue: Color | null, /** Sets the color value of the field. */ @@ -81,7 +89,7 @@ export function useColorFieldState( let {step} = MIN_COLOR.getChannelRange('red'); let initialDefaultValue = useColor(defaultValue); - let [colorValue, setColorValue] = useControlledState(useColor(value), initialDefaultValue!, onChange); + let [colorValue, isPending, setColorValue] = useControlledStateAction(useColor(value), initialDefaultValue!, onChange, props.changeAction); let [initialValue] = useState(colorValue); let [inputValue, setInputValue] = useState(() => (value || defaultValue) && colorValue ? colorValue.toString('hex') : ''); @@ -178,6 +186,7 @@ export function useColorFieldState( ...validation, validate, colorValue, + isPending, defaultColorValue: initialDefaultValue ?? initialValue, setColorValue, inputValue, diff --git a/packages/react-stately/src/datepicker/types.ts b/packages/react-stately/src/datepicker/types.ts index 01e88b77a7d..56695da1e45 100644 --- a/packages/react-stately/src/datepicker/types.ts +++ b/packages/react-stately/src/datepicker/types.ts @@ -56,7 +56,14 @@ interface DateFieldBase extends InputBase, Validation extends DateFieldBase, ValueBase | null> {} +export interface DateFieldProps extends DateFieldBase, ValueBase | null> { + /** + * Async action that is called when the value changes. + * During the action, the field is in a pending state. + * Only supported in React 19 and later. + */ + changeAction?: (value: MappedDateValue | null) => void | Promise +} interface DatePickerBase extends DateFieldBase, OverlayTriggerProps { /** @@ -70,7 +77,14 @@ interface DatePickerBase extends DateFieldBase, OverlayT firstDayOfWeek?: 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' } -export interface DatePickerProps extends DatePickerBase, ValueBase | null> {} +export interface DatePickerProps extends DatePickerBase, ValueBase | null> { + /** + * Async action that is called when the value changes. + * During the action, the field is in a pending state. + * Only supported in React 19 and later. + */ + changeAction?: (value: MappedDateValue | null) => void | Promise +} export interface DateRangePickerProps extends Omit, 'validate'>, Validation>>, ValueBase | null, RangeValue> | null> { /** @@ -85,7 +99,13 @@ export interface DateRangePickerProps extends Omit> | null) => void | Promise } export interface TimePickerProps extends InputBase, Validation>, FocusableProps, LabelableProps, HelpTextProps, ValueBase | null> { @@ -111,5 +131,11 @@ export interface TimePickerProps extends InputBase, Validat /** The minimum allowed time that a user may select. */ minValue?: TimeValue | null, /** The maximum allowed time that a user may select. */ - maxValue?: TimeValue | null + maxValue?: TimeValue | null, + /** + * Async action that is called when the value changes. + * During the action, the field is in a pending state. + * Only supported in React 19 and later. + */ + changeAction?: (value: MappedTimeValue | null) => void | Promise } diff --git a/packages/react-stately/src/datepicker/useDateFieldState.ts b/packages/react-stately/src/datepicker/useDateFieldState.ts index 8749933978b..53c51df0679 100644 --- a/packages/react-stately/src/datepicker/useDateFieldState.ts +++ b/packages/react-stately/src/datepicker/useDateFieldState.ts @@ -17,7 +17,7 @@ import {FormValidationState, useFormValidationState} from '../form/useFormValida import {getPlaceholder} from './placeholders'; import {IncompleteDate} from './IncompleteDate'; import {NumberFormatter} from '@internationalized/number'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; import {useMemo, useState} from 'react'; import {ValidationState} from '@react-types/shared'; @@ -46,6 +46,8 @@ export interface DateFieldState extends FormValidationState { value: DateValue | null, /** The default field value. */ defaultValue: DateValue | null, + /** Whether the change action is pending. */ + isPending: boolean, /** The current value, converted to a native JavaScript `Date` object. */ dateValue: Date, /** The calendar system currently in use. */ @@ -191,10 +193,11 @@ export function useDateFieldState(props: DateFi return [calendar, opts.hourCycle!]; }, [locale, props.hourCycle, createCalendar]); - let [value, setDate] = useControlledState | null>( + let [value, isPending, setDate] = useControlledStateAction | null>( props.value, props.defaultValue ?? null, - props.onChange + props.onChange, + props.changeAction ); let [initialValue] = useState(value); @@ -307,6 +310,7 @@ export function useDateFieldState(props: DateFi ...validation, value: calendarValue, defaultValue: props.defaultValue ?? initialValue, + isPending, dateValue, calendar, setValue, diff --git a/packages/react-stately/src/datepicker/useDatePickerState.ts b/packages/react-stately/src/datepicker/useDatePickerState.ts index 9678751a3eb..2fdb66f9f7a 100644 --- a/packages/react-stately/src/datepicker/useDatePickerState.ts +++ b/packages/react-stately/src/datepicker/useDatePickerState.ts @@ -15,7 +15,7 @@ import {DatePickerProps, DateValue, Granularity, MappedDateValue, TimeValue} fro import {FieldOptions, FormatterOptions, getFormatOptions, getPlaceholderTime, getValidationResult, useDefaultProps} from './utils'; import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; import {useMemo, useState} from 'react'; import {ValidationState} from '@react-types/shared'; @@ -32,6 +32,8 @@ export interface DatePickerState extends OverlayTriggerState, FormValidationStat value: DateValue | null, /** The default date. */ defaultValue: DateValue | null, + /** Whether the change action is pending. */ + isPending: boolean, /** Sets the selected date. */ setValue(value: DateValue | null): void, /** @@ -75,7 +77,7 @@ export interface DatePickerState extends OverlayTriggerState, FormValidationStat */ export function useDatePickerState(props: DatePickerStateOptions): DatePickerState { let overlayState = useOverlayTriggerState(props); - let [value, setValue] = useControlledState | null>(props.value, props.defaultValue || null, props.onChange); + let [value, isPending, setValue] = useControlledStateAction | null>(props.value, props.defaultValue || null, props.onChange, props.changeAction); let [initialValue] = useState(value); let v = (value || props.placeholderValue || null); @@ -165,6 +167,7 @@ export function useDatePickerState(props: DateP ...validation, value, defaultValue: props.defaultValue ?? initialValue, + isPending, setValue, dateValue: selectedDate, timeValue: selectedTime, diff --git a/packages/react-stately/src/datepicker/useDateRangePickerState.ts b/packages/react-stately/src/datepicker/useDateRangePickerState.ts index 6527b6e9c94..a6b20434fda 100644 --- a/packages/react-stately/src/datepicker/useDateRangePickerState.ts +++ b/packages/react-stately/src/datepicker/useDateRangePickerState.ts @@ -17,7 +17,7 @@ import {FieldOptions, FormatterOptions, getFormatOptions, getPlaceholderTime, ge import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; import {OverlayTriggerState, useOverlayTriggerState} from '../overlays/useOverlayTriggerState'; import {RangeValue, ValidationState} from '@react-types/shared'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; import {useMemo, useState} from 'react'; export interface DateRangePickerStateOptions extends DateRangePickerProps { @@ -34,6 +34,8 @@ export interface DateRangePickerState extends OverlayTriggerState, FormValidatio value: RangeValue, /** The default selected date range. */ defaultValue: DateRange | null, + /** Whether the change action is pending. */ + isPending: boolean, /** Sets the selected date range. */ setValue(value: DateRange | null): void, /** @@ -84,7 +86,7 @@ export interface DateRangePickerState extends OverlayTriggerState, FormValidatio */ export function useDateRangePickerState(props: DateRangePickerStateOptions): DateRangePickerState { let overlayState = useOverlayTriggerState(props); - let [controlledValue, setControlledValue] = useControlledState> | null>(props.value, props.defaultValue || null, props.onChange); + let [controlledValue, isPending, setControlledValue] = useControlledStateAction> | null>(props.value, props.defaultValue || null, props.onChange, props.changeAction); let [initialValue] = useState(controlledValue); let [placeholderValue, setPlaceholderValue] = useState>(() => controlledValue || {start: null, end: null}); @@ -197,6 +199,7 @@ export function useDateRangePickerState(props: ...validation, value, defaultValue: props.defaultValue ?? initialValue, + isPending, setValue, dateRange, timeRange, diff --git a/packages/react-stately/src/datepicker/useTimeFieldState.ts b/packages/react-stately/src/datepicker/useTimeFieldState.ts index 41fd5e728d5..1aec10f0de4 100644 --- a/packages/react-stately/src/datepicker/useTimeFieldState.ts +++ b/packages/react-stately/src/datepicker/useTimeFieldState.ts @@ -14,7 +14,7 @@ import {DateFieldState, useDateFieldState} from './useDateFieldState'; import {DateValue, MappedTimeValue, TimePickerProps, TimeValue} from './types'; import {getLocalTimeZone, GregorianCalendar, Time, toCalendarDateTime, today, toTime, toZoned} from '@internationalized/date'; import {useCallback, useMemo} from 'react'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; export interface TimeFieldStateOptions extends TimePickerProps { /** The locale to display and edit the value according to. */ @@ -41,10 +41,11 @@ export function useTimeFieldState(props: TimeFi validate } = props; - let [value, setValue] = useControlledState | null>( + let [value, isPending, setValue] = useControlledStateAction | null>( props.value, defaultValue ?? null, - props.onChange + props.onChange, + props.changeAction ); let v = value || placeholderValue; @@ -72,6 +73,7 @@ export function useTimeFieldState(props: TimeFi minValue: minDate, maxValue: maxDate, onChange, + changeAction: undefined, granularity: granularity || 'minute', maxGranularity: 'hour', placeholderValue: placeholderDate ?? undefined, @@ -82,6 +84,7 @@ export function useTimeFieldState(props: TimeFi return { ...state, + isPending, timeValue }; } diff --git a/packages/react-stately/src/form/useFormValidationState.ts b/packages/react-stately/src/form/useFormValidationState.ts index 2518f81a313..ccaaccbfde9 100644 --- a/packages/react-stately/src/form/useFormValidationState.ts +++ b/packages/react-stately/src/form/useFormValidationState.ts @@ -49,7 +49,8 @@ export const privateValidationStateProp: string = '__reactAriaFormValidationStat interface FormValidationProps extends Validation { builtinValidation?: ValidationResult, name?: string | string[], - value: T | null + value: T | null, + actionError?: unknown } export interface FormValidationState { @@ -77,17 +78,26 @@ export function useFormValidationState(props: FormValidationProps): FormVa } function useFormValidationStateImpl(props: FormValidationProps): FormValidationState { - let {isInvalid, validationState, name, value, builtinValidation, validate, validationBehavior = 'aria'} = props; + let {isInvalid, validationState, name, actionError, value, builtinValidation, validate, validationBehavior = 'aria'} = props; // backward compatibility. if (validationState) { isInvalid ||= validationState === 'invalid'; } + let actionErrorMessage: string | null = ''; + if (actionError) { + if (typeof actionError === 'object' && 'message' in actionError && typeof actionError.message === 'string') { + actionErrorMessage = actionError.message; + } else if (typeof actionError === 'string') { + actionErrorMessage = actionError; + } + } + // If the isInvalid prop is controlled, update validation result in realtime. - let controlledError: ValidationResult | null = isInvalid !== undefined ? { - isInvalid, - validationErrors: [], + let controlledError: ValidationResult | null = isInvalid !== undefined || actionError != null ? { + isInvalid: isInvalid || actionError != null, + validationErrors: actionErrorMessage ? [actionErrorMessage] : [], validationDetails: CUSTOM_VALIDITY_STATE } : null; diff --git a/packages/react-stately/src/numberfield/useNumberFieldState.ts b/packages/react-stately/src/numberfield/useNumberFieldState.ts index b1f1f45a9eb..a79a7eddbce 100644 --- a/packages/react-stately/src/numberfield/useNumberFieldState.ts +++ b/packages/react-stately/src/numberfield/useNumberFieldState.ts @@ -16,7 +16,7 @@ import {FocusableProps, HelpTextProps, InputBase, LabelableProps, RangeInputBase import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; import {NumberFormatter, NumberParser} from '@internationalized/number'; import {useCallback, useMemo, useState} from 'react'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; export interface NumberFieldProps extends InputBase, Validation, FocusableProps, TextInputBase, ValueBase, RangeInputBase, LabelableProps, HelpTextProps { /** @@ -30,7 +30,13 @@ export interface NumberFieldProps extends InputBase, Validation, Focusab * 'validate' will not clamp the value, and will validate that the value is within the min/max range and on a valid step. * @default 'snap' */ - commitBehavior?: 'snap' | 'validate' + commitBehavior?: 'snap' | 'validate', + /** + * Async action that is called when the value changes. + * During the action, the field is in a pending state. + * Only supported in React 19 and later. + */ + changeAction?: (value: number) => void | Promise } export interface NumberFieldState extends FormValidationState { @@ -44,6 +50,8 @@ export interface NumberFieldState extends FormValidationState { * Updated based on the `inputValue` as the user types. */ numberValue: number, + /** Whether the change action is pending. */ + isPending: boolean, /** The default value of the input. */ defaultNumberValue: number, /** The minimum value of the number field. */ @@ -129,7 +137,7 @@ export function useNumberFieldState( defaultValue = snapValue(defaultValue); } - let [numberValue, setNumberValue] = useControlledState(value, isNaN(defaultValue) ? NaN : defaultValue, onChange); + let [numberValue, isPending, setNumberValue] = useControlledStateAction(value, isNaN(defaultValue) ? NaN : defaultValue, onChange, props.changeAction); let [initialValue] = useState(numberValue); let [inputValue, setInputValue] = useState(() => isNaN(numberValue) ? '' : new NumberFormatter(locale, formatOptions).format(numberValue)); @@ -296,6 +304,7 @@ export function useNumberFieldState( minValue, maxValue, numberValue: parsedValue, + isPending, defaultNumberValue: isNaN(defaultValue) ? initialValue : defaultValue, setNumberValue, setInputValue, diff --git a/packages/react-stately/src/searchfield/useSearchFieldState.ts b/packages/react-stately/src/searchfield/useSearchFieldState.ts index e1195fd9f7e..bea394cc205 100644 --- a/packages/react-stately/src/searchfield/useSearchFieldState.ts +++ b/packages/react-stately/src/searchfield/useSearchFieldState.ts @@ -10,44 +10,49 @@ * governing permissions and limitations under the License. */ -import {FocusableProps, HelpTextProps, InputBase, LabelableProps, TextInputBase, Validation, ValueBase} from '@react-types/shared'; -import {useControlledState} from '../utils/useControlledState'; - -// Copied here to avoid depending on @react-aria/textfield from stately. -export interface TextFieldProps extends InputBase, Validation, HelpTextProps, FocusableProps, TextInputBase, ValueBase, LabelableProps {} +import {TextFieldProps, TextFieldState, useTextFieldState} from '../textfield/useTextFieldState'; +import {useAction} from '../utils/useAction'; export interface SearchFieldProps extends TextFieldProps { /** Handler that is called when the SearchField is submitted. */ onSubmit?: (value: string) => void, /** Handler that is called when the clear button is pressed. */ - onClear?: () => void -} + onClear?: () => void, -export interface SearchFieldState { - /** The current value of the search field. */ - readonly value: string, + /** Async action that is called when the SearchField is submitted. */ + submitAction?: (value: string) => void | Promise, + + /** Async action that is called when the clear button is pressed. */ + clearAction?: () => void | Promise +} - /** Sets the value of the search field. */ - setValue(value: string): void +export interface SearchFieldState extends TextFieldState { + /** Clears the search field. */ + clear(): void, + /** Submits the search field. */ + submit(): void } /** * Provides state management for a search field. */ export function useSearchFieldState(props: SearchFieldProps): SearchFieldState { - let [value, setValue] = useControlledState(toString(props.value), toString(props.defaultValue) || '', props.onChange); + let state = useTextFieldState(props); + let [submitAction, isSubmitPending] = useAction(props.submitAction); + let [clearAction, isClearPending] = useAction(props.clearAction); return { - value, - setValue + ...state, + clear() { + clearAction?.(); + state.setValue(''); + props.onClear?.(); + }, + submit() { + submitAction?.(state.value); + props.onSubmit?.(state.value); + }, + isPending: state.isPending || isSubmitPending || isClearPending }; } - -function toString(val) { - if (val == null) { - return; - } - - return val.toString(); -} diff --git a/packages/react-stately/src/textfield/useTextFieldState.ts b/packages/react-stately/src/textfield/useTextFieldState.ts new file mode 100644 index 00000000000..d02d0ca563b --- /dev/null +++ b/packages/react-stately/src/textfield/useTextFieldState.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2020 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 {FocusableProps, HelpTextProps, InputBase, LabelableProps, TextInputBase, Validation, ValueBase} from '@react-types/shared'; +import {FormValidationState, useFormValidationState} from '../form/useFormValidationState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; + +export interface TextFieldProps extends InputBase, Validation, HelpTextProps, FocusableProps, TextInputBase, ValueBase, LabelableProps { + /** Async action that is called when the value changes. */ + changeAction?: (value: string) => void | Promise +} + +export interface TextFieldState extends FormValidationState { + /** The current value of the search field. */ + readonly value: string, + + /** Sets the value of the search field. */ + setValue(value: string): void, + + /** Whether an action is pending. */ + isPending: boolean +} + +/** + * Provides state management for a text field. + */ +export function useTextFieldState(props: TextFieldProps): TextFieldState { + let [value, isPending, setValue, actionError] = useControlledStateAction(props.value, props.defaultValue || '', props.onChange, props.changeAction); + let validationState = useFormValidationState({ + ...props, + actionError, + value + }); + + return { + ...validationState, + value, + setValue, + isPending + }; +} diff --git a/packages/react-stately/src/toggle/useToggleState.ts b/packages/react-stately/src/toggle/useToggleState.ts index a28218c99e4..b85fccf42c7 100644 --- a/packages/react-stately/src/toggle/useToggleState.ts +++ b/packages/react-stately/src/toggle/useToggleState.ts @@ -12,7 +12,7 @@ import {FocusableProps, InputBase, Validation} from '@react-types/shared'; import {ReactNode, useState} from 'react'; -import {useControlledState} from '../utils/useControlledState'; +import {useControlledStateAction} from '../utils/useControlledStateAction'; export interface ToggleStateOptions extends InputBase { /** @@ -26,7 +26,13 @@ export interface ToggleStateOptions extends InputBase { /** * Handler that is called when the element's selection state changes. */ - onChange?: (isSelected: boolean) => void + onChange?: (isSelected: boolean) => void, + /** + * Async action that is called when the state changes. + * During the action, the button is in a pending state. + * Only supported in React 19 and later. + */ + changeAction?: (isSelected: boolean) => void | Promise } export interface ToggleProps extends ToggleStateOptions, Validation, FocusableProps { @@ -47,6 +53,9 @@ export interface ToggleState { /** Whether the toggle is selected by default. */ readonly defaultSelected: boolean, + /** Whether the change action is pending. */ + readonly isPending: boolean, + /** Updates selection state. */ setSelected(isSelected: boolean): void, @@ -60,9 +69,7 @@ export interface ToggleState { export function useToggleState(props: ToggleStateOptions = {}): ToggleState { let {isReadOnly} = props; - // have to provide an empty function so useControlledState doesn't throw a fit - // can't use useControlledState's prop calling because we need the event object from the change - let [isSelected, setSelected] = useControlledState(props.isSelected, props.defaultSelected || false, props.onChange); + let [isSelected, isPending, setSelected] = useControlledStateAction(props.isSelected, props.defaultSelected || false, props.onChange, props.changeAction); let [initialValue] = useState(isSelected); function updateSelected(value) { @@ -72,14 +79,13 @@ export function useToggleState(props: ToggleStateOptions = {}): ToggleState { } function toggleState() { - if (!isReadOnly) { - setSelected(!isSelected); - } + updateSelected(!isSelected); } return { isSelected, defaultSelected: props.defaultSelected ?? initialValue, + isPending, setSelected: updateSelected, toggle: toggleState }; diff --git a/packages/react-stately/src/utils/useAction.ts b/packages/react-stately/src/utils/useAction.ts new file mode 100644 index 00000000000..37c0e1a9a6e --- /dev/null +++ b/packages/react-stately/src/utils/useAction.ts @@ -0,0 +1,45 @@ +/* + * Copyright 2026 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 React, {useCallback, useState} from 'react'; + +export const useAction = typeof React['useTransition'] === 'function' && typeof React['useOptimistic'] === 'function' + ? useActionModern + : useActionLegacy; + +export function useActionModern(action: ((...args: any[]) => void | Promise) | null | undefined): [((...args: any[]) => void) | undefined, boolean, unknown | null] { + let [isPending, transition] = React.useTransition(); + let [error, setError] = useState(null); + let [optimisticError, setOptimisticError] = React.useOptimistic(error); + let onEvent = useCallback((...args: any[]) => { + transition(async () => { + try { + setOptimisticError(null); + await action!(...args); + setError(null); + } catch (err) { + // TODO: if the component is no longer mounted, re-throw? + setError(err); + } + }); + }, [action, setOptimisticError]); + + return [action ? onEvent : undefined, isPending, optimisticError]; +} + +export function useActionLegacy(action: ((...args: any[]) => void | Promise) | null | undefined): [((...args: any[]) => void) | undefined, boolean, unknown | null] { + if (action) { + throw new Error('Actions are only supported in React 19 and later.'); + } + + return [undefined, false, null]; +} diff --git a/packages/react-stately/src/utils/useControlledStateAction.ts b/packages/react-stately/src/utils/useControlledStateAction.ts new file mode 100644 index 00000000000..01704de85e3 --- /dev/null +++ b/packages/react-stately/src/utils/useControlledStateAction.ts @@ -0,0 +1,78 @@ +/* + * Copyright 2026 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 React, {SetStateAction, useCallback, useInsertionEffect, useRef, useState} from 'react'; +import {useControlledState} from './useControlledState'; + +export const useControlledStateAction = typeof React['useTransition'] === 'function' && typeof React['useOptimistic'] === 'function' + ? useControlledStateActionModern + : useControlledStateActionLegacy; + +function useControlledStateActionModern(value: Exclude, defaultValue: Exclude | undefined, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void, error: unknown | null]; +function useControlledStateActionModern(value: Exclude | undefined, defaultValue: Exclude, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void, error: unknown | null]; +function useControlledStateActionModern(value: T, defaultValue: T, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void, error: unknown | null] { + let [controlledValue, setControlledValue] = useControlledState(value as any, defaultValue, onChange); + let [optimisticValue, setOptimisticValue] = React.useOptimistic(controlledValue); + let [isPending, transition] = React.useTransition(); + let [error, setError] = useState(null); + let [optimisticError, setOptimisticError] = React.useOptimistic(error); + + // Store the optimistic value in a ref for use in setState callback. + let valueRef = useRef(optimisticValue); + useInsertionEffect(() => { + valueRef.current = optimisticValue; + }); + + let setValue = useCallback(value => { + // If there is no action, just update the value synchronously. + if (!changeAction) { + setControlledValue(value); + return; + } + + transition(async () => { + // Determine the new value based on the current "optimistic" value, which is displayed to the user. + let newValue = typeof value === 'function' ? value(valueRef.current) : value; + if (!Object.is(newValue, valueRef.current)) { + valueRef.current = newValue; + + // Set the optimistic value. This may be "ahead" of the controlled/uncontrolled value if the app suspends. + setOptimisticValue(newValue); + + // Trigger onChange and update uncontrolled state. + setControlledValue(newValue); + + // Trigger the async action. + try { + setOptimisticError(null); + await changeAction(newValue); + setError(null); + } catch (err) { + setError(err); + } + } + }); + }, [setControlledValue, setOptimisticValue, setOptimisticError, changeAction]); + + return [optimisticValue, isPending, setValue, optimisticError]; +} + +function useControlledStateActionLegacy(value: Exclude, defaultValue: Exclude | undefined, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void, unknown | null]; +function useControlledStateActionLegacy(value: Exclude | undefined, defaultValue: Exclude, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void, unknown | null]; +function useControlledStateActionLegacy(value: T, defaultValue: T, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void, unknown | null] { + if (changeAction) { + throw new Error('Actions are only supported in React 19 and later.'); + } + + let [controlledValue, setControlledValue] = useControlledState(value as any, defaultValue, onChange); + return [controlledValue, false, setControlledValue, null]; +} diff --git a/packages/react-stately/test/searchfield/useSearchFieldState.test.js b/packages/react-stately/test/searchfield/useSearchFieldState.test.js deleted file mode 100644 index 9a1629d68a6..00000000000 --- a/packages/react-stately/test/searchfield/useSearchFieldState.test.js +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2020 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 {actHook as act, renderHook} from '@react-spectrum/test-utils-internal'; -import {useSearchFieldState} from '../../src/searchfield/useSearchFieldState'; - -describe('useSearchFieldState', () => { - let onChange = jest.fn(); - let newValue = 'newValue'; - - afterEach(() => { - onChange.mockClear(); - }); - - it('should be controlled if props.value is defined', () => { - let props = { - value: 'blah', - onChange - }; - let {result} = renderHook(() => useSearchFieldState(props)); - expect(result.current.value).toBe(props.value); - act(() => result.current.setValue(newValue)); - expect(result.current.value).toBe(props.value); - expect(onChange).toHaveBeenCalledWith(newValue); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it('should start with value = props.defaultValue if props.value is not defined and props.defaultValue is defined', () => { - let props = { - defaultValue: 'blah', - onChange - }; - let {result} = renderHook(() => useSearchFieldState(props)); - expect(result.current.value).toBe(props.defaultValue); - act(() => result.current.setValue(newValue)); - expect(result.current.value).toBe(newValue); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it('should default to uncontrolled with value = "" if defaultValue and value aren\'t defined', () => { - let props = { - onChange - }; - let {result} = renderHook(() => useSearchFieldState(props)); - expect(result.current.value).toBe(''); - act(() => result.current.setValue(newValue)); - expect(result.current.value).toBe(newValue); - expect(onChange).toHaveBeenCalledTimes(1); - }); - - it('should convert numeric values to strings (uncontrolled)', () => { - let props = { - defaultValue: 13 - }; - - let {result} = renderHook(() => useSearchFieldState(props)); - expect(result.current.value).toBe(props.defaultValue.toString()); - }); - - it('should convert an array of string values to a string (uncontrolled)', () => { - let props = { - defaultValue: ['hi', 'this', 'is', 'me'] - }; - - let {result} = renderHook(() => useSearchFieldState(props)); - expect(result.current.value).toBe(props.defaultValue.toString()); - }); - - it('should convert numeric values to strings (controlled)', () => { - let props = { - value: 13 - }; - - let {result} = renderHook(() => useSearchFieldState(props)); - expect(result.current.value).toBe(props.value.toString()); - }); - - it('should convert an array of string values to a string (controlled)', () => { - let props = { - value: ['hi', 'this', 'is', 'me'] - }; - - let {result} = renderHook(() => useSearchFieldState(props)); - expect(result.current.value).toBe(props.value.toString()); - }); -}); diff --git a/rfcs/2026-async-react.md b/rfcs/2026-async-react.md new file mode 100644 index 00000000000..97572b0dea4 --- /dev/null +++ b/rfcs/2026-async-react.md @@ -0,0 +1,81 @@ + + +- Start Date: 2026-04-08 +- RFC PR: (leave this empty, to be filled in later) +- Authors: Devon Govett + +# Adopting Async React in React Aria Components + +## Summary + +In this RFC, we propose adding support for React's action prop pattern to React Aria Components, along with built-in support for pending states across many components. + +## Motivation + +At React Conf 2025, the React core team [presented](https://www.youtube.com/watch?v=B_2E96URooA) their vision of "Async React". Using features introduced in React 19 such as [useTransition](https://react.dev/reference/react/useTransition), [useOptimistic](https://react.dev/reference/react/useOptimistic), and [Suspense](https://react.dev/reference/react/Suspense) for data fetching, React can now coordinate loading states across an entire app, and reduce the amount of code needed to handle data loading edge cases. This improves the user experience by making loading/saving states in-line with the component that triggered the update. + +While these React hooks are usable today, they require some boilerplate to set up. This can be simplified by introducing the [action prop](https://react.dev/reference/react/useTransition#exposing-action-props-from-components) pattern. By convention, action props are automatically wrapped in React's `startTransition` function and may include a pending state within the component that triggered them. This way the application doesn't need to handle these states themselves since it's handled by the component library. + +## Detailed Design + +This RFC proposes adding support for action props directly to React Aria Components. While it's possible to introduce these at a higher level (e.g. in a design system), pending states have accessibility requirements to ensure clear announcements for screen readers, focus management, etc. In addition, multiple design systems can benefit from handling pending states at a lower level layer. + +Action props will correspond to events, either using the `action` name for simple actions (e.g. Button) or the `Action` suffix (e.g. `changeAction`). These accept an `async` function, which is called within React's `startTransition` function. Each component supporting actions will expose an `isPending` render prop and `data-pending` DOM attribute. This will be used to render a ``, associated with the element via ARIA attributes. We will also handle announcing the state change via an ARIA live region. + +Components with state will use `useOptimistic` to update immediately in response to user input. This state is automatically updated to the latest value by React when the action completes. Optimistic updates seem to be the desired behavior in most cases, but if you want to opt-out, you can continue to use events such as `onChange` instead of actions, and implement your own transition external to the component. + +To implement this, we can create a new hook that wraps `useControlledState` and also supports action props. When the value setter is called, we start a transition, set the optimistic value, and trigger the change action. We will also continue emit the `onChange` event and support both controlled and uncontrolled state. + +We could also potentially catch errors that are thrown by actions and expose these as render props, enabling [in-line contextual error UIs](https://x.com/devongovett/status/1989788456751697958). + +All together, this significantly simplifies the implementation of loading states for component libraries and applications. Simply render a `` when `isPending` is true, add an async function as an action prop, and React Aria handles the rest. + +Here's a potential list of components that could support actions: + +* Button - `action` +* Checkbox - `changeAction` (only when using `CheckboxField`, introduced in [#9877](https://github.com/adobe/react-spectrum/pull/9877)) +* CheckboxGroup - `changeAction` +* Calendar - `changeAction` +* ColorSwatchPicker - `changeAction` +* ColorSlider - `changeAction` +* ComboBox - `changeAction` +* DateField - `changeAction` +* DatePicker - `changeAction` +* DateRangePicker - `changeAction` +* Disclosure - `expandAction` +* NumberField - `changeAction` +* RadioGroup - `changeAction` +* SearchField - `changeAction`, `submitAction`, `clearAction` +* Select - `changeAction` +* Slider - `changeAction` +* Switch - `changeAction` (only when using `SwitchField`, introduced in [#9877](https://github.com/adobe/react-spectrum/pull/9877)) +* Tabs - `selectionAction` +* TextField - `changeAction` +* TimeField - `changeAction` +* ToggleButton - `changeAction` + +## Documentation + +We'll add new examples to our documentation showing how to use action props, and add pending states to components in our starter kits. + +## Drawbacks + +It adds additional things for people to learn, but since this is the direction the React team is heading it seems worth it. + +## Backwards Compatibility Analysis + +Action props will only be supported in React 19 and later. When using an older version of React, we will throw an error. + +## Open Questions + +* Do pending states make sense in all of these components? Supporting these within Spectrum will require input from design. +* How do we want to support pending states that aren't displayed as a progress bar / spinner (e.g. a "shimmer")? We may need to announce something, even if a progress bar is not present in the DOM. +* How do we want to handle components that have both a loading state for data and a pending state for an action? For example, Select and ComboBox support loading states for their list of items, but may also support a changeAction when the user selects an item. Would these both display the same spinner UI? +* For components with multiple actions, do we want individual pending states (e.g. `isChangePending`, `isSubmitPending`) or a single pending state that aggregates these? diff --git a/starters/docs/src/ProgressCircle.tsx b/starters/docs/src/ProgressCircle.tsx index 11a61e886b2..25a40c625c4 100644 --- a/starters/docs/src/ProgressCircle.tsx +++ b/starters/docs/src/ProgressCircle.tsx @@ -2,6 +2,7 @@ import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import { ProgressBar } from 'react-aria-components/ProgressBar'; import type { ProgressBarProps } from 'react-aria-components/ProgressBar'; +import './theme.css'; export interface ProgressCircleProps extends ProgressBarProps { size?: number @@ -31,13 +32,13 @@ export function ProgressCircle(props: ProgressCircleProps) { cx="50%" cy="50%" r={radius} - stroke="var(--highlight-pressed)" + stroke="var(--highlight-pressed, #ccc)" strokeWidth={strokeWidth} />