Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add validation support for React Aria Components #5313

Merged
merged 5 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-aria-components/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"@react-aria/interactions": "^3.19.1",
"@react-aria/toolbar": "3.0.0-alpha.1",
"@react-aria/utils": "^3.21.1",
"@react-stately/form": "3.0.0-alpha.1",
"@react-stately/table": "^3.11.2",
"@react-types/calendar": "^3.4.1",
"@react-types/grid": "^3.2.2",
Expand Down
33 changes: 22 additions & 11 deletions packages/react-aria-components/src/Checkbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@
*/
import {AriaCheckboxGroupProps, AriaCheckboxProps, mergeProps, useCheckbox, useCheckboxGroup, useCheckboxGroupItem, useFocusRing, useHover, usePress, VisuallyHidden} from 'react-aria';
import {CheckboxGroupState, useCheckboxGroupState, useToggleState} from 'react-stately';
import {ContextValue, forwardRefType, Provider, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps} from '@react-aria/utils';
import {LabelContext} from './Label';
import React, {createContext, ForwardedRef, forwardRef, useContext, useState} from 'react';
import {TextContext} from './Text';

export interface CheckboxGroupProps extends Omit<AriaCheckboxGroupProps, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState'>, RenderProps<CheckboxGroupRenderProps>, SlotProps {}
export interface CheckboxProps extends Omit<AriaCheckboxProps, 'children' | 'validationState'>, RenderProps<CheckboxRenderProps>, SlotProps {}
export interface CheckboxGroupProps extends Omit<AriaCheckboxGroupProps, 'children' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<CheckboxGroupRenderProps>, SlotProps {}
export interface CheckboxProps extends Omit<AriaCheckboxProps, 'children' | 'validationState'>, RACValidation, RenderProps<CheckboxRenderProps>, SlotProps {}

export interface CheckboxGroupRenderProps {
/**
Expand Down Expand Up @@ -105,11 +106,15 @@ export const CheckboxGroupStateContext = createContext<CheckboxGroupState | null

function CheckboxGroup(props: CheckboxGroupProps, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, CheckboxGroupContext);
let state = useCheckboxGroupState(props);
let state = useCheckboxGroupState({
...props,
validationBehavior: props.validationBehavior ?? 'native'
});
let [labelRef, label] = useSlot();
let {groupProps, labelProps, descriptionProps, errorMessageProps} = useCheckboxGroup({
let {groupProps, labelProps, descriptionProps, errorMessageProps, ...validation} = useCheckboxGroup({
...props,
label
label,
validationBehavior: props.validationBehavior ?? 'native'
}, state);

let renderProps = useRenderProps({
Expand Down Expand Up @@ -143,7 +148,8 @@ function CheckboxGroup(props: CheckboxGroupProps, ref: ForwardedRef<HTMLDivEleme
description: descriptionProps,
errorMessage: errorMessageProps
}
}]
}],
[FieldErrorContext, validation]
]}>
{renderProps.children}
</Provider>
Expand All @@ -156,7 +162,7 @@ export const CheckboxContext = createContext<ContextValue<CheckboxProps, HTMLInp
function Checkbox(props: CheckboxProps, ref: ForwardedRef<HTMLInputElement>) {
[props, ref] = useContextProps(props, ref, CheckboxContext);
let groupState = useContext(CheckboxGroupStateContext);
let {inputProps, isSelected, isDisabled, isReadOnly, isPressed: isPressedKeyboard} = groupState
let {inputProps, isSelected, isDisabled, isReadOnly, isPressed: isPressedKeyboard, isInvalid} = groupState
// eslint-disable-next-line react-hooks/rules-of-hooks
? useCheckboxGroupItem({
...props,
Expand All @@ -168,7 +174,12 @@ function Checkbox(props: CheckboxProps, ref: ForwardedRef<HTMLInputElement>) {
children: typeof props.children === 'function' ? true : props.children
}, groupState, ref)
// eslint-disable-next-line react-hooks/rules-of-hooks
: useCheckbox({...props, children: typeof props.children === 'function' ? true : props.children}, useToggleState(props), ref);
: useCheckbox({
...props,
children: typeof props.children === 'function' ? true : props.children,
validationBehavior: props.validationBehavior ?? 'native'
// eslint-disable-next-line react-hooks/rules-of-hooks
}, useToggleState(props), ref);
let {isFocused, isFocusVisible, focusProps} = useFocusRing();
let isInteractionDisabled = isDisabled || isReadOnly;

Expand Down Expand Up @@ -208,7 +219,7 @@ function Checkbox(props: CheckboxProps, ref: ForwardedRef<HTMLInputElement>) {
isFocusVisible,
isDisabled,
isReadOnly,
isInvalid: props.isInvalid || groupState?.isInvalid || false,
isInvalid,
isRequired: props.isRequired || false
}
});
Expand All @@ -228,7 +239,7 @@ function Checkbox(props: CheckboxProps, ref: ForwardedRef<HTMLInputElement>) {
data-focus-visible={isFocusVisible || undefined}
data-disabled={isDisabled || undefined}
data-readonly={isReadOnly || undefined}
data-invalid={props.isInvalid || groupState?.isInvalid || undefined}
data-invalid={isInvalid || undefined}
data-required={props.isRequired || undefined}>
<VisuallyHidden elementType="span">
<input {...inputProps} {...focusProps} ref={ref} />
Expand Down
34 changes: 20 additions & 14 deletions packages/react-aria-components/src/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {AriaComboBoxProps, useComboBox, useFilter} from 'react-aria';
import {ButtonContext} from './Button';
import {Collection, ComboBoxState, Node, useComboBoxState} from 'react-stately';
import {CollectionDocumentContext, useCollectionDocument} from './Collection';
import {ContextValue, forwardRefType, Hidden, Provider, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Hidden, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps, useResizeObserver} from '@react-aria/utils';
import {InputContext} from './Input';
import {LabelContext} from './Label';
Expand Down Expand Up @@ -46,7 +47,7 @@ export interface ComboBoxRenderProps {
isRequired: boolean
}

export interface ComboBoxProps<T extends object> extends Omit<AriaComboBoxProps<T>, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState'>, RenderProps<ComboBoxRenderProps>, SlotProps {
export interface ComboBoxProps<T extends object> extends Omit<AriaComboBoxProps<T>, 'children' | 'placeholder' | 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<ComboBoxRenderProps>, SlotProps {
/** The filter function used to determine if a option should be included in the combo box list. */
defaultFilter?: (textValue: string, inputValue: string) => boolean,
/**
Expand Down Expand Up @@ -116,16 +117,10 @@ function ComboBoxInner<T extends object>({props, collection, comboBoxRef: ref}:
// If props.items isn't provided, rely on collection filtering (aka listbox.items is provided or defaultItems provided to Combobox)
items: props.items,
children: undefined,
collection
collection,
validationBehavior: props.validationBehavior ?? 'native'
});

// Only expose a subset of state to renderProps function to avoid infinite render loop
let renderPropsState = useMemo(() => ({
isOpen: state.isOpen,
isDisabled: props.isDisabled || false,
isInvalid: props.isInvalid || false,
isRequired: props.isRequired || false
}), [state.isOpen, props.isDisabled, props.isInvalid, props.isRequired]);
let buttonRef = useRef<HTMLButtonElement>(null);
let inputRef = useRef<HTMLInputElement>(null);
let listBoxRef = useRef<HTMLDivElement>(null);
Expand All @@ -137,15 +132,17 @@ function ComboBoxInner<T extends object>({props, collection, comboBoxRef: ref}:
listBoxProps,
labelProps,
descriptionProps,
errorMessageProps
errorMessageProps,
...validation
} = useComboBox({
...removeDataAttributes(props),
label,
inputRef,
buttonRef,
listBoxRef,
popoverRef,
name: formValue === 'text' ? name : undefined
name: formValue === 'text' ? name : undefined,
validationBehavior: props.validationBehavior ?? 'native'
}, state);

// Make menu width match input + button
Expand All @@ -165,6 +162,14 @@ function ComboBoxInner<T extends object>({props, collection, comboBoxRef: ref}:
onResize: onResize
});

// Only expose a subset of state to renderProps function to avoid infinite render loop
let renderPropsState = useMemo(() => ({
isOpen: state.isOpen,
isDisabled: props.isDisabled || false,
isInvalid: validation.isInvalid || false,
isRequired: props.isRequired || false
}), [state.isOpen, props.isDisabled, validation.isInvalid, props.isRequired]);

let renderProps = useRenderProps({
...props,
values: renderPropsState,
Expand Down Expand Up @@ -196,7 +201,8 @@ function ComboBoxInner<T extends object>({props, collection, comboBoxRef: ref}:
description: descriptionProps,
errorMessage: errorMessageProps
}
}]
}],
[FieldErrorContext, validation]
]}>
<div
{...DOMProps}
Expand All @@ -206,7 +212,7 @@ function ComboBoxInner<T extends object>({props, collection, comboBoxRef: ref}:
data-focused={state.isFocused || undefined}
data-open={state.isOpen || undefined}
data-disabled={props.isDisabled || undefined}
data-invalid={props.isInvalid || undefined}
data-invalid={validation.isInvalid || undefined}
data-required={props.isRequired || undefined} />
{name && formValue === 'key' && <input type="hidden" name={name} value={state.selectedKey} />}
</Provider>
Expand Down
33 changes: 24 additions & 9 deletions packages/react-aria-components/src/DateField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@
* governing permissions and limitations under the License.
*/
import {AriaDateFieldProps, AriaTimeFieldProps, DateValue, mergeProps, TimeValue, useDateField, useDateSegment, useFocusRing, useHover, useLocale, useTimeField} from 'react-aria';
import {ContextValue, forwardRefType, Provider, removeDataAttributes, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils';
import {createCalendar} from '@internationalized/date';
import {DateFieldState, DateSegmentType, DateSegment as IDateSegment, TimeFieldState, useDateFieldState, useTimeFieldState} from 'react-stately';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps, useObjectRef} from '@react-aria/utils';
import {Group, GroupContext} from './Group';
import {Input, InputContext} from './Input';
Expand All @@ -36,8 +37,8 @@ export interface DateFieldRenderProps {
*/
isDisabled: boolean
}
export interface DateFieldProps<T extends DateValue> extends Omit<AriaDateFieldProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState'>, RenderProps<DateFieldRenderProps>, SlotProps {}
export interface TimeFieldProps<T extends TimeValue> extends Omit<AriaTimeFieldProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState'>, RenderProps<DateFieldRenderProps>, SlotProps {}
export interface DateFieldProps<T extends DateValue> extends Omit<AriaDateFieldProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<DateFieldRenderProps>, SlotProps {}
export interface TimeFieldProps<T extends TimeValue> extends Omit<AriaTimeFieldProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<DateFieldRenderProps>, SlotProps {}

export const DateFieldContext = createContext<ContextValue<DateFieldProps<any>, HTMLDivElement>>(null);
export const TimeFieldContext = createContext<ContextValue<TimeFieldProps<any>, HTMLDivElement>>(null);
Expand All @@ -50,13 +51,19 @@ function DateField<T extends DateValue>(props: DateFieldProps<T>, ref: Forwarded
let state = useDateFieldState({
...props,
locale,
createCalendar
createCalendar,
validationBehavior: props.validationBehavior ?? 'native'
});

let fieldRef = useRef<HTMLDivElement>(null);
let [labelRef, label] = useSlot();
let inputRef = useRef<HTMLInputElement>(null);
let {labelProps, fieldProps, inputProps, descriptionProps, errorMessageProps} = useDateField({...removeDataAttributes(props), label, inputRef}, state, fieldRef);
let {labelProps, fieldProps, inputProps, descriptionProps, errorMessageProps, ...validation} = useDateField({
...removeDataAttributes(props),
label,
inputRef,
validationBehavior: props.validationBehavior ?? 'native'
}, state, fieldRef);

let renderProps = useRenderProps({
...removeDataAttributes(props),
Expand All @@ -83,7 +90,8 @@ function DateField<T extends DateValue>(props: DateFieldProps<T>, ref: Forwarded
description: descriptionProps,
errorMessage: errorMessageProps
}
}]
}],
[FieldErrorContext, validation]
]}>
<div
{...DOMProps}
Expand All @@ -107,13 +115,19 @@ function TimeField<T extends TimeValue>(props: TimeFieldProps<T>, ref: Forwarded
let {locale} = useLocale();
let state = useTimeFieldState({
...props,
locale
locale,
validationBehavior: props.validationBehavior ?? 'native'
});

let fieldRef = useRef<HTMLDivElement>(null);
let [labelRef, label] = useSlot();
let inputRef = useRef<HTMLInputElement>(null);
let {labelProps, fieldProps, inputProps, descriptionProps, errorMessageProps} = useTimeField({...removeDataAttributes(props), label, inputRef}, state, fieldRef);
let {labelProps, fieldProps, inputProps, descriptionProps, errorMessageProps, ...validation} = useTimeField({
...removeDataAttributes(props),
label,
inputRef,
validationBehavior: props.validationBehavior ?? 'native'
}, state, fieldRef);

let renderProps = useRenderProps({
...props,
Expand All @@ -140,7 +154,8 @@ function TimeField<T extends TimeValue>(props: TimeFieldProps<T>, ref: Forwarded
description: descriptionProps,
errorMessage: errorMessageProps
}
}]
}],
[FieldErrorContext, validation]
]}>
<div
{...DOMProps}
Expand Down
43 changes: 32 additions & 11 deletions packages/react-aria-components/src/DatePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@
import {AriaDatePickerProps, AriaDateRangePickerProps, DateValue, useDatePicker, useDateRangePicker, useFocusRing} from 'react-aria';
import {ButtonContext} from './Button';
import {CalendarContext, RangeCalendarContext} from './Calendar';
import {ContextValue, forwardRefType, Provider, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {ContextValue, forwardRefType, Provider, RACValidation, removeDataAttributes, RenderProps, SlotProps, useContextProps, useRenderProps, useSlot} from './utils';
import {DateFieldContext} from './DateField';
import {DatePickerState, DateRangePickerState, useDatePickerState, useDateRangePickerState} from 'react-stately';
import {DialogContext, OverlayTriggerStateContext} from './Dialog';
import {FieldErrorContext} from './FieldError';
import {filterDOMProps} from '@react-aria/utils';
import {GroupContext} from './Group';
import {LabelContext} from './Label';
Expand Down Expand Up @@ -61,8 +62,8 @@ export interface DateRangePickerRenderProps extends Omit<DatePickerRenderProps,
state: DateRangePickerState
}

export interface DatePickerProps<T extends DateValue> extends Omit<AriaDatePickerProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState'>, RenderProps<DatePickerRenderProps>, SlotProps {}
export interface DateRangePickerProps<T extends DateValue> extends Omit<AriaDateRangePickerProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState'>, RenderProps<DateRangePickerRenderProps>, SlotProps {}
export interface DatePickerProps<T extends DateValue> extends Omit<AriaDatePickerProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<DatePickerRenderProps>, SlotProps {}
export interface DateRangePickerProps<T extends DateValue> extends Omit<AriaDateRangePickerProps<T>, 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior'>, RACValidation, RenderProps<DateRangePickerRenderProps>, SlotProps {}

export const DatePickerContext = createContext<ContextValue<DatePickerProps<any>, HTMLDivElement>>(null);
export const DateRangePickerContext = createContext<ContextValue<DateRangePickerProps<any>, HTMLDivElement>>(null);
Expand All @@ -71,7 +72,11 @@ export const DateRangePickerStateContext = createContext<DateRangePickerState |

function DatePicker<T extends DateValue>(props: DatePickerProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, DatePickerContext);
let state = useDatePickerState(props);
let state = useDatePickerState({
...props,
validationBehavior: props.validationBehavior ?? 'native'
});

let groupRef = useRef<HTMLDivElement>(null);
let [labelRef, label] = useSlot();
let {
Expand All @@ -82,8 +87,13 @@ function DatePicker<T extends DateValue>(props: DatePickerProps<T>, ref: Forward
dialogProps,
calendarProps,
descriptionProps,
errorMessageProps
} = useDatePicker({...removeDataAttributes(props), label}, state, groupRef);
errorMessageProps,
...validation
} = useDatePicker({
...removeDataAttributes(props),
label,
validationBehavior: props.validationBehavior ?? 'native'
}, state, groupRef);

let {focusProps, isFocused, isFocusVisible} = useFocusRing({within: true});
let renderProps = useRenderProps({
Expand Down Expand Up @@ -119,7 +129,8 @@ function DatePicker<T extends DateValue>(props: DatePickerProps<T>, ref: Forward
description: descriptionProps,
errorMessage: errorMessageProps
}
}]
}],
[FieldErrorContext, validation]
]}>
<div
{...focusProps}
Expand All @@ -144,7 +155,11 @@ export {_DatePicker as DatePicker};

function DateRangePicker<T extends DateValue>(props: DateRangePickerProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, DateRangePickerContext);
let state = useDateRangePickerState(props);
let state = useDateRangePickerState({
...props,
validationBehavior: props.validationBehavior ?? 'native'
});

let groupRef = useRef<HTMLDivElement>(null);
let [labelRef, label] = useSlot();
let {
Expand All @@ -156,8 +171,13 @@ function DateRangePicker<T extends DateValue>(props: DateRangePickerProps<T>, re
dialogProps,
calendarProps,
descriptionProps,
errorMessageProps
} = useDateRangePicker({...removeDataAttributes(props), label}, state, groupRef);
errorMessageProps,
...validation
} = useDateRangePicker({
...removeDataAttributes(props),
label,
validationBehavior: props.validationBehavior ?? 'native'
}, state, groupRef);

let {focusProps, isFocused, isFocusVisible} = useFocusRing({within: true});
let renderProps = useRenderProps({
Expand Down Expand Up @@ -198,7 +218,8 @@ function DateRangePicker<T extends DateValue>(props: DateRangePickerProps<T>, re
description: descriptionProps,
errorMessage: errorMessageProps
}
}]
}],
[FieldErrorContext, validation]
]}>
<div
{...focusProps}
Expand Down
Loading