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 f3b4ac44f67..bfdaf89b1ce 100644
--- a/packages/@react-spectrum/s2/exports/index.ts
+++ b/packages/@react-spectrum/s2/exports/index.ts
@@ -162,7 +162,7 @@ export {CenterBaseline} from '../src/CenterBaseline';
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';
@@ -305,7 +305,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 e72133c7b1d..4f8b5045d95 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,
@@ -375,7 +375,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 {
GridListHeaderProps,
diff --git a/packages/react-aria-components/src/FileTrigger.tsx b/packages/react-aria-components/src/FileTrigger.tsx
index 7564a8a500e..13e122ec290 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 {AriaFileFieldProps, useFileField} from 'react-aria/useFileField';
+import {AriaLabelingProps, forwardRefType, GlobalDOMAttributes} from '@react-types/shared';
+import {
+ ClassNameOrFunction,
+ ContextValue,
+ dom,
+ Provider,
+ RACValidation,
+ RenderProps,
+ SlotProps,
+ useContextProps,
+ useRenderProps,
+ useSlot,
+ useSlottedContext
+} from './utils';
+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,22 @@ 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.
+ */
+ 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 +96,23 @@ export const FileTrigger = forwardRef(function FileTrigger(
defaultCamera,
children,
acceptDirectory,
+ isDisabled,
+ isInvalid,
+ name,
...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,20 +120,156 @@ export const FileTrigger = forwardRef(function FileTrigger(
}}>
{children}
- e.stopPropagation()}
- style={{display: 'none'}}
- accept={acceptedFileTypes?.toString()}
- onChange={e => 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' ? true : undefined}
+ aria-invalid={isInvalid}
+ // @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});
+
+ 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..2e22e773e42 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,279 @@ 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');
+ });
+
+ 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});
+ });
+
+ 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 84ae4850e3a..423957cafe7 100644
--- a/packages/react-aria/exports/index.ts
+++ b/packages/react-aria/exports/index.ts
@@ -89,6 +89,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';
@@ -362,6 +363,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
+ };
+}