Skip to main content

FileSelect

betav0.1.0

Inline 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

No file selected.
PNG, JPG — 12 Mb max
0/12Mb
evidence.png
Remove all files
2/12Mb
report.pdfscreenshot.pngnotes.txt
Remove all files
4.5/12Mb
A file is required
PDF only
No file selected.
Uploading…
-/12Mb
Loading
No file selected.

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

PropType
valueFile | File[] | null — Controlled selection. A single File (or null) unless `multiple`
defaultValueFile | 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)
multipleboolean — Allow several files → renders the removable chip list (default: false)
labelReactNode — Field label above the trigger. Omit → aria-label or aria-labelledby required
requiredboolean — Renders the `*` indicator and sets aria-required (default: false)
infoReactNode — Info icon + Tooltip next to the label (caller-supplied trigger)
helperTextReactNode — 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
placeholderstring — Empty-state text right of the trigger (default: "No file selected.")
triggerLabelstring — Trigger button label (default: "Select your file")
acceptstring — Native accept (MIME/extension list); also enforced in JS after pick
maxSizenumber — Max size per file in bytes; over-limit picks are rejected and not displayed
showSizeCounterboolean — Show the "used/max" size counter in the helper row; used is the aggregate of the selection (default: false)
disabledboolean — Disabled field (default: false)
loadingboolean — 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)
errorstring — 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)
clearLabelstring — Accessible label for the clear control, composed with the field name (default: "Remove all files")
loadingLabelstring — 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
classNamestring — Additional CSS classes on the outer wrapper

Accessibility

2.1 AAWCAG pass

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.