From 7e17db53e04f9311a7c8658751fc3f1830592611 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Fri, 17 Jul 2026 16:15:03 +0530 Subject: [PATCH 1/6] Implement the fileField component for form. --- .../react-spectrum/exports/FileTrigger.ts | 4 +- .../@adobe/react-spectrum/exports/index.ts | 4 +- .../@react-spectrum/filetrigger/src/index.ts | 4 +- .../@react-spectrum/s2/exports/FileTrigger.ts | 4 +- packages/@react-spectrum/s2/exports/index.ts | 4 +- .../exports/FileTrigger.ts | 4 +- .../react-aria-components/exports/index.ts | 4 +- .../react-aria-components/src/FileTrigger.tsx | 218 ++++++++++++++-- .../test/FileTrigger.test.js | 246 +++++++++++++++++- packages/react-aria/exports/index.ts | 6 + packages/react-aria/exports/useFileField.ts | 17 ++ .../src/filetrigger/useFileField.ts | 120 +++++++++ 12 files changed, 600 insertions(+), 35 deletions(-) create mode 100644 packages/react-aria/exports/useFileField.ts create mode 100644 packages/react-aria/src/filetrigger/useFileField.ts diff --git a/packages/@adobe/react-spectrum/exports/FileTrigger.ts b/packages/@adobe/react-spectrum/exports/FileTrigger.ts index 159d86afd20..a39e85cea95 100644 --- a/packages/@adobe/react-spectrum/exports/FileTrigger.ts +++ b/packages/@adobe/react-spectrum/exports/FileTrigger.ts @@ -1,2 +1,2 @@ -export {FileTrigger} from 'react-aria-components/FileTrigger'; -export type {FileTriggerProps} from 'react-aria-components/FileTrigger'; +export {FileTrigger, FileField} from 'react-aria-components/FileTrigger'; +export type {FileTriggerProps, FileFieldProps} from 'react-aria-components/FileTrigger'; diff --git a/packages/@adobe/react-spectrum/exports/index.ts b/packages/@adobe/react-spectrum/exports/index.ts index 18f5fdd371c..706896982ab 100644 --- a/packages/@adobe/react-spectrum/exports/index.ts +++ b/packages/@adobe/react-spectrum/exports/index.ts @@ -52,7 +52,7 @@ export {DateRangePicker} from '../src/datepicker/DateRangePicker'; export {TimeField} from '../src/datepicker/TimeField'; export {Divider} from '../src/divider/Divider'; export {DropZone} from '../src/dropzone/DropZone'; -export {FileTrigger} from 'react-aria-components/FileTrigger'; +export {FileTrigger, FileField} from 'react-aria-components/FileTrigger'; export {Form} from '../src/form/Form'; export {Icon} from '../src/icon/Icon'; export {IllustratedMessage} from '../src/illustratedmessage/IllustratedMessage'; @@ -160,7 +160,7 @@ export type {SpectrumDateRangePickerProps} from '../src/datepicker/DateRangePick export type {SpectrumTimeFieldProps} from '../src/datepicker/TimeField'; export type {SpectrumDividerProps} from '../src/divider/Divider'; export type {SpectrumDropZoneProps} from '../src/dropzone/DropZone'; -export type {FileTriggerProps} from 'react-aria-components/FileTrigger'; +export type {FileTriggerProps, FileFieldProps} from 'react-aria-components/FileTrigger'; export type {SpectrumFormProps} from '../src/form/Form'; export type {IconProps} from '../src/icon/Icon'; export type {IllustrationProps} from '../src/icon/Illustration'; diff --git a/packages/@react-spectrum/filetrigger/src/index.ts b/packages/@react-spectrum/filetrigger/src/index.ts index ac6599cf522..5352771d35a 100644 --- a/packages/@react-spectrum/filetrigger/src/index.ts +++ b/packages/@react-spectrum/filetrigger/src/index.ts @@ -12,5 +12,5 @@ /// -export {FileTrigger} from 'react-aria-components/FileTrigger'; -export type {FileTriggerProps} from 'react-aria-components/FileTrigger'; +export {FileTrigger, FileField} from 'react-aria-components/FileTrigger'; +export type {FileTriggerProps, FileFieldProps} from 'react-aria-components/FileTrigger'; diff --git a/packages/@react-spectrum/s2/exports/FileTrigger.ts b/packages/@react-spectrum/s2/exports/FileTrigger.ts index 159d86afd20..a39e85cea95 100644 --- a/packages/@react-spectrum/s2/exports/FileTrigger.ts +++ b/packages/@react-spectrum/s2/exports/FileTrigger.ts @@ -1,2 +1,2 @@ -export {FileTrigger} from 'react-aria-components/FileTrigger'; -export type {FileTriggerProps} from 'react-aria-components/FileTrigger'; +export {FileTrigger, FileField} from 'react-aria-components/FileTrigger'; +export type {FileTriggerProps, FileFieldProps} from 'react-aria-components/FileTrigger'; diff --git a/packages/@react-spectrum/s2/exports/index.ts b/packages/@react-spectrum/s2/exports/index.ts index 1d0a90f0685..7b575369c43 100644 --- a/packages/@react-spectrum/s2/exports/index.ts +++ b/packages/@react-spectrum/s2/exports/index.ts @@ -152,7 +152,7 @@ export {mergeStyles} from '../style/runtime'; export {Autocomplete} from 'react-aria-components/Autocomplete'; export {Collection} from 'react-aria/Collection'; -export {FileTrigger} from 'react-aria-components/FileTrigger'; +export {FileTrigger, FileField} from 'react-aria-components/FileTrigger'; export {parseColor, getColorChannels} from 'react-stately/Color'; export {useLocale} from 'react-aria/I18nProvider'; export {useListData} from 'react-stately/useListData'; @@ -287,7 +287,7 @@ export type { export type {AutocompleteProps} from 'react-aria-components/Autocomplete'; export type {DateValue, DateRange} from 'react-aria-components/RangeCalendar'; export type {TimeValue} from 'react-aria-components/TimeField'; -export type {FileTriggerProps} from 'react-aria-components/FileTrigger'; +export type {FileTriggerProps, FileFieldProps} from 'react-aria-components/FileTrigger'; export type {TooltipTriggerComponentProps as TooltipTriggerProps} from 'react-aria-components/Tooltip'; export type { SortDescriptor, diff --git a/packages/react-aria-components/exports/FileTrigger.ts b/packages/react-aria-components/exports/FileTrigger.ts index f26c7ca38f6..7740dca383d 100644 --- a/packages/react-aria-components/exports/FileTrigger.ts +++ b/packages/react-aria-components/exports/FileTrigger.ts @@ -14,7 +14,7 @@ // to import it from a React Server Component in a framework like Next.js. import 'client-only'; -export {FileTrigger} from '../src/FileTrigger'; -export type {FileTriggerProps} from '../src/FileTrigger'; +export {FileTrigger, FileField, FileFieldContext} from '../src/FileTrigger'; +export type {FileTriggerProps, FileFieldProps, FileFieldRenderProps} from '../src/FileTrigger'; export {Pressable} from 'react-aria/Pressable'; diff --git a/packages/react-aria-components/exports/index.ts b/packages/react-aria-components/exports/index.ts index 3b0a0720dc9..30d176ffba2 100644 --- a/packages/react-aria-components/exports/index.ts +++ b/packages/react-aria-components/exports/index.ts @@ -111,7 +111,7 @@ export { } from '../src/Disclosure'; export {DropZone, DropZoneContext} from '../src/DropZone'; export {FieldError, FieldErrorContext} from '../src/FieldError'; -export {FileTrigger} from '../src/FileTrigger'; +export {FileTrigger, FileField, FileFieldContext} from '../src/FileTrigger'; export {Form, FormContext} from '../src/Form'; export { GridListLoadMoreItem, @@ -372,7 +372,7 @@ export type { } from '../src/Disclosure'; export type {DropZoneProps, DropZoneRenderProps} from '../src/DropZone'; export type {FieldErrorProps, FieldErrorRenderProps} from '../src/FieldError'; -export type {FileTriggerProps} from '../src/FileTrigger'; +export type {FileTriggerProps, FileFieldProps, FileFieldRenderProps} from '../src/FileTrigger'; export type {FormProps} from '../src/Form'; export type { GridListProps, diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx index 89d27fecc17..a5faed839d0 100644 --- a/packages/react-aria-components/src/FileTrigger.tsx +++ b/packages/react-aria-components/src/FileTrigger.tsx @@ -10,16 +10,35 @@ * governing permissions and limitations under the License. */ +import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; +import { + ClassNameOrFunction, + ContextValue, + dom, + Provider, + RACValidation, + RenderProps, + SlotProps, + useContextProps, + useRenderProps, + useSlot, + useSlottedContext +} from './utils'; +import {AriaFileFieldProps, useFileField} from 'react-aria/useFileField'; +import {FieldErrorContext} from './FieldError'; import {filterDOMProps} from 'react-aria/filterDOMProps'; - +import {FormContext} from './Form'; import {getEventTarget} from 'react-aria/private/utils/shadowdom/DOMFunctions'; -import {GlobalDOMAttributes} from '@react-types/shared'; import {Input} from './Input'; +import {LabelContext} from './Label'; import {PressResponder} from 'react-aria/private/interactions/PressResponder'; -import React, {ForwardedRef, forwardRef, ReactNode} from 'react'; +import React, {createContext, ForwardedRef, forwardRef, ReactNode, useRef} from 'react'; +import {TextContext} from './Text'; import {useObjectRef} from 'react-aria/useObjectRef'; +import {VisuallyHidden} from 'react-aria/VisuallyHidden'; -export interface FileTriggerProps extends GlobalDOMAttributes { +export interface FileTriggerProps + extends AriaLabelingProps, RACValidation, GlobalDOMAttributes { /** * Specifies what mime type of files are allowed. */ @@ -44,6 +63,23 @@ export interface FileTriggerProps extends GlobalDOMAttributes * Enables the selection of directories instead of individual files. */ acceptDirectory?: boolean; + /** + * The name of the input element, used when submitting an HTML form. See the [MDN + * docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#name) for more info. + */ + name?: string; + /** + * Whether the input is disabled. + */ + isDisabled?: boolean; + /** + * Whether user input is required on the input before form submission. + */ + isRequired?: boolean; + /** + * Whether the input value is invalid. + */ + isInvalid?: boolean; } /** @@ -61,15 +97,23 @@ export const FileTrigger = forwardRef(function FileTrigger( defaultCamera, children, acceptDirectory, + name, + isDisabled, + isInvalid, ...rest } = props; + let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; + let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; let inputRef = useObjectRef(ref); - let domProps = filterDOMProps(rest, {global: true}); + let domProps = filterDOMProps(rest, {global: true, labelable: true}); return ( <> { + if (isDisabled) { + return; + } if (inputRef.current?.value) { inputRef.current.value = ''; } @@ -77,19 +121,157 @@ export const FileTrigger = forwardRef(function FileTrigger( }}> {children} - onSelect?.(getEventTarget(e).files)} - capture={defaultCamera} - multiple={allowsMultiple} - // @ts-expect-error - webkitdirectory={acceptDirectory ? '' : undefined} - /> + + onSelect?.(getEventTarget(e).files)} + capture={defaultCamera} + multiple={allowsMultiple} + name={name} + disabled={isDisabled} + required={props.isRequired && validationBehavior === 'native'} + aria-required={(props.isRequired && validationBehavior === 'aria') || undefined} + aria-invalid={isInvalid || undefined} + // @ts-expect-error + webkitdirectory={acceptDirectory ? '' : undefined} + /> + ); }); + +export interface FileFieldRenderProps { + /** + * Whether the file field is disabled. + * + * @selector [data-disabled] + */ + isDisabled: boolean; + /** + * Whether the file field is invalid. + * + * @selector [data-invalid] + */ + isInvalid: boolean; + /** + * Whether the file field is required. + * + * @selector [data-required] + */ + isRequired: boolean; +} + +export interface FileFieldProps + extends + Omit< + AriaFileFieldProps, + 'label' | 'description' | 'errorMessage' | 'validationState' | 'validationBehavior' + >, + RACValidation, + RenderProps, + SlotProps, + GlobalDOMAttributes { + /** + * Specifies what mime type of files are allowed. + */ + acceptedFileTypes?: ReadonlyArray; + /** + * Whether multiple files can be selected. + */ + allowsMultiple?: boolean; + /** + * Specifies the use of a media capture mechanism to capture the media on the spot. + */ + defaultCamera?: 'user' | 'environment'; + /** + * Enables the selection of directories instead of individual files. + */ + acceptDirectory?: boolean; + /** + * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the + * element. A function may be provided to compute the class based on component state. + * + * @default 'react-aria-FileField' + */ + className?: ClassNameOrFunction; +} + +export const FileFieldContext = createContext>(null); + +/** + * A FileField allows a user to select a file from their local file system as part of an HTML + * form, and composes with Label and FieldError to support validation. + */ +export const FileField = /*#__PURE__*/ (forwardRef as forwardRefType)(function FileField( + props: FileFieldProps, + ref: ForwardedRef +) { + [props, ref] = useContextProps(props, ref, FileFieldContext); + let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; + let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native'; + let inputRef = useRef(null); + let [labelRef, label] = useSlot(!props['aria-label'] && !props['aria-labelledby']); + let {labelProps, triggerProps, descriptionProps, errorMessageProps, ...validation} = useFileField( + { + ...props, + label, + validationBehavior + }, + inputRef + ); + + let renderProps = useRenderProps({ + ...props, + values: { + isDisabled: props.isDisabled || false, + isRequired: props.isRequired || false, + isInvalid: validation.isInvalid + }, + defaultClassName: 'react-aria-FileField' + }); + + let DOMProps = filterDOMProps(props, {global: true}); + delete DOMProps.id; + + return ( + + + + {renderProps.children} + + + + ); +}); diff --git a/packages/react-aria-components/test/FileTrigger.test.js b/packages/react-aria-components/test/FileTrigger.test.js index 25322420612..e57517e940a 100644 --- a/packages/react-aria-components/test/FileTrigger.test.js +++ b/packages/react-aria-components/test/FileTrigger.test.js @@ -9,11 +9,13 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ +import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; import {Button} from '../src/Button'; - -import {FileTrigger} from '../src/FileTrigger'; +import {FieldError} from '../src/FieldError'; +import {FileField, FileTrigger} from '../src/FileTrigger'; +import {Form} from '../src/Form'; +import {Label} from '../src/Label'; import {Link} from '../src/Link'; -import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; import userEvent from '@testing-library/user-event'; @@ -102,4 +104,242 @@ describe('FileTrigger', () => { let input = document.querySelector('input[type="file"]'); expect(input).toHaveAttribute('webkitdirectory'); }); + + it('should pass the name prop through to the hidden input', () => { + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('name', 'avatar'); + }); + + it('should disable the hidden input and prevent opening the file picker when isDisabled is set', async () => { + let {getByRole} = render( + + + + ); + + let button = getByRole('button'); + let input = document.querySelector('input[type="file"]'); + let clickSpy = jest.spyOn(input, 'click'); + expect(input).toBeDisabled(); + + await user.click(button); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('should mark the hidden input as required in native validation mode', () => { + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('required'); + expect(input).not.toHaveAttribute('aria-required'); + }); + + it('should mark the hidden input as aria-required in aria validation mode', () => { + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).not.toHaveAttribute('required'); + expect(input).toHaveAttribute('aria-required', 'true'); + }); + + it('should mark the hidden input as aria-invalid when isInvalid is set', () => { + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('aria-invalid', 'true'); + }); + + it('should allow multiple files to be selected', async () => { + let file1 = new File(['hello'], 'hello.png', {type: 'image/png'}); + let file2 = new File(['world'], 'world.png', {type: 'image/png'}); + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('multiple'); + expect(input).toHaveAttribute('name', 'photos'); + + await userEvent.upload(input, [file1, file2]); + expect(input.files).toHaveLength(2); + expect(input.files[0].name).toBe('hello.png'); + expect(input.files[1].name).toBe('world.png'); + }); +}); + +describe('FileField', () => { + let user; + beforeAll(() => { + user = userEvent.setup({delay: null, pointerMap}); + }); + + it('should associate the Label with the hidden input', () => { + let {getByText} = render( + + + + + ); + + let label = getByText('Files'); + let input = document.querySelector('input[type="file"]'); + expect(label).toHaveAttribute('for', input.id); + expect(input).toHaveAttribute('id', label.getAttribute('for')); + }); + + it('should pass the name prop through to the hidden input', () => { + render( + + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('name', 'files'); + }); + + it('should open the file picker when the trigger is pressed', async () => { + let {getByRole} = render( + + + + + ); + + let button = getByRole('button'); + let input = document.querySelector('input[type="file"]'); + let clickSpy = jest.spyOn(input, 'click'); + await user.click(button); + expect(clickSpy).toHaveBeenCalledTimes(1); + }); + + it('should disable the hidden input and mark the wrapper as disabled when isDisabled is set', async () => { + let {getByRole} = render( + + + + + ); + + let button = getByRole('button'); + let input = document.querySelector('input[type="file"]'); + let clickSpy = jest.spyOn(input, 'click'); + expect(input).toBeDisabled(); + expect(button.closest('.react-aria-FileField')).toHaveAttribute('data-disabled'); + + await user.click(button); + expect(clickSpy).not.toHaveBeenCalled(); + }); + + it('supports required validation errors', () => { + let {getByRole, getByTestId} = render( +
+ + + + + +
+ ); + + let button = getByRole('button'); + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('required'); + expect(input).not.toHaveAttribute('aria-describedby'); + expect(input.validity.valid).toBe(false); + + act(() => { + getByTestId('form').checkValidity(); + }); + + expect(input).toHaveAttribute('aria-describedby'); + expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent( + 'Constraints not satisfied' + ); + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(button.closest('.react-aria-FileField')).toHaveAttribute('data-invalid'); + }); + + it('supports selecting multiple files', async () => { + let file1 = new File(['hello'], 'hello.png', {type: 'image/png'}); + let file2 = new File(['world'], 'world.png', {type: 'image/png'}); + render( + + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('multiple'); + expect(input).toHaveAttribute('name', 'photos'); + + await user.upload(input, [file1, file2]); + expect(input.files).toHaveLength(2); + expect(input.files[0].name).toBe('hello.png'); + expect(input.files[1].name).toBe('world.png'); + }); + + it('supports a custom validate function', async () => { + render( + (files && files[0] && files[0].size > 5 ? 'File too large.' : null)}> + + + + + ); + + let input = document.querySelector('input[type="file"]'); + let file = new File(['hello world'], 'hello.png', {type: 'image/png'}); + await user.upload(input, file); + + expect(input).toHaveAttribute('aria-invalid', 'true'); + expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent( + 'File too large.' + ); + }); + + it('supports server validation errors via Form', () => { + let {getByRole} = render( +
+ + + + + +
+ ); + + let button = getByRole('button'); + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('aria-describedby'); + expect(document.getElementById(input.getAttribute('aria-describedby'))).toHaveTextContent( + 'Upload failed, try again.' + ); + expect(button.closest('.react-aria-FileField')).toHaveAttribute('data-invalid'); + }); }); diff --git a/packages/react-aria/exports/index.ts b/packages/react-aria/exports/index.ts index 37c8c65bfa1..733a17e0838 100644 --- a/packages/react-aria/exports/index.ts +++ b/packages/react-aria/exports/index.ts @@ -88,6 +88,7 @@ export {useFocusable, Focusable} from '../src/interactions/useFocusable'; export {Pressable} from '../src/interactions/Pressable'; export {useField} from '../src/label/useField'; export {useLabel} from '../src/label/useLabel'; +export {useFileField} from '../src/filetrigger/useFileField'; export {useGridList} from '../src/gridlist/useGridList'; export {useGridListItem} from '../src/gridlist/useGridListItem'; export {useGridListSection} from '../src/gridlist/useGridListSection'; @@ -347,6 +348,11 @@ export type {PressableProps} from '../src/interactions/Pressable'; export type {ScrollWheelProps} from '../src/interactions/useScrollWheel'; export type {AriaFieldProps, FieldAria} from '../src/label/useField'; export type {LabelAria, LabelAriaProps} from '../src/label/useLabel'; +export type { + AriaFileFieldProps, + FileFieldAria, + FileFieldTriggerProps +} from '../src/filetrigger/useFileField'; export type { AriaLandmarkRole, AriaLandmarkProps, diff --git a/packages/react-aria/exports/useFileField.ts b/packages/react-aria/exports/useFileField.ts new file mode 100644 index 00000000000..cdb359532e4 --- /dev/null +++ b/packages/react-aria/exports/useFileField.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +export {useFileField} from '../src/filetrigger/useFileField'; +export type { + AriaFileFieldProps, + FileFieldAria, + FileFieldTriggerProps +} from '../src/filetrigger/useFileField'; diff --git a/packages/react-aria/src/filetrigger/useFileField.ts b/packages/react-aria/src/filetrigger/useFileField.ts new file mode 100644 index 00000000000..3d2fa4f1e6f --- /dev/null +++ b/packages/react-aria/src/filetrigger/useFileField.ts @@ -0,0 +1,120 @@ +/* + * 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 { + AriaLabelingProps, + DOMAttributes, + DOMProps, + HelpTextProps, + LabelableProps, + RefObject, + Validation, + ValidationResult +} from '@react-types/shared'; +import {filterDOMProps} from '../utils/filterDOMProps'; +import {mergeProps} from '../utils/mergeProps'; +import {useField} from '../label/useField'; +import {useFormReset} from '../utils/useFormReset'; +import {useFormValidation} from '../form/useFormValidation'; +import {useFormValidationState} from 'react-stately/private/form/useFormValidationState'; +import {useState} from 'react'; + +export interface AriaFileFieldProps + extends LabelableProps, HelpTextProps, DOMProps, AriaLabelingProps, Validation { + /** + * HTML form input name. See + * [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#name). + */ + name?: string; + /** Whether the input is disabled. */ + isDisabled?: boolean; + /** Handler that is called when the user selects one or more files. */ + onSelect?: (files: FileList | null) => void; +} + +export interface FileFieldTriggerProps extends DOMProps, AriaLabelingProps { + /** HTML form input name. */ + name?: string; + /** Whether the input is disabled. */ + isDisabled?: boolean; + /** Whether user input is required on the input before form submission. */ + isRequired?: boolean; + /** Whether the input value is invalid. */ + isInvalid?: boolean; + /** Whether to use native HTML form validation or ARIA to mark the input as required/invalid. */ + validationBehavior: 'native' | 'aria'; + /** Handler that is called when the user selects one or more files. */ + onSelect: (files: FileList | null) => void; +} + +export interface FileFieldAria extends ValidationResult { + /** Props for the label element. */ + labelProps: DOMAttributes; + /** Props to spread onto the FileTrigger that renders the underlying hidden file input. */ + triggerProps: FileFieldTriggerProps; + /** Props for the description element, if any. */ + descriptionProps: DOMAttributes; + /** Props for the error message element, if any. */ + errorMessageProps: DOMAttributes; +} + +/** + * Provides the behavior and accessibility implementation for a file field, allowing users to + * select files from their local file system as part of a form, with support for labeling, + * descriptions, and validation errors. + * + * @param props - Props for the file field. + * @param ref - Ref to the hidden file input element. + */ +export function useFileField( + props: AriaFileFieldProps, + ref: RefObject +): FileFieldAria { + let {name, isDisabled = false, isRequired = false, validationBehavior = 'aria', onSelect} = props; + + let [files, setFiles] = useState(null); + let validationState = useFormValidationState({ + ...props, + value: files + }); + let {isInvalid, validationErrors, validationDetails} = validationState.displayValidation; + let {labelProps, fieldProps, descriptionProps, errorMessageProps} = useField({ + ...props, + isInvalid, + errorMessage: props.errorMessage || validationErrors + }); + + useFormReset(ref, null, setFiles); + useFormValidation(props, validationState, ref); + + let domProps = filterDOMProps(props, {labelable: true}); + + return { + labelProps, + triggerProps: mergeProps(domProps, fieldProps, { + name, + isDisabled, + isRequired, + isInvalid, + validationBehavior, + onSelect(selectedFiles: FileList | null) { + setFiles(selectedFiles); + onSelect?.(selectedFiles); + } + }), + descriptionProps, + errorMessageProps, + isInvalid, + validationErrors, + validationDetails + }; +} From df1e7cda268df29cff0033c5c0c5b8db75145a2e Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Mon, 20 Jul 2026 19:20:22 +0530 Subject: [PATCH 2/6] added the Changes for file field --- packages/react-aria-components/src/FileTrigger.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx index a5faed839d0..77e106fb3a2 100644 --- a/packages/react-aria-components/src/FileTrigger.tsx +++ b/packages/react-aria-components/src/FileTrigger.tsx @@ -97,9 +97,9 @@ export const FileTrigger = forwardRef(function FileTrigger( defaultCamera, children, acceptDirectory, - name, isDisabled, isInvalid, + name, ...rest } = props; let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {}; From 9097ea2545cc672500c9cd5b7f4be9bbf275d9cb Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Tue, 21 Jul 2026 11:31:24 +0530 Subject: [PATCH 3/6] modified the file field implementation --- .../react-aria-components/src/FileTrigger.tsx | 27 +++--- .../test/FileTrigger.test.js | 93 +++++++++++++------ 2 files changed, 79 insertions(+), 41 deletions(-) diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx index 77e106fb3a2..580e9502109 100644 --- a/packages/react-aria-components/src/FileTrigger.tsx +++ b/packages/react-aria-components/src/FileTrigger.tsx @@ -64,8 +64,7 @@ export interface FileTriggerProps */ acceptDirectory?: boolean; /** - * The name of the input element, used when submitting an HTML form. See the [MDN - * docs](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#name) for more info. + * The name of the input element, used when submitting an HTML form */ name?: string; /** @@ -118,7 +117,8 @@ export const FileTrigger = forwardRef(function FileTrigger( inputRef.current.value = ''; } inputRef.current?.click(); - }}> + }} + > {children} @@ -135,8 +135,8 @@ export const FileTrigger = forwardRef(function FileTrigger( name={name} disabled={isDisabled} required={props.isRequired && validationBehavior === 'native'} - aria-required={(props.isRequired && validationBehavior === 'aria') || undefined} - aria-invalid={isInvalid || undefined} + aria-required={props.isRequired && validationBehavior === 'aria' ? true : undefined} + aria-invalid={isInvalid} // @ts-expect-error webkitdirectory={acceptDirectory ? '' : undefined} /> @@ -236,17 +236,17 @@ export const FileField = /*#__PURE__*/ (forwardRef as forwardRefType)(function F }); let DOMProps = filterDOMProps(props, {global: true}); - delete DOMProps.id; return ( + > + {...triggerProps} + validationBehavior={validationBehavior} + > {renderProps.children} diff --git a/packages/react-aria-components/test/FileTrigger.test.js b/packages/react-aria-components/test/FileTrigger.test.js index e57517e940a..da7464af7f8 100644 --- a/packages/react-aria-components/test/FileTrigger.test.js +++ b/packages/react-aria-components/test/FileTrigger.test.js @@ -9,23 +9,23 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; -import {Button} from '../src/Button'; -import {FieldError} from '../src/FieldError'; -import {FileField, FileTrigger} from '../src/FileTrigger'; -import {Form} from '../src/Form'; -import {Label} from '../src/Label'; -import {Link} from '../src/Link'; +import { act, pointerMap, render } from '@react-spectrum/test-utils-internal'; +import { Button } from '../src/Button'; +import { FieldError } from '../src/FieldError'; +import { FileField, FileTrigger } from '../src/FileTrigger'; +import { Form } from '../src/Form'; +import { Label } from '../src/Label'; +import { Link } from '../src/Link'; import React from 'react'; import userEvent from '@testing-library/user-event'; describe('FileTrigger', () => { let user; beforeAll(() => { - user = userEvent.setup({delay: null, pointerMap}); + user = userEvent.setup({ delay: null, pointerMap }); }); it('should render a FileTrigger with Link', () => { - let {getByRole} = render( + let { getByRole } = render( Upload @@ -36,7 +36,7 @@ describe('FileTrigger', () => { }); it('should render a FileTrigger with Button', () => { - let {getByRole} = render( + let { getByRole } = render( @@ -47,8 +47,8 @@ describe('FileTrigger', () => { }); it('should upload a file with Button', async () => { - let file = new File(['hello'], 'hello.png', {type: 'image/png'}); - let {getByRole} = render( + let file = new File(['hello'], 'hello.png', { type: 'image/png' }); + let { getByRole } = render( @@ -66,8 +66,8 @@ describe('FileTrigger', () => { }); it('should upload a file with Link', async () => { - let file = new File(['hello'], 'hello.png', {type: 'image/png'}); - let {getByRole} = render( + let file = new File(['hello'], 'hello.png', { type: 'image/png' }); + let { getByRole } = render( Upload @@ -84,7 +84,7 @@ describe('FileTrigger', () => { it('should attach a ref to the input', () => { let ref = React.createRef(); - let {getByTestId} = render( + let { getByTestId } = render( @@ -117,7 +117,7 @@ describe('FileTrigger', () => { }); it('should disable the hidden input and prevent opening the file picker when isDisabled is set', async () => { - let {getByRole} = render( + let { getByRole } = render( @@ -168,8 +168,8 @@ describe('FileTrigger', () => { }); it('should allow multiple files to be selected', async () => { - let file1 = new File(['hello'], 'hello.png', {type: 'image/png'}); - let file2 = new File(['world'], 'world.png', {type: 'image/png'}); + let file1 = new File(['hello'], 'hello.png', { type: 'image/png' }); + let file2 = new File(['world'], 'world.png', { type: 'image/png' }); render( @@ -185,16 +185,53 @@ describe('FileTrigger', () => { expect(input.files[0].name).toBe('hello.png'); expect(input.files[1].name).toBe('world.png'); }); + + it('should set the accept attribute when acceptedFileTypes is provided', () => { + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('accept', 'image/png,image/jpeg'); + }); + + it('should set the capture attribute when defaultCamera is provided', () => { + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + expect(input).toHaveAttribute('capture', 'user'); + }); + + it('should call onSelect when files are selected', async () => { + let onSelect = jest.fn(); + let file = new File(['hello'], 'hello.png', { type: 'image/png' }); + render( + + + + ); + + let input = document.querySelector('input[type="file"]'); + await userEvent.upload(input, file); + expect(onSelect).toHaveBeenCalledTimes(1); + expect(onSelect).toHaveBeenCalledWith(input.files); + }); }); describe('FileField', () => { let user; beforeAll(() => { - user = userEvent.setup({delay: null, pointerMap}); + user = userEvent.setup({ delay: null, pointerMap }); }); it('should associate the Label with the hidden input', () => { - let {getByText} = render( + let { getByText } = render( @@ -220,7 +257,7 @@ describe('FileField', () => { }); it('should open the file picker when the trigger is pressed', async () => { - let {getByRole} = render( + let { getByRole } = render( @@ -235,7 +272,7 @@ describe('FileField', () => { }); it('should disable the hidden input and mark the wrapper as disabled when isDisabled is set', async () => { - let {getByRole} = render( + let { getByRole } = render( @@ -253,7 +290,7 @@ describe('FileField', () => { }); it('supports required validation errors', () => { - let {getByRole, getByTestId} = render( + let { getByRole, getByTestId } = render(
@@ -282,8 +319,8 @@ describe('FileField', () => { }); it('supports selecting multiple files', async () => { - let file1 = new File(['hello'], 'hello.png', {type: 'image/png'}); - let file2 = new File(['world'], 'world.png', {type: 'image/png'}); + let file1 = new File(['hello'], 'hello.png', { type: 'image/png' }); + let file2 = new File(['world'], 'world.png', { type: 'image/png' }); render( @@ -314,7 +351,7 @@ describe('FileField', () => { ); let input = document.querySelector('input[type="file"]'); - let file = new File(['hello world'], 'hello.png', {type: 'image/png'}); + let file = new File(['hello world'], 'hello.png', { type: 'image/png' }); await user.upload(input, file); expect(input).toHaveAttribute('aria-invalid', 'true'); @@ -324,8 +361,8 @@ describe('FileField', () => { }); it('supports server validation errors via Form', () => { - let {getByRole} = render( - + let { getByRole } = render( + From 2ede4e47bc554952dcaf7f6c7e7cc52adea8d582 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Thu, 23 Jul 2026 18:43:34 +0530 Subject: [PATCH 4/6] formatted the test file. --- .../test/FileTrigger.test.js | 58 +++++++++---------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/packages/react-aria-components/test/FileTrigger.test.js b/packages/react-aria-components/test/FileTrigger.test.js index da7464af7f8..2e22e773e42 100644 --- a/packages/react-aria-components/test/FileTrigger.test.js +++ b/packages/react-aria-components/test/FileTrigger.test.js @@ -9,23 +9,23 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import { act, pointerMap, render } from '@react-spectrum/test-utils-internal'; -import { Button } from '../src/Button'; -import { FieldError } from '../src/FieldError'; -import { FileField, FileTrigger } from '../src/FileTrigger'; -import { Form } from '../src/Form'; -import { Label } from '../src/Label'; -import { Link } from '../src/Link'; +import {act, pointerMap, render} from '@react-spectrum/test-utils-internal'; +import {Button} from '../src/Button'; +import {FieldError} from '../src/FieldError'; +import {FileField, FileTrigger} from '../src/FileTrigger'; +import {Form} from '../src/Form'; +import {Label} from '../src/Label'; +import {Link} from '../src/Link'; import React from 'react'; import userEvent from '@testing-library/user-event'; describe('FileTrigger', () => { let user; beforeAll(() => { - user = userEvent.setup({ delay: null, pointerMap }); + user = userEvent.setup({delay: null, pointerMap}); }); it('should render a FileTrigger with Link', () => { - let { getByRole } = render( + let {getByRole} = render( Upload @@ -36,7 +36,7 @@ describe('FileTrigger', () => { }); it('should render a FileTrigger with Button', () => { - let { getByRole } = render( + let {getByRole} = render( @@ -47,8 +47,8 @@ describe('FileTrigger', () => { }); it('should upload a file with Button', async () => { - let file = new File(['hello'], 'hello.png', { type: 'image/png' }); - let { getByRole } = render( + let file = new File(['hello'], 'hello.png', {type: 'image/png'}); + let {getByRole} = render( @@ -66,8 +66,8 @@ describe('FileTrigger', () => { }); it('should upload a file with Link', async () => { - let file = new File(['hello'], 'hello.png', { type: 'image/png' }); - let { getByRole } = render( + let file = new File(['hello'], 'hello.png', {type: 'image/png'}); + let {getByRole} = render( Upload @@ -84,7 +84,7 @@ describe('FileTrigger', () => { it('should attach a ref to the input', () => { let ref = React.createRef(); - let { getByTestId } = render( + let {getByTestId} = render( @@ -117,7 +117,7 @@ describe('FileTrigger', () => { }); it('should disable the hidden input and prevent opening the file picker when isDisabled is set', async () => { - let { getByRole } = render( + let {getByRole} = render( @@ -168,8 +168,8 @@ describe('FileTrigger', () => { }); it('should allow multiple files to be selected', async () => { - let file1 = new File(['hello'], 'hello.png', { type: 'image/png' }); - let file2 = new File(['world'], 'world.png', { type: 'image/png' }); + let file1 = new File(['hello'], 'hello.png', {type: 'image/png'}); + let file2 = new File(['world'], 'world.png', {type: 'image/png'}); render( @@ -210,7 +210,7 @@ describe('FileTrigger', () => { it('should call onSelect when files are selected', async () => { let onSelect = jest.fn(); - let file = new File(['hello'], 'hello.png', { type: 'image/png' }); + let file = new File(['hello'], 'hello.png', {type: 'image/png'}); render( @@ -227,11 +227,11 @@ describe('FileTrigger', () => { describe('FileField', () => { let user; beforeAll(() => { - user = userEvent.setup({ delay: null, pointerMap }); + user = userEvent.setup({delay: null, pointerMap}); }); it('should associate the Label with the hidden input', () => { - let { getByText } = render( + let {getByText} = render( @@ -257,7 +257,7 @@ describe('FileField', () => { }); it('should open the file picker when the trigger is pressed', async () => { - let { getByRole } = render( + let {getByRole} = render( @@ -272,7 +272,7 @@ describe('FileField', () => { }); it('should disable the hidden input and mark the wrapper as disabled when isDisabled is set', async () => { - let { getByRole } = render( + let {getByRole} = render( @@ -290,7 +290,7 @@ describe('FileField', () => { }); it('supports required validation errors', () => { - let { getByRole, getByTestId } = render( + let {getByRole, getByTestId} = render( @@ -319,8 +319,8 @@ describe('FileField', () => { }); it('supports selecting multiple files', async () => { - let file1 = new File(['hello'], 'hello.png', { type: 'image/png' }); - let file2 = new File(['world'], 'world.png', { type: 'image/png' }); + let file1 = new File(['hello'], 'hello.png', {type: 'image/png'}); + let file2 = new File(['world'], 'world.png', {type: 'image/png'}); render( @@ -351,7 +351,7 @@ describe('FileField', () => { ); let input = document.querySelector('input[type="file"]'); - let file = new File(['hello world'], 'hello.png', { type: 'image/png' }); + let file = new File(['hello world'], 'hello.png', {type: 'image/png'}); await user.upload(input, file); expect(input).toHaveAttribute('aria-invalid', 'true'); @@ -361,8 +361,8 @@ describe('FileField', () => { }); it('supports server validation errors via Form', () => { - let { getByRole } = render( - + let {getByRole} = render( + From a6332b03ff74ac866d20185368a66d487a87ef12 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Thu, 23 Jul 2026 19:31:21 +0530 Subject: [PATCH 5/6] resolved lint and CI test compilation error> --- packages/react-aria-components/src/FileTrigger.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx index 580e9502109..07a565d21eb 100644 --- a/packages/react-aria-components/src/FileTrigger.tsx +++ b/packages/react-aria-components/src/FileTrigger.tsx @@ -64,7 +64,7 @@ export interface FileTriggerProps */ acceptDirectory?: boolean; /** - * The name of the input element, used when submitting an HTML form + * The name of the input element, used when submitting an HTML form. */ name?: string; /** @@ -117,8 +117,7 @@ export const FileTrigger = forwardRef(function FileTrigger( inputRef.current.value = ''; } inputRef.current?.click(); - }} - > + }}> {children} @@ -245,8 +244,7 @@ export const FileField = /*#__PURE__*/ (forwardRef as forwardRefType)(function F data-invalid={validation.isInvalid} data-required={props.isRequired} {...DOMProps} - {...renderProps} - > + {...renderProps}> + {...triggerProps} + validationBehavior={validationBehavior}> {renderProps.children} From 37cc4245dab8adca543b24a23990b1887228ca80 Mon Sep 17 00:00:00 2001 From: jsmitrah Date: Thu, 23 Jul 2026 20:10:57 +0530 Subject: [PATCH 6/6] resolved lint and CI test compilation error --- packages/react-aria-components/src/FileTrigger.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx index 07a565d21eb..13e122ec290 100644 --- a/packages/react-aria-components/src/FileTrigger.tsx +++ b/packages/react-aria-components/src/FileTrigger.tsx @@ -10,6 +10,7 @@ * governing permissions and limitations under the License. */ +import {AriaFileFieldProps, useFileField} from 'react-aria/useFileField'; import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes} from '@react-types/shared'; import { ClassNameOrFunction, @@ -24,7 +25,6 @@ import { useSlot, useSlottedContext } from './utils'; -import {AriaFileFieldProps, useFileField} from 'react-aria/useFileField'; import {FieldErrorContext} from './FieldError'; import {filterDOMProps} from 'react-aria/filterDOMProps'; import {FormContext} from './Form'; @@ -239,9 +239,9 @@ export const FileField = /*#__PURE__*/ (forwardRef as forwardRefType)(function F return (