Skip to content
Open
4 changes: 2 additions & 2 deletions packages/@adobe/react-spectrum/exports/FileTrigger.ts
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 2 additions & 2 deletions packages/@adobe/react-spectrum/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-spectrum/filetrigger/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@

/// <reference types="css-module-types" />

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';
4 changes: 2 additions & 2 deletions packages/@react-spectrum/s2/exports/FileTrigger.ts
Original file line number Diff line number Diff line change
@@ -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';
4 changes: 2 additions & 2 deletions packages/@react-spectrum/s2/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/react-aria-components/exports/FileTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
4 changes: 2 additions & 2 deletions packages/react-aria-components/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
217 changes: 198 additions & 19 deletions packages/react-aria-components/src/FileTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLInputElement> {
export interface FileTriggerProps
extends AriaLabelingProps, RACValidation, GlobalDOMAttributes<HTMLInputElement> {
/**
* Specifies what mime type of files are allowed.
*/
Expand All @@ -44,6 +63,22 @@ export interface FileTriggerProps extends GlobalDOMAttributes<HTMLInputElement>
* 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;
}

/**
Expand All @@ -61,36 +96,180 @@ 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 (
<>
<PressResponder
onPress={() => {
if (isDisabled) {
return;
}
if (inputRef.current?.value) {
inputRef.current.value = '';
}
inputRef.current?.click();
}}>
{children}
</PressResponder>
<Input
{...domProps}
className=""
type="file"
ref={inputRef}
onClick={e => e.stopPropagation()}
style={{display: 'none'}}
accept={acceptedFileTypes?.toString()}
onChange={e => onSelect?.(getEventTarget(e).files)}
capture={defaultCamera}
multiple={allowsMultiple}
// @ts-expect-error
webkitdirectory={acceptDirectory ? '' : undefined}
/>
<VisuallyHidden>
<Input
{...domProps}
className=""
type="file"
ref={inputRef}
tabIndex={-1}
accept={acceptedFileTypes?.toString()}
onChange={e => 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}
/>
</VisuallyHidden>
</>
);
});

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<FileFieldRenderProps>,
SlotProps,
GlobalDOMAttributes<HTMLDivElement> {
/**
* Specifies what mime type of files are allowed.
*/
acceptedFileTypes?: ReadonlyArray<string>;
/**
* 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<FileFieldRenderProps>;
}

export const FileFieldContext = createContext<ContextValue<FileFieldProps, HTMLDivElement>>(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<HTMLDivElement>
) {
[props, ref] = useContextProps(props, ref, FileFieldContext);
let {validationBehavior: formValidationBehavior} = useSlottedContext(FormContext) || {};
let validationBehavior = props.validationBehavior ?? formValidationBehavior ?? 'native';
let inputRef = useRef<HTMLInputElement>(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 (
<dom.div
ref={ref}
slot={props.slot || undefined}
data-disabled={props.isDisabled}
data-invalid={validation.isInvalid ?? undefined}
data-required={props.isRequired}
{...DOMProps}
{...renderProps}>
<Provider
values={[
[LabelContext, {...labelProps, ref: labelRef}],
[
TextContext,
{
slots: {
description: descriptionProps,
errorMessage: errorMessageProps
}
}
],
[FieldErrorContext, validation]
]}>
<FileTrigger
ref={inputRef}
acceptedFileTypes={props.acceptedFileTypes}
allowsMultiple={props.allowsMultiple}
defaultCamera={props.defaultCamera}
acceptDirectory={props.acceptDirectory}
{...triggerProps}
validationBehavior={validationBehavior}>
{renderProps.children}
</FileTrigger>
</Provider>
</dom.div>
);
});
Loading