{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-input","title":"File Input","description":"Choose files in a field that shows their names and ends with an Upload button, and send them with a form. Search terms: file upload, upload field, attachment, browse files.","dependencies":[],"registryDependencies":["@siteplane/base","@siteplane/button","@siteplane/input","@siteplane/input-group","@siteplane/spinner"],"files":[{"path":"packages/ui/src/components/file-input/file-input.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { useFormReset } from \"../../system/form/use-form-reset\";\nimport { useTruncated } from \"../../system/hooks/use-truncated\";\nimport {\n  type SiteplaneControlSize,\n  useResolvedControlSize,\n} from \"../../system/provider/siteplane-ui-provider\";\nimport { useSiteplaneMessages } from \"../../system/locale/messages-context\";\nimport type { FormControlVariant } from \"../../system/styles/surface\";\nimport { Button } from \"../button\";\nimport {\n  useFieldControlBridge,\n  useFieldValueInput,\n} from \"../field/field-control-bridge\";\nimport { ExternalInputOwner, Input, unnamedInputRender } from \"../input/input\";\nimport {\n  InputGroup,\n  InputGroupAddon,\n  InputGroupInput,\n} from \"../input-group/input-group\";\nimport { Spinner } from \"../spinner\";\nimport {\n  fileNames,\n  opensFileDialog,\n  selectedFiles,\n} from \"./file-input-selection\";\n\nimport \"./file-input.css\";\n\nexport type FileInputProps = Omit<\n  React.ComponentProps<\"div\">,\n  | \"aria-describedby\"\n  | \"aria-invalid\"\n  | \"aria-label\"\n  | \"aria-labelledby\"\n  | \"children\"\n  | \"defaultValue\"\n  | \"id\"\n  | \"onChange\"\n  | \"placeholder\"\n> & {\n  /** The control size, as Input takes it; unset, the provider's. */\n  size?: SiteplaneControlSize | undefined;\n  /** The surface of the field, as Input's. */\n  variant?: FormControlVariant | undefined;\n  /** What the field shows while no file is chosen. */\n  placeholder?: string | undefined;\n  /** The label of the button that opens the file dialog. */\n  buttonText?: string | undefined;\n  /** The file types the dialog offers, as on a native file input. */\n  accept?: string | undefined;\n  /** Lets the user choose several files at once. */\n  multiple?: boolean | undefined;\n  /** The name of the files in the form data; a Field's name comes first. */\n  name?: string | undefined;\n  /** The id of the form that the files belong to. */\n  form?: string | undefined;\n  /** A form submits only with a file chosen. */\n  required?: boolean | undefined;\n  disabled?: boolean | undefined;\n  /**\n   * Shows a spinner at the end of the field, such as while the chosen file\n   * uploads. The field and its button stay usable.\n   */\n  loading?: boolean | undefined;\n  /**\n   * Called with the chosen files when the user picks files, and with null\n   * when the field is emptied, as a reset of its form does.\n   */\n  onFilesChange?: ((files: FileList | null) => void) | undefined;\n  /** The id of the field's text, which a label's `htmlFor` names. */\n  id?: string | undefined;\n  \"aria-describedby\"?: string | undefined;\n  \"aria-invalid\"?: React.AriaAttributes[\"aria-invalid\"];\n  \"aria-label\"?: string | undefined;\n  \"aria-labelledby\"?: string | undefined;\n};\n\n/**\n * A field for choosing files: a read-only text that shows the placeholder or\n * the names of the chosen files, and an Upload button as a segment at its\n * end. A click anywhere on the field, or Enter or Space on its text or its\n * button, opens the file dialog. A file input out of sight holds the chosen\n * files, so a form around the field sends them under its name.\n *\n * It is an InputGroup: the text is an InputGroupInput, which takes a Field's\n * label, description, error and state as an Input does, and the button, a\n * ghost Button, fills a bare addon after a divider. The file input's own constraints, such as\n * `required`, are the Field's validity.\n */\nexport function FileInput({\n  accept,\n  \"aria-describedby\": ariaDescribedBy,\n  \"aria-invalid\": ariaInvalid,\n  \"aria-label\": ariaLabel,\n  \"aria-labelledby\": ariaLabelledBy,\n  buttonText: buttonTextProp,\n  disabled,\n  form,\n  id,\n  loading = false,\n  multiple,\n  name,\n  onClick,\n  onFilesChange,\n  placeholder: placeholderProp,\n  required,\n  size,\n  variant,\n  ...props\n}: FileInputProps): React.ReactElement {\n  const messages = useSiteplaneMessages(\"fileInput\");\n  const buttonText = buttonTextProp ?? messages.button;\n  const placeholder = placeholderProp ?? messages.placeholder;\n  const resolvedSize = useResolvedControlSize<SiteplaneControlSize>(size);\n  // The name and the disabled state of a Field reach the file input and the\n  // button; the text takes the Field's as any Input does.\n  const field = useFieldControlBridge({ disabled, name });\n  const textRef = React.useRef<HTMLInputElement | null>(null);\n  const fileRef = React.useRef<HTMLInputElement | null>(null);\n  const valueInput = useFieldValueInput(textRef);\n  const registerValueInput = valueInput.ref;\n  const [names, setNames] = React.useState(\"\");\n  const [truncatedRef, truncated] = useTruncated<HTMLInputElement>();\n  // The files the input held when the dialog opened, and whether it is open.\n  const keptFiles = React.useRef<File[]>([]);\n  const dialogOpen = React.useRef(false);\n  const resetRef = useFormReset<HTMLInputElement>(form, () => {\n    // A reset of the form empties its file input.\n    if (!names) return;\n    setNames(\"\");\n    onFilesChange?.(null);\n  });\n\n  function openFileDialog() {\n    const input = fileRef.current;\n    if (!input || field.disabled) return;\n    // Emptied, so a file that is picked again is reported again.\n    keptFiles.current = Array.from(input.files ?? []);\n    dialogOpen.current = true;\n    input.value = \"\";\n    input.click();\n  }\n\n  const setText = React.useCallback(\n    (input: HTMLInputElement | null) => {\n      textRef.current = input;\n      const stop = truncatedRef(input);\n      return () => {\n        stop?.();\n        textRef.current = null;\n      };\n    },\n    [truncatedRef],\n  );\n\n  const setFileInput = React.useCallback(\n    (input: HTMLInputElement | null) => {\n      fileRef.current = input;\n      resetRef.current = input;\n      if (!input) return undefined;\n      const unregister = registerValueInput(input);\n      // A dialog that is cancelled leaves the files the input held.\n      const restore = () => {\n        dialogOpen.current = false;\n        const kept = keptFiles.current;\n        if (kept.length === 0 || (input.files?.length ?? 0) > 0) return;\n        const transfer = new DataTransfer();\n        for (const file of kept) transfer.items.add(file);\n        input.files = transfer.files;\n      };\n      input.addEventListener(\"cancel\", restore);\n      return () => {\n        input.removeEventListener(\"cancel\", restore);\n        unregister?.();\n        fileRef.current = null;\n        resetRef.current = null;\n      };\n    },\n    [registerValueInput, resetRef],\n  );\n\n  return (\n    <InputGroup\n      data-slot=\"file-input\"\n      // The field of the size: its height, padding and text (the field scale).\n      size={resolvedSize}\n      {...props}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) openFileDialog();\n      }}\n      variant={variant}\n    >\n      {/* The file input holds the files for the form. It is out of sight\n          but focusable, so a browser can point at it when a required file\n          is missing, and it stands first, at the field's start, where a\n          browser points at any input. Its own click, which opens the\n          dialog, stays with it. */}\n      <ExternalInputOwner>\n        <Input\n          accept={accept}\n          aria-hidden=\"true\"\n          className=\"sr-only\"\n          disabled={field.disabled}\n          form={form}\n          multiple={multiple}\n          name={field.name}\n          nativeInput\n          onChange={(event) => {\n            dialogOpen.current = false;\n            const files = selectedFiles(event.currentTarget.files);\n            const next = fileNames(files);\n            setNames(next);\n            valueInput.change(next);\n            onFilesChange?.(files);\n          }}\n          onClick={(event) => event.stopPropagation()}\n          ref={setFileInput}\n          required={required}\n          size={resolvedSize}\n          tabIndex={-1}\n          type=\"file\"\n          unstyled\n        />\n      </ExternalInputOwner>\n      <InputGroupInput\n        aria-describedby={ariaDescribedBy}\n        aria-invalid={ariaInvalid}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledBy}\n        aria-required={required || undefined}\n        disabled={disabled}\n        id={id}\n        name={name}\n        // While the dialog is open the file input is empty, so the Field\n        // checks nothing then: not on the Enter that opens it, which also\n        // stops the form's submit, and not when the page behind it blurs.\n        onBlur={(event) => {\n          if (dialogOpen.current) event.preventBaseUIHandler();\n        }}\n        onKeyDown={(event) => {\n          if (event.repeat || !opensFileDialog(event.key)) return;\n          event.preventDefault();\n          event.preventBaseUIHandler();\n          openFileDialog();\n        }}\n        placeholder={placeholder}\n        readOnly\n        ref={setText}\n        // Base UI writes the Field's name on the input it renders. The names\n        // shown are not the form value, the files are: the text renders\n        // without a name, so a form sends only the file input.\n        render={unnamedInputRender}\n        title={truncated ? names : undefined}\n        value={names}\n      />\n      {loading ? (\n        <InputGroupAddon align=\"inline-end\">\n          <Spinner />\n        </InputGroupAddon>\n      ) : null}\n      {/* The Upload segment is the flat segment of an Input Group, the\n          ghost Button on the field after its divider, as the Copy segment\n          (Lukas, 3 November 2026; rulebook S14, P3b). It was the raised\n          default Button, the only segment with a fill of its own. */}\n      <InputGroupAddon align=\"inline-end\" divider padding=\"none\">\n        <Button\n          data-slot=\"file-input-button\"\n          disabled={field.disabled}\n          size={resolvedSize}\n          type=\"button\"\n          variant=\"ghost\"\n        >\n          {buttonText}\n        </Button>\n      </InputGroupAddon>\n    </InputGroup>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/file-input/file-input.tsx"},{"path":"packages/ui/src/components/file-input/file-input-selection.ts","content":"/*\n * Kit-internal: what FileInput reads from its file input and which keys open\n * the file dialog, kept apart from the component so they can be checked on\n * their own. Not public API: the file-input index does not export this module.\n */\n\n/** The files a file input holds, or null while it holds none. */\nexport function selectedFiles(files: FileList | null): FileList | null {\n  return files && files.length > 0 ? files : null;\n}\n\n/** What the field shows for the files: their names, joined by \", \". */\nexport function fileNames(files: ArrayLike<File> | null): string {\n  return files ? Array.from(files, (file) => file.name).join(\", \") : \"\";\n}\n\n/** Whether a key pressed on the field's text opens the file dialog. */\nexport function opensFileDialog(key: string): boolean {\n  return key === \"Enter\" || key === \" \";\n}\n","type":"registry:ui","target":"~/src/siteplane/components/file-input/file-input-selection.ts"},{"path":"packages/ui/src/components/file-input/file-input.css","content":"@layer theme, base, components, siteplane, utilities;\n\n@layer siteplane {\n  /* A click anywhere on the field opens the file dialog, so its text and an\n   * addon beside it, such as the spinner, take the pointer. A disabled field\n   * keeps the not-allowed cursor of the field rules (design-rework.css). */\n  .sp-file-input .sp-input:enabled,\n  .sp-file-input .sp-input-group-addon {\n    cursor: pointer;\n  }\n\n  /* Names longer than the field end with an ellipsis; the text's title\n   * carries them in full (file-input.tsx). */\n  .sp-file-input .sp-input {\n    text-overflow: ellipsis;\n  }\n}\n","type":"registry:ui","target":"~/src/siteplane/components/file-input/file-input.css"},{"path":"packages/ui/src/components/file-input/index.ts","content":"export * from \"./file-input\";\n","type":"registry:ui","target":"~/src/siteplane/components/file-input/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/file-input.md","categories":["Forms & selection"],"meta":{"avoidWhen":["Avoid for dropping files or for a list of uploads with progress; use File Upload.","Avoid where the browser's own file control is enough; use Input with type file."],"description":"Choose files in a field that shows their names and ends with an Upload button, and send them with a form.","knownLimitations":["The field uploads nothing and shows no preview. Read the files from onFilesChange or from the form data; Form's onFormSubmit receives the names that the field shows.","A pick replaces the selection; single files cannot be removed from it."],"providerNotes":[],"releaseStatus":"preview","searchTerms":["file input","file upload","upload field","choose file","attachment","browse files"],"useWhen":["Use in a form where one file or a few belong among other fields, such as a document, an invoice or a logo.","Use with Field for a label, a description of the accepted files and the error of a required file."]}}