// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file FileInput.tsx * @input Uses React, useId, Field, Icon, Spinner, VisuallyHidden * @output Exports FileInput component, public types, and its root/icon theme targets * @position Core implementation; consumed by index.ts, tested by FileInput.test.tsx * * SYNC: When modified, update these files to stay in sync: * - /packages/core/src/FileInput/FileInput.doc.mjs (props table, features, implementation notes) * - /packages/core/src/FileInput/FileInput.test.tsx (tests for new/changed behavior) * - /packages/core/src/FileInput/index.ts (exports if types change) * - /apps/storybook/stories/FileInput.stories.tsx (storybook stories) * - /packages/cli/assets/templates/blocks/components/FileInput/ (showcase blocks) */ import { useId, useCallback, useRef, useState, useTransition, type DragEvent } from 'react'; import * as stylex from "../style-runtime.js"; import "../theme/tokens.stylex.js"; import { colorVars, sizeVars, spacingVars, radiusVars, typographyVars, typeScaleVars, borderVars, durationVars, easeVars } from "../theme/tokens.stylex.js"; import { Field, InputClearButton, type InputStatus, type FieldStatusVariant } from "../Field/index.js"; import { Icon } from "../Icon/index.js"; import { Spinner } from "../Spinner/index.js"; import { VisuallyHidden } from "../VisuallyHidden/index.js"; import { useAnnounce } from "../hooks/useAnnounce.js"; import { useInputStatusIcon } from "../hooks/useInputStatusIcon.js"; import { useClickableContainer } from "../hooks/useClickableContainer.js"; import { useTooltip } from "../Tooltip/index.js"; export type { InputStatus as FileInputStatus, InputStatusType as FileInputStatusType } from "../Field/index.js"; import { mergeProps } from "../utils/index.js"; import type { BaseProps } from "../BaseProps.js"; import type { SizeValue } from "../utils/types.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import type { TranslatorFn } from "../i18n/index.js"; import { useMergedRefs } from "../hooks/useMergedRefs.js"; function formatFileSize(bytes: number): string { if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } function validateFiles(files: File[], accept: string | undefined, maxSize: number | undefined, maxFiles: number | undefined, isMultiple: boolean, t: TranslatorFn): { valid: File[]; errors: string[]; } { const errors: string[] = []; let valid = files; if (accept) { const acceptedTypes = accept.split(',').map(s => s.trim().toLowerCase()); valid = valid.filter(file => { const matches = acceptedTypes.some(type => { if (type.startsWith('.')) { return file.name.toLowerCase().endsWith(type); } if (type.endsWith('/*')) { return file.type.startsWith(type.slice(0, -1)); } return file.type.toLowerCase() === type; }); if (!matches) { errors.push(t('@astryx.fileInput.errorInvalidType', { fileName: file.name })); } return matches; }); } if (maxSize != null) { valid = valid.filter(file => { if (file.size > maxSize) { errors.push(t('@astryx.fileInput.errorMaxSize', { fileName: file.name, maxSize: formatFileSize(maxSize) })); return false; } return true; }); } if (isMultiple && maxFiles != null && valid.length > maxFiles) { errors.push(t('@astryx.fileInput.errorMaxFiles', { maxFiles })); valid = valid.slice(0, maxFiles); } return { valid, errors }; } const styles = { dropzone: { kB7OPa: "x9f619", kVAEAm: "x1n2onr6", kY2c9j: "x1vjfegm", k1xSpc: "x78zum5", kXwgrk: "xdt5ytf", kGNEyG: "x6s0dn4", kjj79g: "xl56j7k", kOIVth: "x1txdalj", k8WAf4: "xq6koh6", kg3NbH: "x1pzlopt", kMzoRj: "x1litavf", ksu8eU: "xbsl7fq", kVAM5u: "xvy26l8 x6q1khz", kaIpWk: "xh6dtrn", kWkggS: "x10xzikg", k1ekBW: "x1tv3a4w", kIyJzY: "xuedmi6 x12w9bfk", kAMwcw: "xlr8y92", kkrTdU: "x1ypdohk x16khyan", kI3sdo: "x1a2a7pz", $$css: true as const }, dropzoneHover: { kGVxlE: "x1v5wwkd", $$css: true as const }, dropzoneActive: { kVAM5u: "xad5do", kWkggS: "xgcxg3y", $$css: true as const }, dropzoneDisabled: { kkrTdU: "xt0e3qv", kSiTet: "xbyyjgo", kVAM5u: "xvy26l8", $$css: true as const }, compact: { kB7OPa: "x9f619", kVAEAm: "x1n2onr6", kY2c9j: "x1vjfegm", k1xSpc: "x78zum5", kGNEyG: "x6s0dn4", kOIVth: "x1txdalj", k8WAf4: "xu0wf1k", kg3NbH: "xf314gf", kMzoRj: "x1litavf", ksu8eU: "x1y0btm7", kVAM5u: "xvy26l8 x6q1khz", kaIpWk: "xh6dtrn", kWkggS: "x10xzikg", k1ekBW: "x12zzom9", kIyJzY: "xuedmi6 x12w9bfk", kAMwcw: "xlr8y92", kGVxlE: "x1gnnqk1 x1telqz2", kkrTdU: "x1ypdohk x16khyan", kZKoxP: "x1ueg155", kI3sdo: "x1a2a7pz", $$css: true as const }, compactDisabled: { kkrTdU: "xt0e3qv", kSiTet: "xbyyjgo", kVAM5u: "xvy26l8", $$css: true as const } }; const statusBorderStyles = { warning: { kVAM5u: "x8wg1ba", $$css: true as const }, error: { kVAM5u: "x1ofxpqo", $$css: true as const }, success: { kVAM5u: "x16m2moy", $$css: true as const } }; export interface FileInputProps extends Omit { ref?: React.Ref; /** * Accessible label for the file input. */ label: string; /** * Whether to visually hide the label (still accessible to screen readers). * @default false */ isLabelHidden?: boolean; /** * Currently selected file(s). Controlled component. */ value: File | File[] | null; /** * Callback fired when files are selected or removed. */ onChange: (files: File | File[] | null) => void; /** * Async change action (React 19 transitions pattern). * Use for immediate upload on file selection. */ changeAction?: (files: File | File[] | null) => Promise; /** * Accepted file types. Uses the HTML accept attribute format. * Examples: "image/*", ".pdf,.doc,.docx", "image/png,image/jpeg" */ accept?: string; /** * Whether multiple files can be selected. * When true, `value` and `onChange` use `File[]` instead of `File`. * @default false */ isMultiple?: boolean; /** * Maximum file size in bytes. Files exceeding this are rejected * with an error status. */ maxSize?: number; /** * Maximum number of files (only applies when `isMultiple` is true). */ maxFiles?: number; /** * Whether the input is disabled. * @default false */ isDisabled?: boolean; /** * Explains why the input is disabled. When set together with `isDisabled`, * the file input shows a tooltip with this text on hover and keyboard focus, * and its trigger stays focusable (via `aria-disabled`) so the reason is * discoverable by keyboard and assistive technology. Opening the file picker * stays blocked. * * Use this instead of wrapping a disabled input in `Tooltip` — disabled * controls don't emit the pointer events an external tooltip needs. * * @example * ``` * * ``` */ disabledMessage?: string; /** * Whether the input is required. * @default false */ isRequired?: boolean; /** * Whether the input is in a loading state (e.g. uploading). * @default false */ isLoading?: boolean; /** * Validation status for the input. */ status?: InputStatus; /** * How the status message is placed relative to the input. * - 'attached': message overlaps directly below the input (bordered treatment) * - 'detached': message floats below as a separate element with spacing * - 'tooltip': no message box; the status icon becomes a focusable info-tip button that reveals the message on hover, keyboard focus, or tap * @default 'attached' */ statusVariant?: FieldStatusVariant; /** * Description text displayed below the label. */ description?: string; /** * Placeholder text shown when no file is selected. * @default "Choose file" or "Choose files" */ placeholder?: string; /** * Visual mode for the file input. * - 'input': compact inline style, similar to a text input * - 'dropzone': larger area with dashed border and drag-and-drop support * @default 'input' */ mode?: 'dropzone' | 'input'; /** * Whether the field is optional. Mutually exclusive with isRequired. * @default false */ isOptional?: boolean; /** * Width of the field. Numbers are treated as pixels, strings are used as-is * (e.g. `'100%'`). Sizes the whole field (label, control, and status) so they * stay aligned, unlike setting width via `xstyle`/`className`/`style`. */ width?: SizeValue; /** * Tooltip text to display in an info icon at the end of the label. */ labelTooltip?: string; } /** * A file input component with optional drag-and-drop support. * * @example * ``` * * ``` */ export function FileInput({ label, isLabelHidden = false, value, onChange, changeAction, accept, isMultiple = false, maxSize, maxFiles, isDisabled = false, disabledMessage, isRequired = false, isLoading = false, status: statusProp, statusVariant = 'attached', description, placeholder, mode = 'input', isOptional = false, labelTooltip, width, xstyle, className, style, ref, ...rest }: FileInputProps) { const t = useTranslator(); const id = useId(); const descriptionID = useId(); const statusMessageID = useId(); const requiredID = useId(); const inputRef = useRef(null); const containerRef = useRef(null); const [isDragOver, setIsDragOver] = useState(false); const [validationError, setValidationError] = useState(null); const [, startTransition] = useTransition(); // Announce successful file selection to screen readers via a persistent // live region (forms-17). Validation errors are announced (assertively) by // the FieldStatus that the derived error status mounts, so only the // successful attach needs an explicit announcement here. const announce = useAnnounce(); // Disabled-reason tooltip. Disabled controls swallow pointer events, so the // tooltip listeners attach to the visible container (which stays hoverable) // and the trigger stays perceivable via aria-disabled instead of the native // disabled attribute. Opening the picker is blocked by the isDisabled guards // in the handlers. const showsDisabledMessage = isDisabled && !!disabledMessage; const disabledMessageTooltip = useTooltip({ placement: 'above', focusTrigger: 'always', isEnabled: showsDisabledMessage }); const status = statusProp ?? (validationError ? { type: 'error' as const, message: validationError } : undefined); const { statusIcon, describedBy: statusTooltipDescribedBy } = useInputStatusIcon({ status, statusVariant }); // Required state. The trigger is a real button, but AT does not reliably // announce aria-required, so the trigger instead points its aria-describedby // at a visually hidden "Required" span — mirroring the Field label's visible // indicator (where isOptional takes precedence) and the pattern Slider uses // for its thumbs. const conveysRequired = isRequired && !isOptional; const ariaDescribedBy = [description ? descriptionID : null, statusVariant !== 'tooltip' && status?.message ? statusMessageID : null, // The tooltip variant renders no message box; describe the input by the // tooltip's content instead so the status is still announced. statusTooltipDescribedBy, conveysRequired ? requiredID : null, showsDisabledMessage ? disabledMessageTooltip.describedBy : null].filter(Boolean).join(' ') || undefined; const defaultPlaceholder = isMultiple ? t('@astryx.fileInput.placeholderMultiple') : t('@astryx.fileInput.placeholder'); const displayPlaceholder = placeholder ?? defaultPlaceholder; const handleFiles = useCallback((fileList: File[]) => { if (isDisabled) { return; } const { valid, errors } = validateFiles(fileList, accept, maxSize, maxFiles, isMultiple, t); if (errors.length > 0) { setValidationError(errors[0]); } else { setValidationError(null); } if (valid.length === 0) { onChange(null); return; } const result = isMultiple ? valid : valid[0]; onChange(result); // Announce the successful selection politely. Validation errors are // announced assertively by the FieldStatus that the derived error // status mounts, so only announce the attach here (do not // double-announce errors). if (errors.length === 0) { announce(valid.length === 1 ? t('@astryx.fileInput.fileSelected', { fileName: valid[0].name }) : t('@astryx.fileInput.filesSelected', { count: valid.length })); } if (changeAction) { startTransition(async () => { await changeAction(result); }); } }, [accept, isDisabled, isMultiple, maxFiles, maxSize, onChange, changeAction, startTransition, announce, t]); const handleInputChange = useCallback((e: React.ChangeEvent) => { const fileList = Array.from(e.target.files ?? []); handleFiles(fileList); if (inputRef.current) { inputRef.current.value = ''; } }, [handleFiles]); const handleClear = useCallback((e: React.MouseEvent) => { e.stopPropagation(); setValidationError(null); onChange(null); if (inputRef.current) { inputRef.current.value = ''; const targetInput = inputRef.current; if (e.detail === 0) { targetInput.focus(); } else { // Defer focus restoration past the button's unmount task so iOS Safari // and touch browsers don't jump the page scroll to 0 on tap. requestAnimationFrame(() => { targetInput.focus({ preventScroll: true }); }); } } }, [onChange]); const handleClick = useCallback(() => { if (!isDisabled) { inputRef.current?.click(); } }, [isDisabled]); // Explicit keyboard activation so Enter/Space open the picker even where a // synthetic keydown does not raise a native click. The isDisabled guard // blocks activation while focusable-disabled. const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if ((e.key === 'Enter' || e.key === ' ') && !isDisabled) { e.preventDefault(); inputRef.current?.click(); } }, [isDisabled]); // Make the surface clickable without giving it an interactive role. Clicks on // the nested clear/status controls are ignored by the hook, so those buttons // are plain siblings — no nested-interactive violation. const { onClick: onContainerClick, onMouseUp: onContainerMouseUp } = useClickableContainer({ containerRef, onClick: handleClick, disabled: isDisabled }); const handleDragEnter = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!isDisabled && mode === 'dropzone') { setIsDragOver(true); } }, [isDisabled, mode]); const handleDragOver = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!isDisabled && mode === 'dropzone') { setIsDragOver(true); } }, [isDisabled, mode]); const handleDragLeave = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); // Moving over the dropzone's own children (icon, text) fires dragleave on // the container too — only a leave that actually exits the dropzone ends // the drag-over state, otherwise the highlight flickers mid-drag. if (e.relatedTarget instanceof Node && e.currentTarget.contains(e.relatedTarget)) { return; } setIsDragOver(false); }, []); const handleDrop = useCallback((e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); if (isDisabled || mode !== 'dropzone') { return; } const fileList = Array.from(e.dataTransfer.files); if (fileList.length > 0) { handleFiles(fileList); } }, [isDisabled, mode, handleFiles]); const hasFiles = value != null && (Array.isArray(value) ? value.length > 0 : true); const fileNames = hasFiles ? Array.isArray(value) ? value.map(f => f.name).join(', ') : value?.name ?? '' : null; const renderDropzoneContent = () => { if (isLoading) { return ; } if (hasFiles) { return
{fileNames}
; } return <> {isDragOver ? t('@astryx.fileInput.dropHint') : displayPlaceholder} ; }; const renderCompactContent = () => { if (isLoading) { return <> {fileNames ?? displayPlaceholder} ; } return <> {fileNames ?? displayPlaceholder} {statusIcon} ; }; const isDropzone = mode === 'dropzone'; const dragDropProps = isDropzone ? { onDragEnter: handleDragEnter, onDragOver: handleDragOver, onDragLeave: handleDragLeave, onDrop: handleDrop } : {}; return
{ containerRef.current = el; // Anchor + hover/focus listeners for the disabled-message tooltip on // the visible container (the focusable trigger is visually hidden, so // anchoring there would place the tooltip at a 1px box). focusin // bubbles from the trigger button, so the tooltip still opens on // keyboard focus. Handlers are gated internally by isEnabled. disabledMessageTooltip.ref(el); }} onClick={!isDisabled ? onContainerClick : undefined} onMouseUp={!isDisabled ? onContainerMouseUp : undefined} {...dragDropProps} {...mergeProps(themeProps('file-input', { mode, status: status?.type ?? null }), stylex.props(isDropzone ? styles.dropzone : styles.compact, isDropzone && !isDisabled && styles.dropzoneHover, isDropzone && isDragOver && styles.dropzoneActive, isDropzone && isDisabled && styles.dropzoneDisabled, !isDropzone && isDisabled && styles.compactDisabled, status && statusBorderStyles[status.type], xstyle), className, style)}> {/* Visually hidden real button carrying the accessible role, name, and ARIA. Kept as a sibling of the clear/status controls inside a non-interactive container, so no interactive element nests inside another (WCAG 4.1.2). The visible focus feedback is the container's :focus-within border; the surface click is handled by the container (VisuallyHidden disables the button's own pointer events), and keyboard activation still fires here. */}
{conveysRequired && {t('@astryx.fileInput.required')} } {showsDisabledMessage && disabledMessageTooltip.renderTooltip(disabledMessage)}
; } FileInput.displayName = 'FileInput';