FileSelect
betav0.1.0Inline file-collection form field. Standalone component wrapping a hidden native <input type="file"> and composing the FDS Button (trigger), IconButton (clear) and Chip (selection). Integrates a label, required marker, info tooltip slot, helper text (which doubles as the accepted-formats hint and stays visible in every state), an optional used/max size counter, a single selection rendered as in-field text and a multiple selection as removable info-tinted chips, an in-field error message, a non-cancelable loading state and a decorative paperclip. Form-library-agnostic through value/onValueChange — it replaces the near-duplicate product wrappers (opencti CustomFileUploader, openaev CustomFileUploader/FileField). Not a drag-and-drop or bulk-import surface (those stay dialog-based importers), and it exposes no variant, size or read-only mode.
Variants
Examples
<FileSelect label="Attachment" onValueChange={(file) => console.log(file)} /><FileSelect
label="Evidence"
accept="image/png,image/jpeg"
maxSize={12 * 1024 * 1024}
showSizeCounter
helperText="PNG, JPG — 12 Mb max"
onValueChange={(file) => console.log(file)}
/><FileSelect label="Report" required error="A file is required" /><FileSelect
label="Attachments"
multiple
accept=".pdf,.docx"
onValueChange={(files) => console.log(files)}
/><FileSelect label="Import" loading helperText="Uploading…" />// Migration from opencti CustomFileUploader — Formik glue moves to the consumer.
// onReject feeds MIME/size rejections back into Formik: a rejected pick is
// never handed to onValueChange, so the field is the only place that knows the
// file was refused, and it also surfaces a built-in message in-field.
<FileSelect
label="Attachment"
accept="image/*"
maxSize={sizeLimit}
required
showSizeCounter
error={formik.errors.file}
onReject={(rejections) =>
formik.setFieldError(
"file",
rejections[0].reason === "maxSize"
? "File is too large"
: "Unsupported file type",
)
}
onValueChange={(file) => {
formik.setFieldError("file", undefined);
formik.setFieldValue("file", file);
}}
/>Props
| Prop | Type |
|---|---|
value | File | File[] | null — Controlled selection. A single File (or null) unless `multiple` |
defaultValue | File | File[] | null — Uncontrolled initial selection |
onValueChange | (value: File | File[] | null) => void — Fires with the accepted selection (form-library-agnostic) |
onReject | (rejections: { file: File; reason: "accept" | "maxSize" }[]) => void — Fires when a pick is refused by `accept` or `maxSize`. In `multiple`, reports the discarded files even when others are accepted, so the consumer never loses a rejection (RULE-03) |
multiple | boolean — Allow several files → renders the removable chip list (default: false) |
label | ReactNode — Field label above the trigger. Omit → aria-label or aria-labelledby required |
required | boolean — Renders the `*` indicator and sets aria-required (default: false) |
info | ReactNode — Info icon + Tooltip next to the label (caller-supplied trigger) |
helperText | ReactNode — Help text below the field; the place to state accepted formats. Stays visible in every state (recolored in error), never replaced by the error message |
placeholder | string — Empty-state text right of the trigger (default: "No file selected.") |
triggerLabel | string — Trigger button label (default: "Select your file") |
accept | string — Native accept (MIME/extension list); also enforced in JS after pick |
maxSize | number — Max size per file in bytes; over-limit picks are rejected and not displayed |
showSizeCounter | boolean — Show the "used/max" size counter in the helper row; used is the aggregate of the selection (default: false) |
disabled | boolean — Disabled field (default: false) |
loading | boolean — Async operation in flight: Spinner in the trigger, aria-busy + role=status announcement. The trigger is made inert for the duration (aria-disabled + no-op activation + disabled paint, keeping its tab stop) so picks cannot overlap; the field stays non-cancelable (default: false) |
error | string — Error message shown as the in-field text (replaces the placeholder, or moves to the helper row when a selection occupies the in-field slot) and sets aria-invalid. String-only, aligned with Input: a boolean would allow an error state with no message, which RULE-02 forbids (arbitration Q8) |
clearLabel | string — Accessible label for the clear control, composed with the field name (default: "Remove all files") |
loadingLabel | string — Trigger + role=status text while `loading` (default: "Loading") |
removeFileLabel | (fileName: string) => string — Accessible label for a per-file chip delete control (default: `Remove ${fileName}`) |
rejectionMessage | (rejections: { file: File; reason: "accept" | "maxSize" }[]) => string — Builds the in-field message shown when a pick is refused, so uncontrolled consumers can translate it without adopting controlled `error` state (default: English builder) |
onClear | () => void — Called when the user clears the whole selection |
className | string — Additional CSS classes on the outer wrapper |
Accessibility
Standalone field; ref forwards to the hidden native <input type="file">. The visible, focusable trigger is the composed <Button> (the native input is sr-only and tabIndex=-1, opened via the Button's click); the field container is a role=group labelled by the field label so the trigger inherits the field's accessible name. Omitting label requires an aria-label or aria-labelledby — enforced in development with a console.warn (never a throw), same mechanism as Input. error sets aria-invalid and links the in-field message via aria-describedby (merged, never replacing, a consumer-provided value); meaning is carried by the visible text, never colour alone (WCAG 1.4.1). The paperclip is decorative (aria-hidden) and never a trigger (RULE-06); it is coloured through the stroke-* icon-token utilities (stroke-default/-success/-error/-disabled). The non-disabled tones map to --icon-default/-success/-error and are gate-checked as non-text pairs (minRatio 3); the disabled tone is exempt (WCAG 1.4.3 Note 1). loading sets aria-busy on the interactive control (trigger Button / clear IconButton) and announces via a role=status sr-only text driven by loadingLabel; the Spinner is decorative; the trigger is made inert for the duration via aria-disabled rather than the native attribute, so it keeps its tab stop, and the field stays non-cancelable (arbitration Q10, superseding the non-disabled half of Q4). The clear control composes the field name into its accessible name ("{field} Remove all files") so it is unambiguous across several FileSelect fields in one form; each chip delete label comes from removeFileLabel (default `Remove {filename}`). Without a visible label the role=group falls back to the consumer aria-label/labelledby so the group — not only the sr-only input — is named. A rejected pick (accept/maxSize) is never swallowed: onReject fires with the discarded files (even on a partial reject in multiple) and a built-in message is surfaced in-field, so a Formik consumer learns of a MIME/size error on a file the form never received (RULE-03). The native `required` attribute is deliberately NOT set on the sr-only tabIndex=-1 input (its validation bubble would anchor to an invisible box); aria-required carries the semantics. A long in-field error truncates and reveals its full text through a Tooltip on the focusable error slot. Chip truncation exposes the full filename through <Chip>'s built-in Tooltip. Every interactive control (trigger, clear, chip delete) uses the standard FDS focus ring, owned by the composed Button/IconButton/Chip. Disabled tokens (--text-input-disabled, --icon-disabled, --bg-input-disabled) are low-contrast by design and permanently exempt under WCAG 1.4.3 Note 1 (inactive UI); their classes are disabled-gated so the contrast gate excludes them and no pair is declared. All declared pairs pass; verified by pnpm check:contrast. ADOPTION CEILING: the ROADMAP crosswalk for FileSelect is intentionally empty — it replaces bespoke product wrappers (opencti/openaev CustomFileUploader, openaev FileField), not a MUI symbol, so there is no upstream symbol to cross off. This caps adoption at "partial" by design (not a measurement failure); see arbitration Q9.