From 5971b01d94466a0475ef0e09b5ed8ba838742cda Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Apr 2026 11:59:50 -0700 Subject: [PATCH 01/12] Support actions in Button and ToggleButton --- .../react-spectrum/src/button/Button.tsx | 5 +- packages/react-aria-components/src/Button.tsx | 69 ++----------- .../src/ToggleButton.tsx | 15 +-- .../stories/Button.stories.tsx | 35 +++++++ .../stories/ToggleButton.stories.tsx | 31 ++++++ .../stories/button-pending.css | 4 + packages/react-aria/src/button/useButton.ts | 99 +++++++++++++++++-- .../react-aria/src/button/useToggleButton.ts | 17 +++- .../src/button/useToggleButtonGroup.ts | 9 +- .../exports/private/utils/useAction.ts | 1 + .../private/utils/useControlledStateAction.ts | 1 + .../src/toggle/useToggleState.ts | 22 +++-- packages/react-stately/src/utils/useAction.ts | 36 +++++++ .../src/utils/useControlledStateAction.ts | 70 +++++++++++++ starters/docs/src/ProgressCircle.tsx | 5 +- 15 files changed, 323 insertions(+), 96 deletions(-) create mode 100644 packages/react-stately/exports/private/utils/useAction.ts create mode 100644 packages/react-stately/exports/private/utils/useControlledStateAction.ts create mode 100644 packages/react-stately/src/utils/useAction.ts create mode 100644 packages/react-stately/src/utils/useControlledStateAction.ts 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, - /** - * 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 +83,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} = useButton(props, ref); let {focusProps, isFocused, isFocusVisible} = useFocusRing(props); let {hoverProps, isHovered} = useHover({ ...props, @@ -105,7 +95,7 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop isFocused, isFocusVisible, isDisabled: props.isDisabled || false, - isPending: isPending ?? false + isPending }; let renderProps = useRenderProps({ @@ -114,70 +104,23 @@ 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 ( - + {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/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/stories/Button.stories.tsx b/packages/react-aria-components/stories/Button.stories.tsx index a3e7df7b98b..aecd22bf411 100644 --- a/packages/react-aria-components/stories/Button.stories.tsx +++ b/packages/react-aria-components/stories/Button.stories.tsx @@ -49,6 +49,13 @@ export const PendingButtonTooltip: ButtonStory = { } }; +export const ReactAction: ButtonStory = { + render: (args) => , + args: { + children: 'Press me' + } +}; + function PendingButtonExample(props) { let [isPending, setPending] = useState(false); @@ -113,6 +120,34 @@ function PendingButtonTooltipExample(props) { ); } +function ReactActionExample(props) { + return ( + + ); +} + export const RippleButtonExample: ButtonStory = { render: () => ( Press me 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..8ccbbe48c02 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 { diff --git a/packages/react-aria/src/button/useButton.ts b/packages/react-aria/src/button/useButton.ts index b80ad891f5d..62022cdfc21 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,12 @@ 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 } // Order with overrides is important: 'button' should be default @@ -168,11 +190,14 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< }; } + let [onAction, isActionPending] = 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 +209,73 @@ 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' : buttonProps['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 }; } + +// 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-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/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..ccb1d124695 --- /dev/null +++ b/packages/react-stately/src/utils/useAction.ts @@ -0,0 +1,36 @@ +/* + * 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 from 'react'; + +export const useAction = typeof React['useTransition'] === 'function' + ? useActionModern + : useActionLegacy; + +export function useActionModern(action: ((...args: any[]) => void | Promise) | null | undefined): [((...args: any[]) => void) | undefined, boolean] { + let [isPending, transition] = React.useTransition(); + let onEvent = (...args: any[]) => { + transition(async () => { + await action!(...args); + }); + }; + + return [action ? onEvent : undefined, isPending]; +} + +export function useActionLegacy(action: ((...args: any[]) => void | Promise) | null | undefined): [((...args: any[]) => void) | undefined, boolean] { + if (action) { + throw new Error('Actions are only supported in React 19 and later.'); + } + + return [undefined, false]; +} diff --git a/packages/react-stately/src/utils/useControlledStateAction.ts b/packages/react-stately/src/utils/useControlledStateAction.ts new file mode 100644 index 00000000000..dc3109bac58 --- /dev/null +++ b/packages/react-stately/src/utils/useControlledStateAction.ts @@ -0,0 +1,70 @@ +/* + * 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} from 'react'; +import {useControlledState} from './useControlledState'; + +export const useControlledStateAction = typeof React['useTransition'] === 'function' + ? useControlledStateActionModern + : useControlledStateActionLegacy; + +function useControlledStateActionModern(value: Exclude, defaultValue: Exclude | undefined, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void]; +function useControlledStateActionModern(value: Exclude | undefined, defaultValue: Exclude, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void]; +function useControlledStateActionModern(value: T, defaultValue: T, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void] { + let [controlledValue, setControlledValue] = useControlledState(value as any, defaultValue, onChange); + let [optimisticValue, setOptimisticValue] = React.useOptimistic(controlledValue); + let [isPending, transition] = React.useTransition(); + + // 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. + await changeAction(newValue); + } + }); + }, [setControlledValue, setOptimisticValue, changeAction]); + + return [optimisticValue, isPending, setValue]; +} + +function useControlledStateActionLegacy(value: Exclude, defaultValue: Exclude | undefined, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void]; +function useControlledStateActionLegacy(value: Exclude | undefined, defaultValue: Exclude, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void]; +function useControlledStateActionLegacy(value: T, defaultValue: T, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void] { + 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]; +} 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} /> Date: Wed, 8 Apr 2026 12:00:05 -0700 Subject: [PATCH 02/12] TextField and SearchField --- .../react-aria-components/src/SearchField.tsx | 15 ++++-- .../react-aria-components/src/TextField.tsx | 23 ++++++--- .../stories/SearchField.stories.tsx | 42 ++++++++++++++- .../stories/TextField.stories.tsx | 40 ++++++++++++++- .../src/autocomplete/useSearchAutocomplete.ts | 7 +-- packages/react-aria/src/label/useField.ts | 35 +++++++++++-- .../src/searchfield/useSearchField.ts | 28 ++++------ .../react-aria/src/textfield/useTextField.ts | 50 ++++++++---------- .../exports/useTextFieldState.ts | 14 +++++ .../src/searchfield/useSearchFieldState.ts | 51 ++++++++++--------- .../src/textfield/useTextFieldState.ts | 49 ++++++++++++++++++ 11 files changed, 265 insertions(+), 89 deletions(-) create mode 100644 packages/react-stately/exports/useTextFieldState.ts create mode 100644 packages/react-stately/src/textfield/useTextFieldState.ts 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..dd2e7a084d6 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,13 @@ 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); + 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 +117,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 +135,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/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..cfe5f7b97fb 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,40 @@ TextFieldSubmitExample.story = { } } }; + +export const ReactAction: TextFieldStory = () => { + 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/src/autocomplete/useSearchAutocomplete.ts b/packages/react-aria/src/autocomplete/useSearchAutocomplete.ts index 059d4c5a1f1..e2deb2a0551 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 { @@ -126,10 +126,7 @@ export function useSearchAutocomplete(props: AriaSearchAutocompleteOptions }, onKeyDown, onKeyUp - }, { - value: state.inputValue, - setValue: state.setInputValue - }, inputRef); + }, useSearchFieldState({value: state.inputValue, onChange: state.setInputValue}), inputRef); let {listBoxProps, labelProps, inputProps: comboBoxInputProps, ...validation} = useComboBox( 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/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/useTextField.ts b/packages/react-aria/src/textfield/useTextField.ts index f4f0f942110..3a765bfa21c 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 @@ -140,7 +133,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 +154,14 @@ export function useTextField(props.value, props.defaultValue || '', props.onChange); + // 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, isInvalid, errorMessage: props.errorMessage || validationErrors }); @@ -179,9 +172,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 +228,7 @@ export function useTextField 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..610e16d207e --- /dev/null +++ b/packages/react-stately/src/textfield/useTextFieldState.ts @@ -0,0 +1,49 @@ +/* + * 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] = useControlledStateAction(props.value, props.defaultValue || '', props.onChange, props.changeAction); + let validationState = useFormValidationState({ + ...props, + value + }); + + return { + ...validationState, + value, + setValue, + isPending + }; +} From 288db489c345f5132e11742a5f522b3496642c1a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Apr 2026 16:54:15 -0700 Subject: [PATCH 03/12] Implement in more components --- .../react-spectrum/src/color/ColorField.tsx | 2 + .../test/numberfield/NumberField.test.js | 8 - .../react-aria-components/src/ColorField.tsx | 25 ++- .../react-aria-components/src/DateField.tsx | 22 ++- .../react-aria-components/src/DatePicker.tsx | 18 +- .../react-aria-components/src/NumberField.tsx | 12 +- .../react-aria-components/src/TextField.tsx | 5 +- .../stories/ColorField.stories.tsx | 52 +++++- .../stories/DateField.stories.tsx | 46 ++++- .../stories/DatePicker.stories.tsx | 137 +++++++++++++- .../stories/NumberField.stories.tsx | 52 ++++++ .../stories/TimeField.stories.tsx | 46 ++++- .../test/ColorField.test.js | 55 ++++++ .../test/DateField.test.js | 56 ++++++ .../test/DatePicker.test.js | 73 ++++++++ .../test/DateRangePicker.test.js | 79 ++++++++ .../test/NumberField.test.js | 54 ++++++ .../test/SearchField.test.js | 52 ++++++ .../test/TextField.test.js | 50 ++++++ .../test/TimeField.test.js | 56 ++++++ .../src/autocomplete/useSearchAutocomplete.ts | 13 +- packages/react-aria/src/button/useButton.ts | 2 +- .../src/color/useColorChannelField.ts | 1 + .../react-aria/src/color/useColorField.ts | 7 +- .../react-aria/src/datepicker/useDateField.ts | 12 +- .../src/datepicker/useDatePicker.ts | 6 +- .../src/datepicker/useDateRangePicker.ts | 6 +- .../src/numberfield/useNumberField.ts | 10 +- .../src/textfield/useFormattedTextField.ts | 9 +- .../react-aria/src/textfield/useTextField.ts | 7 +- .../react-aria/test/button/useButton.test.js | 71 -------- .../test/searchfield/useSearchField.test.js | 168 ------------------ .../src/color/useColorChannelFieldState.ts | 5 +- .../src/color/useColorFieldState.ts | 15 +- .../react-stately/src/datepicker/types.ts | 34 +++- .../src/datepicker/useDateFieldState.ts | 10 +- .../src/datepicker/useDatePickerState.ts | 7 +- .../src/datepicker/useDateRangePickerState.ts | 7 +- .../src/datepicker/useTimeFieldState.ts | 9 +- .../src/numberfield/useNumberFieldState.ts | 15 +- .../searchfield/useSearchFieldState.test.js | 95 ---------- 41 files changed, 1002 insertions(+), 407 deletions(-) delete mode 100644 packages/react-aria/test/button/useButton.test.js delete mode 100644 packages/react-aria/test/searchfield/useSearchField.test.js delete mode 100644 packages/react-stately/test/searchfield/useSearchFieldState.test.js diff --git a/packages/@adobe/react-spectrum/src/color/ColorField.tsx b/packages/@adobe/react-spectrum/src/color/ColorField.tsx index 01d979c3739..84b3c0a44db 100644 --- a/packages/@adobe/react-spectrum/src/color/ColorField.tsx +++ b/packages/@adobe/react-spectrum/src/color/ColorField.tsx @@ -75,6 +75,7 @@ function ColorChannelField(props: ColorChannelFieldProps) { value, // eslint-disable-line @typescript-eslint/no-unused-vars defaultValue, // eslint-disable-line @typescript-eslint/no-unused-vars onChange, // eslint-disable-line @typescript-eslint/no-unused-vars + changeAction, // eslint-disable-line @typescript-eslint/no-unused-vars validate, // eslint-disable-line @typescript-eslint/no-unused-vars forwardedRef, ...otherProps @@ -111,6 +112,7 @@ function HexColorField(props: HexColorFieldProps) { value, // eslint-disable-line @typescript-eslint/no-unused-vars defaultValue, // eslint-disable-line @typescript-eslint/no-unused-vars onChange, // eslint-disable-line @typescript-eslint/no-unused-vars + changeAction, // eslint-disable-line @typescript-eslint/no-unused-vars forwardedRef, ...otherProps } = props; diff --git a/packages/@adobe/react-spectrum/test/numberfield/NumberField.test.js b/packages/@adobe/react-spectrum/test/numberfield/NumberField.test.js index e378c069ad7..7cca5146b32 100644 --- a/packages/@adobe/react-spectrum/test/numberfield/NumberField.test.js +++ b/packages/@adobe/react-spectrum/test/numberfield/NumberField.test.js @@ -1681,10 +1681,8 @@ describe('NumberField', function () { expect(label).toHaveAttribute('for', textField.id); expect(incrementButton).toHaveAttribute('aria-label', 'Increase Width'); - expect(incrementButton).not.toHaveAttribute('id'); expect(incrementButton).not.toHaveAttribute('aria-labelledby'); expect(decrementButton).toHaveAttribute('aria-label', 'Decrease Width'); - expect(decrementButton).not.toHaveAttribute('id'); expect(decrementButton).not.toHaveAttribute('aria-labelledby'); }); @@ -1697,10 +1695,8 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('aria-label', 'Width'); expect(incrementButton).toHaveAttribute('aria-label', 'Increase Width'); - expect(incrementButton).not.toHaveAttribute('id'); expect(incrementButton).not.toHaveAttribute('aria-labelledby'); expect(decrementButton).toHaveAttribute('aria-label', 'Decrease Width'); - expect(decrementButton).not.toHaveAttribute('id'); expect(decrementButton).not.toHaveAttribute('aria-labelledby'); }); @@ -1753,10 +1749,8 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('aria-label', 'Width'); expect(incrementButton).toHaveAttribute('aria-label', 'Increment'); - expect(incrementButton).not.toHaveAttribute('id'); expect(incrementButton).not.toHaveAttribute('aria-labelledby'); expect(decrementButton).toHaveAttribute('aria-label', 'Decrease Width'); - expect(decrementButton).not.toHaveAttribute('id'); expect(decrementButton).not.toHaveAttribute('aria-labelledby'); }); @@ -1770,10 +1764,8 @@ describe('NumberField', function () { expect(textField).toHaveAttribute('aria-label', 'Width'); expect(incrementButton).toHaveAttribute('aria-label', 'Increase Width'); - expect(incrementButton).not.toHaveAttribute('id'); expect(incrementButton).not.toHaveAttribute('aria-labelledby'); expect(decrementButton).toHaveAttribute('aria-label', 'Decrement'); - expect(decrementButton).not.toHaveAttribute('id'); expect(decrementButton).not.toHaveAttribute('aria-labelledby'); }); 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 73b6ffd8812..308ada75f65 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'; @@ -60,6 +61,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. */ @@ -101,6 +107,7 @@ export const NumberField = /*#__PURE__*/ (forwardRef as forwardRefType)(function inputProps, incrementButtonProps, decrementButtonProps, + progressBarProps, descriptionProps, errorMessageProps, ...validation @@ -116,6 +123,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, isReadOnly: props.isReadOnly || false }, @@ -144,7 +152,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/TextField.tsx b/packages/react-aria-components/src/TextField.tsx index dd2e7a084d6..cc18c95ec7d 100644 --- a/packages/react-aria-components/src/TextField.tsx +++ b/packages/react-aria-components/src/TextField.tsx @@ -94,7 +94,10 @@ export const TextField = /*#__PURE__*/ createHideableComponent(function TextFiel !props['aria-label'] && !props['aria-labelledby'] ); let [inputElementType, setInputElementType] = useState('input'); - let state = useTextFieldState(props); + let state = useTextFieldState({ + ...props, + validationBehavior + }); let {labelProps, inputProps, descriptionProps, errorMessageProps, progressBarProps, ...validation} = useTextField({ ...removeDataAttributes(props), inputElementType, 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/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/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 b2099a006db..b0c685526b2 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(); @@ -508,4 +511,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 e2deb2a0551..9eea35711be 100644 --- a/packages/react-aria/src/autocomplete/useSearchAutocomplete.ts +++ b/packages/react-aria/src/autocomplete/useSearchAutocomplete.ts @@ -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,10 +121,15 @@ export function useSearchAutocomplete(props: AriaSearchAutocompleteOptions if (state.selectionManager.focusedKey === null) { onSubmit(value, null); } - }, + } + }); + + let {inputProps, clearButtonProps} = useSearchField({ + ...otherProps, + autoComplete: 'off', onKeyDown, onKeyUp - }, useSearchFieldState({value: state.inputValue, onChange: 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 62022cdfc21..add351b16a8 100644 --- a/packages/react-aria/src/button/useButton.ts +++ b/packages/react-aria/src/button/useButton.ts @@ -251,7 +251,7 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< 'aria-controls': props['aria-controls'], 'aria-pressed': props['aria-pressed'], 'aria-current': props['aria-current'], - 'aria-disabled': isPending ? 'true' : buttonProps['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. 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/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/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 3a765bfa21c..68c6e90d300 100644 --- a/packages/react-aria/src/textfield/useTextField.ts +++ b/packages/react-aria/src/textfield/useTextField.ts @@ -115,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 } /** @@ -154,6 +156,7 @@ export function useTextField = arguments.length === 3 ? arguments[2] : arguments[1]; @@ -161,7 +164,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/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/numberfield/useNumberFieldState.ts b/packages/react-stately/src/numberfield/useNumberFieldState.ts index d5a46978ad7..b78ebf0b0e0 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/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()); - }); -}); From b454faca6f47f0de4045bcc91434a4e6377c41bf Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Apr 2026 16:54:58 -0700 Subject: [PATCH 04/12] add RFC --- rfcs/2026-async-react.md | 81 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 rfcs/2026-async-react.md 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? From 83755430b1b2cda1a3eb24a8d2d0694c359d5630 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Apr 2026 17:03:05 -0700 Subject: [PATCH 05/12] fix jest config --- jest.config.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 630fbaa01650fe662d99f0fa706465faac820448 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 8 Apr 2026 17:12:13 -0700 Subject: [PATCH 06/12] Check if useOptimistic exists as well --- packages/react-stately/src/utils/useAction.ts | 2 +- packages/react-stately/src/utils/useControlledStateAction.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-stately/src/utils/useAction.ts b/packages/react-stately/src/utils/useAction.ts index ccb1d124695..d2674c1fd3e 100644 --- a/packages/react-stately/src/utils/useAction.ts +++ b/packages/react-stately/src/utils/useAction.ts @@ -12,7 +12,7 @@ import React from 'react'; -export const useAction = typeof React['useTransition'] === 'function' +export const useAction = typeof React['useTransition'] === 'function' && typeof React['useOptimistic'] === 'function' ? useActionModern : useActionLegacy; diff --git a/packages/react-stately/src/utils/useControlledStateAction.ts b/packages/react-stately/src/utils/useControlledStateAction.ts index dc3109bac58..05978e44fb2 100644 --- a/packages/react-stately/src/utils/useControlledStateAction.ts +++ b/packages/react-stately/src/utils/useControlledStateAction.ts @@ -13,7 +13,7 @@ import React, {SetStateAction, useCallback, useInsertionEffect, useRef} from 'react'; import {useControlledState} from './useControlledState'; -export const useControlledStateAction = typeof React['useTransition'] === 'function' +export const useControlledStateAction = typeof React['useTransition'] === 'function' && typeof React['useOptimistic'] === 'function' ? useControlledStateActionModern : useControlledStateActionLegacy; From d79e068a451ff721ce1d6ea8ac3a333fe1a074db Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Thu, 9 Apr 2026 18:02:15 -0700 Subject: [PATCH 07/12] wip: Experimenting with error handling --- packages/react-aria-components/src/Button.tsx | 15 ++++++--- .../react-aria-components/src/Tooltip.tsx | 12 ++++++- .../stories/Button.stories.tsx | 33 ++++++++++++++++--- .../stories/TextField.stories.tsx | 20 +++++++---- .../stories/button-pending.css | 6 ++++ packages/react-aria/src/button/useButton.ts | 9 +++-- .../src/form/useFormValidationState.ts | 20 ++++++++--- .../src/textfield/useTextFieldState.ts | 3 +- packages/react-stately/src/utils/useAction.ts | 25 +++++++++----- .../src/utils/useControlledStateAction.ts | 30 ++++++++++------- 10 files changed, 129 insertions(+), 44 deletions(-) diff --git a/packages/react-aria-components/src/Button.tsx b/packages/react-aria-components/src/Button.tsx index 0dad9212139..11989676810 100644 --- a/packages/react-aria-components/src/Button.tsx +++ b/packages/react-aria-components/src/Button.tsx @@ -60,7 +60,12 @@ export interface ButtonRenderProps { * Whether the button's action is pending. * @selector [data-pending] */ - isPending: boolean + isPending: boolean, + /** + * The last error that occurred within the button's action. + * @selector [data-action-error] + */ + actionError: unknown | null } export interface ButtonProps extends Omit, HoverEvents, SlotProps, RenderProps, Omit, 'onClick'> { @@ -83,7 +88,7 @@ export const ButtonContext = createContext) { [props, ref] = useContextProps(props, ref, ButtonContext); let ctx = props as ButtonContextValue; - let {buttonProps, progressBarProps, isPressed, isPending} = useButton(props, ref); + let {buttonProps, progressBarProps, isPressed, isPending, actionError} = useButton(props, ref); let {focusProps, isFocused, isFocusVisible} = useFocusRing(props); let {hoverProps, isHovered} = useHover({ ...props, @@ -95,7 +100,8 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop isFocused, isFocusVisible, isDisabled: props.isDisabled || false, - isPending + isPending, + actionError }; let renderProps = useRenderProps({ @@ -117,7 +123,8 @@ export const Button = /*#__PURE__*/ createHideableComponent(function Button(prop data-hovered={isHovered || undefined} data-focused={isFocused || undefined} data-pending={isPending || undefined} - data-focus-visible={isFocusVisible || undefined}> + data-focus-visible={isFocusVisible || undefined} + data-action-error={actionError || undefined}> {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 aecd22bf411..b56d5d6866d 100644 --- a/packages/react-aria-components/stories/Button.stories.tsx +++ b/packages/react-aria-components/stories/Button.stories.tsx @@ -52,7 +52,9 @@ export const PendingButtonTooltip: ButtonStory = { export const ReactAction: ButtonStory = { render: (args) => , args: { - children: 'Press me' + children: 'Press me', + // @ts-ignore + error: false } }; @@ -121,14 +123,23 @@ function PendingButtonTooltipExample(props) { } function ReactActionExample(props) { + let ref = useRef(null); return ( diff --git a/packages/react-aria-components/stories/TextField.stories.tsx b/packages/react-aria-components/stories/TextField.stories.tsx index cfe5f7b97fb..047bc08f9e2 100644 --- a/packages/react-aria-components/stories/TextField.stories.tsx +++ b/packages/react-aria-components/stories/TextField.stories.tsx @@ -75,15 +75,21 @@ export const ReactAction: TextFieldStory = () => {
{ - setSearch(value); + if (value === 'error') { + throw new Error('Error in action'); + } else { + setSearch(value); + } }}> - {({isPending}) => (<> - - - {isPending && } - )} + {({isPending}) => ( +
+ + + {isPending && } + +
+ )}
diff --git a/packages/react-aria-components/stories/button-pending.css b/packages/react-aria-components/stories/button-pending.css index 8ccbbe48c02..509063d2202 100644 --- a/packages/react-aria-components/stories/button-pending.css +++ b/packages/react-aria-components/stories/button-pending.css @@ -29,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/src/button/useButton.ts b/packages/react-aria/src/button/useButton.ts index add351b16a8..6bca4d1f71d 100644 --- a/packages/react-aria/src/button/useButton.ts +++ b/packages/react-aria/src/button/useButton.ts @@ -130,7 +130,9 @@ export interface ButtonAria { /** Whether the button is currently pressed. */ isPressed: boolean, /** Whether the button action is pending. */ - isPending: boolean + isPending: boolean, + /** The last error that occurred within the button's action. */ + actionError: unknown | null } // Order with overrides is important: 'button' should be default @@ -190,7 +192,7 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< }; } - let [onAction, isActionPending] = useAction(props.action); + let [onAction, isActionPending, actionError] = useAction(props.action); let isPending = props.isPending || isActionPending; let {pressProps, isPressed} = usePress({ @@ -260,7 +262,8 @@ export function useButton(props: AriaButtonOptions, ref: RefObject< progressBarProps: { id: progressId }, - isPending + isPending, + actionError }; } 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/textfield/useTextFieldState.ts b/packages/react-stately/src/textfield/useTextFieldState.ts index 610e16d207e..d02d0ca563b 100644 --- a/packages/react-stately/src/textfield/useTextFieldState.ts +++ b/packages/react-stately/src/textfield/useTextFieldState.ts @@ -34,9 +34,10 @@ export interface TextFieldState extends FormValidationState { * Provides state management for a text field. */ export function useTextFieldState(props: TextFieldProps): TextFieldState { - let [value, isPending, setValue] = useControlledStateAction(props.value, props.defaultValue || '', props.onChange, props.changeAction); + let [value, isPending, setValue, actionError] = useControlledStateAction(props.value, props.defaultValue || '', props.onChange, props.changeAction); let validationState = useFormValidationState({ ...props, + actionError, value }); diff --git a/packages/react-stately/src/utils/useAction.ts b/packages/react-stately/src/utils/useAction.ts index d2674c1fd3e..37c0e1a9a6e 100644 --- a/packages/react-stately/src/utils/useAction.ts +++ b/packages/react-stately/src/utils/useAction.ts @@ -10,27 +10,36 @@ * governing permissions and limitations under the License. */ -import React from 'react'; +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] { +export function useActionModern(action: ((...args: any[]) => void | Promise) | null | undefined): [((...args: any[]) => void) | undefined, boolean, unknown | null] { let [isPending, transition] = React.useTransition(); - let onEvent = (...args: any[]) => { + let [error, setError] = useState(null); + let [optimisticError, setOptimisticError] = React.useOptimistic(error); + let onEvent = useCallback((...args: any[]) => { transition(async () => { - await action!(...args); + 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]; + return [action ? onEvent : undefined, isPending, optimisticError]; } -export function useActionLegacy(action: ((...args: any[]) => void | Promise) | null | undefined): [((...args: any[]) => void) | undefined, boolean] { +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]; + return [undefined, false, null]; } diff --git a/packages/react-stately/src/utils/useControlledStateAction.ts b/packages/react-stately/src/utils/useControlledStateAction.ts index 05978e44fb2..01704de85e3 100644 --- a/packages/react-stately/src/utils/useControlledStateAction.ts +++ b/packages/react-stately/src/utils/useControlledStateAction.ts @@ -10,19 +10,21 @@ * governing permissions and limitations under the License. */ -import React, {SetStateAction, useCallback, useInsertionEffect, useRef} from 'react'; +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]; -function useControlledStateActionModern(value: Exclude | undefined, defaultValue: Exclude, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void]; -function useControlledStateActionModern(value: T, defaultValue: T, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void] { +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); @@ -50,21 +52,27 @@ function useControlledStateActionModern(value: T, defaultValue: T, onC setControlledValue(newValue); // Trigger the async action. - await changeAction(newValue); + try { + setOptimisticError(null); + await changeAction(newValue); + setError(null); + } catch (err) { + setError(err); + } } }); - }, [setControlledValue, setOptimisticValue, changeAction]); + }, [setControlledValue, setOptimisticValue, setOptimisticError, changeAction]); - return [optimisticValue, isPending, setValue]; + 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]; -function useControlledStateActionLegacy(value: Exclude | undefined, defaultValue: Exclude, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void]; -function useControlledStateActionLegacy(value: T, defaultValue: T, onChange?: (v: C) => void, changeAction?: (v: C) => void | Promise): [T, boolean, (value: SetStateAction) => void] { +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]; + return [controlledValue, false, setControlledValue, null]; } From 0e0f6b6ceff5e7f3f360c09b1a4bc1cce74037f2 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 14 Apr 2026 17:45:08 -0700 Subject: [PATCH 08/12] fix types --- packages/@react-spectrum/s2/src/Button.tsx | 3 ++- packages/react-aria-components/src/ToggleButton.tsx | 4 ++-- packages/react-aria/src/button/useToggleButton.ts | 5 +++-- packages/react-aria/src/button/useToggleButtonGroup.ts | 5 +++-- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/packages/@react-spectrum/s2/src/Button.tsx b/packages/@react-spectrum/s2/src/Button.tsx index 30144e8b53b..37a4fffa578 100644 --- a/packages/@react-spectrum/s2/src/Button.tsx +++ b/packages/@react-spectrum/s2/src/Button.tsx @@ -457,7 +457,8 @@ export const LinkButton = forwardRef(function LinkButton(props: LinkButtonProps, size, staticColor, isStaticColor: !!staticColor, - isPending: false + isPending: false, + actionError: null }, styles)}> {(renderProps) => (<> {variant === 'genai' || variant === 'premium' diff --git a/packages/react-aria-components/src/ToggleButton.tsx b/packages/react-aria-components/src/ToggleButton.tsx index 4b98b2000e3..8acefaa3e7f 100644 --- a/packages/react-aria-components/src/ToggleButton.tsx +++ b/packages/react-aria-components/src/ToggleButton.tsx @@ -71,7 +71,7 @@ export const ToggleButton = /*#__PURE__*/ (forwardRef as forwardRefType)(functio } } : props); - let {buttonProps, progressBarProps, isPressed, isSelected, isDisabled, isPending} = groupState && props.id != null + let {buttonProps, progressBarProps, isPressed, isSelected, isDisabled, isPending, actionError} = 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, isPending, state}, + values: {isHovered, isPressed, isFocused, isSelected: state.isSelected, isFocusVisible, isDisabled, isPending, state, actionError}, defaultClassName: 'react-aria-ToggleButton' }); diff --git a/packages/react-aria/src/button/useToggleButton.ts b/packages/react-aria/src/button/useToggleButton.ts index 4450683fdec..030627d0d3b 100644 --- a/packages/react-aria/src/button/useToggleButton.ts +++ b/packages/react-aria/src/button/useToggleButton.ts @@ -63,7 +63,7 @@ export function useToggleButton(props: AriaToggleButtonOptions, sta */ export function useToggleButton(props: AriaToggleButtonOptions, state: ToggleState, ref: RefObject): ToggleButtonAria> { const {isSelected} = state; - const {isPressed, buttonProps, progressBarProps, isPending} = useButton({ + const {isPressed, buttonProps, progressBarProps, isPending, actionError} = useButton({ ...props, isPending: props.isPending || state.isPending, onPress: chain(state.toggle, props.onPress) @@ -77,6 +77,7 @@ export function useToggleButton(props: AriaToggleButtonOptions, sta 'aria-pressed': isSelected }), progressBarProps, - isPending + isPending, + actionError }; } diff --git a/packages/react-aria/src/button/useToggleButtonGroup.ts b/packages/react-aria/src/button/useToggleButtonGroup.ts index cb82fd4adae..a222cb58d56 100644 --- a/packages/react-aria/src/button/useToggleButtonGroup.ts +++ b/packages/react-aria/src/button/useToggleButtonGroup.ts @@ -82,7 +82,7 @@ export function useToggleButtonGroupItem(props: AriaToggleButtonGroupItemOptions } }; - let {isPressed, isSelected, isDisabled, isPending, buttonProps, progressBarProps} = useToggleButton({ + let {isPressed, isSelected, isDisabled, isPending, buttonProps, progressBarProps, actionError} = useToggleButton({ ...props, id: undefined, isDisabled: props.isDisabled || state.isDisabled @@ -99,6 +99,7 @@ export function useToggleButtonGroupItem(props: AriaToggleButtonGroupItemOptions isDisabled, buttonProps, progressBarProps, - isPending + isPending, + actionError }; } From aa6f5b4c8e05103d803f8e8544d3fdf57ed20385 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Tue, 14 Apr 2026 17:45:17 -0700 Subject: [PATCH 09/12] add examples --- rfcs/2026-async-react.md | 150 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 148 insertions(+), 2 deletions(-) diff --git a/rfcs/2026-async-react.md b/rfcs/2026-async-react.md index 97572b0dea4..7d9f1612f59 100644 --- a/rfcs/2026-async-react.md +++ b/rfcs/2026-async-react.md @@ -33,9 +33,9 @@ Components with state will use `useOptimistic` to update immediately in response 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). +We will also catch errors that are thrown by actions and expose them as an `actionError` render prop, or via the `FieldError` component, enabling [in-line contextual error UIs](https://x.com/devongovett/status/1989788456751697958). This will help reduce over-reliance on toasts as a catch-all way of handling errors in applications by making inline errors just as easy to implement. -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. +All together, this significantly simplifies the implementation of loading states and error handling 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: @@ -50,6 +50,7 @@ Here's a potential list of components that could support actions: * DatePicker - `changeAction` * DateRangePicker - `changeAction` * Disclosure - `expandAction` +* Form – `submitAction` (add a `FormError` component to display form-level errors) * NumberField - `changeAction` * RadioGroup - `changeAction` * SearchField - `changeAction`, `submitAction`, `clearAction` @@ -61,6 +62,151 @@ Here's a potential list of components that could support actions: * TimeField - `changeAction` * ToggleButton - `changeAction` +### Examples + +#### Pending button + +A save button that displays a spinner while an action is running (e.g. calling a server). Pending buttons are not interactive (but remain focusable). + +```tsx +function App() { + return ( + + ); +} +``` + +#### Async search results + +A search field for a filterable list. Typing in the field causes a state update, and the results list suspends. While the results are loading, the search field displays a spinner and the previous results display in the list and remain interactive. + +This illustrates that the pending state may display for longer than the action itself if another part of the UI suspends as a result. The `Suspense` wrapping the result list only displays its fallback during the initial load sequence, not when the update is triggered by an action (this is React's default behavior for transitions). + +```tsx +function App() { + let [search, setSearch] = useState(''); + + return ( + <> + setSearch(value)}> + {({isPending}) => ( + <> + + + {isPending && } + + )} + + + + + + ); +} +``` + +#### Error handling + +If an error occurs in an action, it is available via the `actionError` render prop. This button has a shake animation when an error occurs, and displays an error icon. + +```tsx +function App() { + return ( + + ); +} +``` + +If you didn't want the Button itself to handle errors and wanted to show errors in a different way, you could add a try/catch statement within the action and catch the error there. + +```diff +action={async () => { ++ try { + await save(); ++ } catch (err) { ++ showToast(err); ++ } +}} +``` + +In field components, we could also use the existing `FieldError` to show action errors. In this example, if saving a setting failed, the error would be displayed below the checkbox. + +```tsx +function App() { + return ( + { + try { + await saveSetting(isSelected); + } catch { + throw 'Failed to save setting.'; + } + }}> + Setting + + + ); +} +``` + +**Note**: Errors are only caught when they occur within the action itself, not if another component suspends as a result. This makes sense from a UI perspective: if an error occurred while loading something, it should display where the results would have been (via an error boundary). If it occurred while saving something, it should display where the action was initiated. + +#### In a design system + +In a design system such as Spectrum, the loading and error states in the above examples would be built-in. This means application code does not need to worry these states at all. + +```tsx +import {Button, Checkbox} from '@react-spectrum/s2'; + +function App() { + return ( + <> + + { + try { + await saveSetting(isSelected); + } catch { + throw 'Failed to save setting.'; + } + }}> + Setting + + + ); +} +``` + ## 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. From 5d00b8eaff19cc8cd10596b3186f0f40f85e9423 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 15 Apr 2026 11:46:56 -0700 Subject: [PATCH 10/12] prototyping form actions --- .../react-aria-components/exports/Alert.ts | 18 +++ .../react-aria-components/exports/index.ts | 2 + packages/react-aria-components/src/Alert.tsx | 73 ++++++++++++ packages/react-aria-components/src/Button.tsx | 12 +- packages/react-aria-components/src/Form.tsx | 108 ++++++++++++++++-- .../stories/Form.stories.tsx | 50 ++++++++ rfcs/2026-async-react.md | 45 ++++++++ 7 files changed, 295 insertions(+), 13 deletions(-) create mode 100644 packages/react-aria-components/exports/Alert.ts create mode 100644 packages/react-aria-components/src/Alert.tsx diff --git a/packages/react-aria-components/exports/Alert.ts b/packages/react-aria-components/exports/Alert.ts new file mode 100644 index 00000000000..435916ef0d4 --- /dev/null +++ b/packages/react-aria-components/exports/Alert.ts @@ -0,0 +1,18 @@ +/* + * 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. + */ + +// Mark as a client only package. This will cause a build time error if you try +// to import it from a React Server Component in a framework like Next.js. +import 'client-only'; + +export {Alert, AlertContext} from '../src/Alert'; +export type {AlertProps, AlertRenderProps} from '../src/Alert'; diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index cb24054d65b..b0cfea9bd94 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -14,6 +14,7 @@ // to import it from a React Server Component in a framework like Next.js. import 'client-only'; +export {Alert, AlertContext} from '../src/Alert'; export {Autocomplete, AutocompleteContext, AutocompleteStateContext, SelectableCollectionContext, FieldInputContext} from '../src/Autocomplete'; export {Breadcrumbs, BreadcrumbsContext, Breadcrumb} from '../src/Breadcrumbs'; export {Button, ButtonContext} from '../src/Button'; @@ -101,6 +102,7 @@ export type {CollectionProps} from 'react-aria/Collection'; export type {Placement} from 'react-aria/useOverlayPosition'; export type {VisuallyHiddenProps} from 'react-aria/VisuallyHidden'; +export type {AlertProps, AlertRenderProps} from '../src/Alert'; export type {AutocompleteProps, SelectableCollectionContextValue} from '../src/Autocomplete'; export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbRenderProps} from '../src/Breadcrumbs'; export type {ButtonProps, ButtonRenderProps} from '../src/Button'; diff --git a/packages/react-aria-components/src/Alert.tsx b/packages/react-aria-components/src/Alert.tsx new file mode 100644 index 00000000000..0dcd8b1b2d1 --- /dev/null +++ b/packages/react-aria-components/src/Alert.tsx @@ -0,0 +1,73 @@ +/* + * 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 {ContextValue, RenderProps, useContextProps, useRenderProps} from './utils'; +import {DOMProps} from '@react-types/shared'; +import {filterDOMProps} from 'react-aria/filterDOMProps'; +import React, {createContext, ForwardedRef, forwardRef, useEffect, useRef} from 'react'; +import {useFocusRing} from 'react-aria/useFocusRing'; +import {useObjectRef} from 'react-aria/useObjectRef'; + +export interface AlertProps extends RenderProps, DOMProps { + /** + * Whether to automatically focus the alert when it first renders. + */ + autoFocus?: boolean +} + +export interface AlertRenderProps { + /** + * Whether the button is focused, either via a mouse or keyboard. + * @selector [data-focused] + */ + isFocused: boolean, + /** + * Whether the button is keyboard focused. + * @selector [data-focus-visible] + */ + isFocusVisible: boolean +} + +export const AlertContext = createContext>(null); + +export const Alert = forwardRef(function Alert(props: AlertProps, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, AlertContext); + let domProps = filterDOMProps(props, {global: true})!; + let {isFocused, isFocusVisible, focusProps} = useFocusRing({autoFocus: props.autoFocus}); + let renderProps = useRenderProps({ + ...props, + defaultClassName: 'react-aria-Alert', + values: { + isFocused, + isFocusVisible + } + }); + + let autoFocusRef = useRef(props.autoFocus); + let domRef = useObjectRef(ref); + useEffect(() => { + if (autoFocusRef.current && domRef.current) { + domRef.current.focus(); + } + autoFocusRef.current = false; + }, [domRef]); + + return ( +
+ ); +}); diff --git a/packages/react-aria-components/src/Button.tsx b/packages/react-aria-components/src/Button.tsx index 11989676810..6294b76e208 100644 --- a/packages/react-aria-components/src/Button.tsx +++ b/packages/react-aria-components/src/Button.tsx @@ -22,11 +22,12 @@ import { } from './utils'; import {createHideableComponent} from 'react-aria/private/collections/Hidden'; import {filterDOMProps} from 'react-aria/filterDOMProps'; +import {FormPendingContext} from './Form'; import {GlobalDOMAttributes} from '@react-types/shared'; import {HoverEvents} from '@react-types/shared'; import {mergeProps} from 'react-aria/mergeProps'; import {ProgressBarContext} from './ProgressBar'; -import React, {createContext, ForwardedRef} from 'react'; +import React, {createContext, ForwardedRef, useContext} from 'react'; import {useFocusRing} from 'react-aria/useFocusRing'; import {useHover} from 'react-aria/useHover'; @@ -88,7 +89,14 @@ export const ButtonContext = createContext) { [props, ref] = useContextProps(props, ref, ButtonContext); let ctx = props as ButtonContextValue; - let {buttonProps, progressBarProps, isPressed, isPending, actionError} = useButton(props, ref); + + // Ideally we would use React's `useFormStatus` for this but it is buggy. + // https://github.com/facebook/react/issues/30368 + let isFormPending = useContext(FormPendingContext); + let {buttonProps, progressBarProps, isPressed, isPending, actionError} = useButton({ + ...props, + isPending: props.isPending || (props.type === 'submit' && isFormPending) + }, ref); let {focusProps, isFocused, isFocusVisible} = useFocusRing(props); let {hoverProps, isHovered} = useHover({ ...props, diff --git a/packages/react-aria-components/src/Form.tsx b/packages/react-aria-components/src/Form.tsx index 2eb4d4f70f2..8131d8fc352 100644 --- a/packages/react-aria-components/src/Form.tsx +++ b/packages/react-aria-components/src/Form.tsx @@ -10,12 +10,14 @@ * governing permissions and limitations under the License. */ -import {ContextValue, dom, DOMProps, DOMRenderProps, useContextProps} from './utils'; +import {AlertContext} from './Alert'; +import {ContextValue, dom, Provider, RenderProps, useContextProps, useRenderProps} from './utils'; import {FormValidationContext} from 'react-stately/private/form/useFormValidationState'; import {GlobalDOMAttributes, FormProps as SharedFormProps} from '@react-types/shared'; -import React, {createContext, ForwardedRef, forwardRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, useMemo} from 'react'; +import {useAction} from 'react-stately/private/utils/useAction'; -export interface FormProps extends SharedFormProps, DOMProps, DOMRenderProps<'form', undefined>, GlobalDOMAttributes { +export interface FormProps extends SharedFormProps, RenderProps, GlobalDOMAttributes { /** * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. * @default 'react-aria-Form' @@ -27,10 +29,33 @@ export interface FormProps extends SharedFormProps, DOMProps, DOMRenderProps<'fo * or invalid via ARIA. * @default 'native' */ - validationBehavior?: 'aria' | 'native' + validationBehavior?: 'aria' | 'native', + /** + * Async action that is called when the value changes. + * This differs from the React's `action` prop in a few ways: + * + * * Errors thrown during the action are caught and passed to the `actionError` render prop. + * * The pending state is automatically passed to the form's submit button. + * * The form is not automatically reset after the action completes. + */ + submitAction?: (data: FormData) => void | Promise +} + +export interface FormRenderProps { + /** + * Whether the form's submit action is pending. + * @selector [data-pending] + */ + isPending: boolean, + /** + * The last error that occurred within the form's submit action. + * @selector [data-action-error] + */ + actionError: unknown | null } export const FormContext = createContext>(null); +export const FormPendingContext = createContext(false); /** * A form is a group of inputs that allows users to submit data to a server, @@ -38,14 +63,75 @@ export const FormContext = createContext) { [props, ref] = useContextProps(props, ref, FormContext); - let {validationErrors, validationBehavior = 'native', children, className, ...domProps} = props; + let {validationErrors, validationBehavior = 'native', children, className, style, submitAction, action, onSubmit, ...domProps} = props; + + let [onAction, isPending, actionError] = useAction(submitAction); + let [formError, fieldErrors] = useMemo(() => { + // Support errors from libraries conforming to the Standard Schema spec: https://standardschema.dev/schema + if (actionError && typeof actionError === 'object' && Array.isArray(actionError['issues'])) { + let formErrors: string[] = []; + let fieldErrors: Record = {}; + for (let issue of actionError['issues']) { + if ( + issue && + typeof issue === 'object' && + typeof issue.message === 'string' + ) { + if (Array.isArray(issue.path) && issue.path.length > 0 && typeof issue.path[0] === 'string') { + fieldErrors[issue.path[0]] ||= []; + fieldErrors[issue.path[0]].push(issue.message); + } else { + formErrors.push(issue.message); + } + } + } + + return [formErrors.length > 0 ? formErrors : null, fieldErrors]; + + // Alternative error shape based on Zod's flattenError result: https://zod.dev/error-formatting#zflattenerror + } else if (actionError && typeof actionError === 'object' && (actionError['formErrors'] || actionError['fieldErrors'])) { + return [actionError['formErrors'], actionError['fieldErrors']]; + } + + return [actionError, null]; + }, [actionError]); + + let renderProps = useRenderProps({ + children, + className, + style, + defaultClassName: 'react-aria-Form', + values: { + isPending, + actionError: formError + } + }); + return ( - - - - {children} - - + { + onSubmit?.(e); + if (onAction) { + e.preventDefault(); + onAction(new FormData(e.currentTarget)); + } + }}> + + {renderProps.children} + ); }); diff --git a/packages/react-aria-components/stories/Form.stories.tsx b/packages/react-aria-components/stories/Form.stories.tsx index 7531f0995ca..1662a6083c9 100644 --- a/packages/react-aria-components/stories/Form.stories.tsx +++ b/packages/react-aria-components/stories/Form.stories.tsx @@ -11,7 +11,9 @@ */ import {action} from 'storybook/actions'; +import {Alert} from '../src/Alert'; import {Button} from '../src/Button'; +import {FieldError} from '../src/FieldError'; import {Form} from '../src/Form'; import {Input} from '../src/Input'; import {Label} from '../src/Label'; @@ -77,3 +79,51 @@ export const FormAutoFillExample: FormStory = () => { ); }; +export const FormErrorExample: FormStory = () => { + return ( +
{ + await new Promise(resolve => setTimeout(resolve, 1000)); + + let name = formData.get('name'); + if (!name) { + throw { + issues: [{ + message: 'Enter your name', + path: ['name'] + }] + }; + } + + if (name === 'test') { + throw 'Could not create account. Please try again later.'; + } + }}> + {({actionError}) => (<> +

Submit an empty value for a field-level error.
Enter "test" to see a form-level error.

+ {actionError && + ({ + border: '2px solid red', + padding: 16, + outline: isFocusVisible ? '2px solid blue' : undefined, + outlineOffset: 2 + })}> + {String(actionError)} + + } + + + + + + + )} +
+ ); +}; diff --git a/rfcs/2026-async-react.md b/rfcs/2026-async-react.md index 7d9f1612f59..028329feedf 100644 --- a/rfcs/2026-async-react.md +++ b/rfcs/2026-async-react.md @@ -207,6 +207,51 @@ function App() { } ``` +#### Form errors + +When an error is thrown in a form's `submitAction`, it will be available via the `actionError` render prop. This can be displayed to the user by rendering an ``, which will be focused and announced by screen readers. For field-level errors (e.g. server validation), a special error object compatible with [Standard Schema](https://standardschema.dev/schema) could be supported, allowing these errors to be automatically propagated to the correct fields (as we support via the `validationErrors` prop today). + +**Note**: This proposes a separate `submitAction` prop rather than overloading the existing `action` prop supported by React. `submitAction` has a few differences from `action`: + +* Errors thrown during the action are caught and passed to the `actionError` render prop. +* The pending state is automatically passed to the form's submit button. Alternatively we could use React's [useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus) hook for that, but this has [bugs](https://github.com/facebook/react/issues/30368) at the moment. +* The form is not automatically reset after the action completes. This is a [controversial](https://github.com/facebook/react/issues/29034) behavior that is often unwanted (e.g. when errors occur). If a reset is desired, it can be triggered manually via `ReactDOM.requestFormReset`. + +```tsx +function App() { + return ( +
{ + let email = formData.get('email'); + if (!await isAccountAvailable(email)) { + throw { + issues: [{ + message: 'An account with that email already exists', + path: ['email'] + }] + } + } + + try { + await createAccount(email); + } catch { + throw 'Could not create account'; + } + }}> + {({actionError}) => ( + <> + {actionError && + {String(actionError)} + } + + + + )} + + ); +} +``` + ## 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. From 666d776473ddb0c1dacc0ebdd28c886fd6959912 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 15 Apr 2026 12:22:50 -0700 Subject: [PATCH 11/12] fix test --- packages/react-aria-components/src/Form.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/Form.tsx b/packages/react-aria-components/src/Form.tsx index 8131d8fc352..d0db3e2cbe1 100644 --- a/packages/react-aria-components/src/Form.tsx +++ b/packages/react-aria-components/src/Form.tsx @@ -63,7 +63,7 @@ export const FormPendingContext = createContext(false); */ export const Form = forwardRef(function Form(props: FormProps, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, FormContext); - let {validationErrors, validationBehavior = 'native', children, className, style, submitAction, action, onSubmit, ...domProps} = props; + let {validationErrors, validationBehavior = 'native', render, children, className, style, submitAction, action, onSubmit, ...domProps} = props; let [onAction, isPending, actionError] = useAction(submitAction); let [formError, fieldErrors] = useMemo(() => { @@ -100,6 +100,7 @@ export const Form = forwardRef(function Form(props: FormProps, ref: ForwardedRef children, className, style, + render, defaultClassName: 'react-aria-Form', values: { isPending, From ec5a08aa9d7ca051b9b4cbf35a53b70e8f7b41b5 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Wed, 22 Apr 2026 20:20:12 -0700 Subject: [PATCH 12/12] Add suspense example --- .storybook/preview.js | 6 + .../react-aria-components/src/ListBox.tsx | 106 ++++++++++++++---- .../stories/ListBox.stories.tsx | 102 +++++++++++++++++ rfcs/2026-async-react.md | 85 ++++++++++++-- 4 files changed, 267 insertions(+), 32 deletions(-) diff --git a/.storybook/preview.js b/.storybook/preview.js index ac74152dba0..104399addbf 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -28,6 +28,12 @@ export const parameters = { ] } }, + react: { + rootOptions: { + // Prevent errors caught in error boundaries from showing Parcel's error overlay. + onCaughtError() {} + } + }, layout: 'fullscreen', // Stops infinite loop memory crash when saving CSF stories https://github.com/storybookjs/storybook/issues/12747#issuecomment-1151803506 docs: { diff --git a/packages/react-aria-components/src/ListBox.tsx b/packages/react-aria-components/src/ListBox.tsx index 863b9797269..c06c93677c3 100644 --- a/packages/react-aria-components/src/ListBox.tsx +++ b/packages/react-aria-components/src/ListBox.tsx @@ -55,7 +55,7 @@ import {ListState, UNSTABLE_useFilteredListState, useListState} from 'react-stat import {LoadMoreSentinelProps, useLoadMoreSentinel} from 'react-aria/private/utils/useLoadMoreSentinel'; import {mergeProps} from 'react-aria/mergeProps'; import {Node, Orientation, SelectionBehavior} from '@react-types/shared'; -import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {SelectableCollectionContext, SelectableCollectionContextValue} from './Autocomplete'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SeparatorContext} from './Separator'; @@ -572,11 +572,24 @@ export const ListBoxLoadMoreItem = createLeafComponent(LoaderNode, function List scrollOffset }), [onLoadMore, scrollOffset, state?.collection]); useLoadMoreSentinel(memoedLoadMoreProps, sentinelRef); + + return ( + <> + {/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */} + {/* @ts-ignore - compatibility with React < 19 */} +
+
+
+ {isLoading && {item.rendered}} + + ); +}); + +const ListBoxLoader = createLeafComponent(LoaderNode, function ListBoxLoader(props: HTMLAttributes, ref: ForwardedRef) { let renderProps = useRenderProps({ - ...otherProps, + ...props, id: undefined, - children: item.rendered, - defaultClassName: 'react-aria-ListBoxLoadingIndicator', + defaultClassName: 'react-aria-ListBoxLoadMoreItem', values: undefined }); @@ -589,22 +602,73 @@ export const ListBoxLoadMoreItem = createLeafComponent(LoaderNode, function List }; return ( - <> - {/* Alway render the sentinel. For now onus is on the user for styling when using flex + gap (this would introduce a gap even though it doesn't take room) */} - {/* @ts-ignore - compatibility with React < 19 */} -
-
-
- {isLoading && renderProps.children && ( - }> - {renderProps.children} - - )} - + + {renderProps.children} + ); }); + +export interface ListBoxSuspenseProps { + fallback: ReactNode, + renderError?: (error: unknown) => ReactNode, + loading?: 'eager' | 'lazy', + children: ReactNode +} + +export function ListBoxSuspense({fallback, renderError, loading, children}: ListBoxSuspenseProps) { + let [isVisible, setVisible] = useState(false); + + if (loading === 'lazy' && !isVisible) { + return setVisible(true)} />; + } + + let res = ( + {fallback}}> + {children} + + ); + + if (renderError) { + res = ( + {renderError(err)}}> + {res} + + ); + } + + return res; +} + +interface ErrorBoundaryProps { + children: ReactNode, + renderError: (error: unknown) => ReactNode +} + +interface ErrorBoundaryState { + error: unknown | null +} + +class ErrorBoundary extends React.Component { + state = { + error: null + }; + + static getDerivedStateFromError(error: unknown) { + return {error}; + } + + componentDidCatch(): void {} + + render(): ReactNode { + if (this.state.error) { + return this.props.renderError(this.state.error); + } + + return this.props.children; + } +} diff --git a/packages/react-aria-components/stories/ListBox.stories.tsx b/packages/react-aria-components/stories/ListBox.stories.tsx index 110c6885a5f..b9f8997f690 100644 --- a/packages/react-aria-components/stories/ListBox.stories.tsx +++ b/packages/react-aria-components/stories/ListBox.stories.tsx @@ -19,6 +19,7 @@ import {ListBox, ListBoxItem, ListBoxProps, ListBoxSection} from '../src/ListBox import {ListBoxLoadMoreItem} from '../src/ListBox'; import {LoadingSpinner, MyHeader, MyListBoxItem} from './utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; +import {ListBoxSuspense as RACListBoxSuspense} from '../src/ListBox'; import React, {JSX, useState} from 'react'; import {Separator} from '../src/Separator'; import styles from '../example/index.css'; @@ -778,6 +779,107 @@ export const AsyncListBoxVirtualized: StoryFn = (args ); }; +export const ListBoxSuspense: StoryObj = { + render: (args) => , + args: { + orientation: 'vertical', + delay: 50, + error: false + }, + argTypes: { + orientation: { + control: 'radio', + options: ['horizontal', 'vertical'] + } + } +}; + +function ListBoxSuspenseRender(args: {delay: number, error: boolean, orientation: 'horizontal' | 'vertical'}): JSX.Element { + return ( + + } + renderError={err => String(err)}> + + + + ); +} + +function Page({url, delay, error}: {url: string, delay: number, error: boolean}) { + let promise = loadCached<{results: Character[], next: string | null}>(url, delay, error); + let {results, next} = React.use(promise); + + return ( + <> + + {item => ( + + {item.name} + + )} + + {next && ( + + +
+ } + renderError={(err) => String(err)}> + + + )} + + ); +} + +const cache = new Map(); + +async function load(url: string, delay: number, error: boolean) { + await new Promise(resolve => setTimeout(resolve, delay)); + if (error) { + throw 'Error loading pokemon!'; + } + let res = await fetch(url); + let json = await res.json(); + return json; +} + +function loadCached(url: string, delay: number, error: boolean): Promise { + let key = `${url}:${error}`; + let res = cache.get(key); + if (!res) { + res = load(url, delay, error); + cache.set(key, res); + } + return res; +} + export const ListBoxScrollMargin: ListBoxStory = (args) => { let items: {id: number, name: string, description: string}[] = []; for (let i = 0; i < 100; i++) { diff --git a/rfcs/2026-async-react.md b/rfcs/2026-async-react.md index 028329feedf..ccd1248dc96 100644 --- a/rfcs/2026-async-react.md +++ b/rfcs/2026-async-react.md @@ -19,13 +19,15 @@ In this RFC, we propose adding support for React's action prop pattern to React ## 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. +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 pending states across an entire app, and reduce the amount of code needed to handle data fetching 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. +While these React features 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. +This RFC proposes two new features: built-in action props, and improved support for data fetching with Suspense. While it's possible to introduce these at a higher level (e.g. in a design system), pending and error 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 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. @@ -35,7 +37,7 @@ To implement this, we can create a new hook that wraps `useControlledState` and We will also catch errors that are thrown by actions and expose them as an `actionError` render prop, or via the `FieldError` component, enabling [in-line contextual error UIs](https://x.com/devongovett/status/1989788456751697958). This will help reduce over-reliance on toasts as a catch-all way of handling errors in applications by making inline errors just as easy to implement. -All together, this significantly simplifies the implementation of loading states and error handling 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. +All together, this significantly simplifies the implementation of pending states and error handling 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: @@ -57,12 +59,13 @@ Here's a potential list of components that could support actions: * Select - `changeAction` * Slider - `changeAction` * Switch - `changeAction` (only when using `SwitchField`, introduced in [#9877](https://github.com/adobe/react-spectrum/pull/9877)) +* Table – `sortAction` (progress should show within the sorted column header) * Tabs - `selectionAction` * TextField - `changeAction` * TimeField - `changeAction` * ToggleButton - `changeAction` -### Examples +Below are some examples of some common patterns. #### Pending button @@ -211,12 +214,6 @@ function App() { When an error is thrown in a form's `submitAction`, it will be available via the `actionError` render prop. This can be displayed to the user by rendering an ``, which will be focused and announced by screen readers. For field-level errors (e.g. server validation), a special error object compatible with [Standard Schema](https://standardschema.dev/schema) could be supported, allowing these errors to be automatically propagated to the correct fields (as we support via the `validationErrors` prop today). -**Note**: This proposes a separate `submitAction` prop rather than overloading the existing `action` prop supported by React. `submitAction` has a few differences from `action`: - -* Errors thrown during the action are caught and passed to the `actionError` render prop. -* The pending state is automatically passed to the form's submit button. Alternatively we could use React's [useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus) hook for that, but this has [bugs](https://github.com/facebook/react/issues/30368) at the moment. -* The form is not automatically reset after the action completes. This is a [controversial](https://github.com/facebook/react/issues/29034) behavior that is often unwanted (e.g. when errors occur). If a reset is desired, it can be triggered manually via `ReactDOM.requestFormReset`. - ```tsx function App() { return ( @@ -252,6 +249,72 @@ function App() { } ``` +**Note**: This proposes a separate `submitAction` prop rather than overloading the existing `action` prop supported by React. `submitAction` has a few differences from `action`: + +* Errors thrown during the action are caught and passed to the `actionError` render prop. +* The pending state is automatically passed to the form's submit button. Alternatively we could use React's [useFormStatus](https://react.dev/reference/react-dom/hooks/useFormStatus) hook for that, but this has [bugs](https://github.com/facebook/react/issues/30368) at the moment. +* The form is not automatically reset after the action completes. This is a [controversial](https://github.com/facebook/react/issues/29034) behavior that is often unwanted (e.g. when errors occur). If a reset is desired, it can be triggered manually via `ReactDOM.requestFormReset`. + +### Suspense + +Today, we support initial loading states in our collection components through `renderEmptyState` with externally controlled state management. Infinite loading is done via collection-specific components, e.g. `ListBoxLoadMoreItem` which trigger their `onLoadMore` callback when scrolled into view. State management is entirely left to external data fetching libraries (e.g. our `useAsyncList` hook). + +With Suspense, we can simplify this by building loading states into our collection components. Here's what an infinite loading ListBox could look like: + +```tsx +function Example() { + return ( + + } + renderError={error => `Error loading data: ${error}`}> + + + + ); +} + +function Page({url}) { + // Use a Suspense-compatible data fetching library to get a cached promise for the url. + let promise = fetchCached(url); + let {results, next} = React.use(promise); + + // After the data loads, render the items. + return ( + <> + + {item => {item.name}} + + {next && ( + // Lazily render the next page recursively. + } + renderError={error => `Error loading data: ${error}`}> + + + )} + + ); +} +``` + +In this example, `ListBoxSuspense` works like `React.Suspense` but with a few additions: + +* It supports `loading="lazy"`, which renders its children only once it is near the viewport. +* It includes an error boundary when `renderError` is provided. +* It wraps its fallback/error in an appropriate element to ensure the accessibility tree is valid (e.g. `
`). + +TBD whether a separate component per collection is necessary, or if we could somehow ensure the correct accessibility wrapper is added automatically. + +There are several benefits to using Suspense for data fetching instead of an external hook: + +* It is declarative. External loading and error states must be manually passed around and rendered. Suspense allows design systems and component libraries to build in these states automatically, no matter the data source. +* It is composable. Different sections within a collection can load from different data sources. Apps can decide whether to make those have a single loading state or separate ones. +* It will wait for nested parts of the UI to become ready. For example, if each list item contained an image, the list could wait to display the images together instead of popping in one by one. +* It supports streaming data from the server with React Server Components, enabling fetching to start earlier. + ## 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.