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 + }; +}