{"name":"file-upload","type":"registry:component","files":[{"path":"file-upload.tsx","type":"registry:component","target":"components/ui/file-upload/file-upload.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { mergeProps } from \"@base-ui-components/react/merge-props\"\nimport { useRender } from \"@base-ui-components/react/use-render\"\nimport {\n  CircleCheck as CircleCheckIcon,\n  File as FileGlyph,\n  FileSpreadsheet as SpreadsheetGlyph,\n  FileText as DocumentGlyph,\n  LoaderCircle as LoaderCircleIcon,\n  Plus as PlusIcon,\n  TriangleAlert as TriangleAlertIcon,\n  Upload as UploadIcon,\n  X as CloseIcon,\n} from \"honestui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Progress,\n  ProgressIndicator,\n  ProgressTrack,\n} from \"@/components/ui/progress\"\n\nimport {\n  FileUploadControllerContext,\n  useFileUploadController,\n} from \"./file-upload-context\"\nimport type {\n  FileUploadAccept,\n  FileUploadControllerValue,\n  FileUploadDragState,\n  FileUploadFocusTarget,\n  FileUploadItem,\n  FileUploadPartProps,\n  FileUploadRejection,\n  FileUploadStatus,\n} from \"./file-upload-types\"\nimport {\n  checkFileAgainstRules,\n  createFileUploadId,\n  formatFileSize,\n  getFileExtension,\n  toInputAcceptAttribute,\n} from \"./file-upload-utils\"\n\n/**\n * Shared item data type, published here as FileUploadItemData because the\n * composed row component owns the FileUploadItem name.\n */\nexport type {\n  FileUploadAccept,\n  FileUploadControllerValue,\n  FileUploadDragState,\n  FileUploadFocusTarget,\n  FileUploadInputAttributes,\n  FileUploadItem as FileUploadItemData,\n  FileUploadPartProps,\n  FileUploadRejection,\n  FileUploadStatus,\n} from \"./file-upload-types\"\n\n/** True when a drag carrying `draggedCount` files cannot fit what is chosen. */\nfunction exceedsRemainingCapacity(\n  draggedCount: number,\n  selectedCount: number,\n  options: { multiple: boolean; maxFiles?: number }\n) {\n  if (!options.multiple) {\n    return draggedCount > 1\n  }\n\n  if (options.maxFiles === undefined) {\n    return false\n  }\n\n  return draggedCount > Math.max(0, options.maxFiles - selectedCount)\n}\n\nfunction countDraggedFiles(dataTransfer: DataTransfer | null | undefined) {\n  if (!dataTransfer) return 0\n\n  return Array.from(dataTransfer.items).filter((item) => item.kind === \"file\").length\n}\n\nfunction describeRejections(rejections: FileUploadRejection[]) {\n  if (rejections.length === 0) return null\n\n  return rejections.length === 1\n    ? `${rejections[0].file.name} rejected. ${rejections[0].reason}`\n    : `${rejections.length} files rejected. ${rejections[0].reason}`\n}\n\nexport interface FileUploadProps\n  extends Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> {\n  /** Files owned by your application, each carrying its upload state. */\n  value?: FileUploadItem[] | undefined\n  defaultValue?: FileUploadItem[] | undefined\n  onValueChange?: ((items: FileUploadItem[]) => void) | undefined\n  /**\n   * MIME-to-extension pairs such as `{\"image/png\": [\".png\"]}`, or an HTML\n   * accept string. This filters the picker and drives rejection reasons.\n   */\n  accept?: FileUploadAccept | undefined\n  multiple?: boolean\n  maxFiles?: number\n  /** In bytes; reasons quote the limit back with human units. */\n  maxSize?: number\n  minSize?: number\n  disabled?: boolean\n  /** Runs after rules pass and files join the selection. */\n  onAccept?: ((items: FileUploadItem[]) => void) | undefined\n  /** Runs once per selection with every rejected file and its reason. */\n  onReject?: ((rejections: FileUploadRejection[]) => void) | undefined\n  onRemove?: ((item: FileUploadItem) => void) | undefined\n  /** Your application starts the new request; Retry only reports intent. */\n  onRetry?: ((item: FileUploadItem) => void) | undefined\n  validateFile?: ((file: File) => string | null) | undefined\n  /** Reads pasted files while focus stays inside this uploader. */\n  allowPaste?: boolean\n  /** Lets pickers choose folders. Support varies by browser; test yours. */\n  directory?: boolean\n  capture?: \"user\" | \"environment\"\n}\n\nfunction FileUpload({\n  value: valueProp,\n  defaultValue = [],\n  onValueChange,\n  accept,\n  multiple = false,\n  maxFiles,\n  maxSize,\n  minSize,\n  disabled = false,\n  onAccept,\n  onReject,\n  onRemove,\n  onRetry,\n  validateFile,\n  allowPaste = false,\n  directory = false,\n  capture,\n  className,\n  children,\n  ...rootProps\n}: FileUploadProps) {\n  const [uncontrolledValue, setUncontrolledValue] =\n    React.useState<FileUploadItem[]>(defaultValue)\n  const isControlled = valueProp !== undefined\n  const value = isControlled ? valueProp : uncontrolledValue\n\n  const [rejections, setRejections] = React.useState<FileUploadRejection[]>([])\n  const [dragState, setDragState] = React.useState<FileUploadDragState>(\"idle\")\n  const [selectionError, setSelectionError] = React.useState<string | null>(null)\n  const [announcement, setAnnouncement] = React.useState<string | null>(null)\n  const [focusRequest, setFocusRequest] = React.useState<{\n    target: FileUploadFocusTarget\n    nonce: number\n  } | null>(null)\n\n  const inputRef = React.useRef<HTMLInputElement>(null)\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const openerRef = React.useRef<HTMLElement | null>(null)\n  const dragDepthRef = React.useRef(0)\n  const focusNonceRef = React.useRef(0)\n  const previousStatusesRef = React.useRef<\n    Map<string, FileUploadStatus | undefined> | undefined\n  >(undefined)\n\n  // One polite region whose text swaps; content changes re-trigger reads.\n  const announce = React.useCallback((message: string) => {\n    setAnnouncement(message === \"\" ? \"\\u200b\" : message)\n  }, [])\n\n  const setValue = React.useCallback(\n    (nextItems: FileUploadItem[]) => {\n      if (!isControlled) {\n        setUncontrolledValue(nextItems)\n      }\n\n      onValueChange?.(nextItems)\n    },\n    [isControlled, onValueChange]\n  )\n\n  // Single mode always allows opening the picker again: Replace counts as a\n  // legitimate reason to open it, only multiple mode has a hard ceiling.\n  const maxFilesReached = maxFiles !== undefined && value.length >= maxFiles\n  const canAddMore =\n    !disabled && (multiple ? !maxFilesReached : true)\n\n  const openPicker = React.useCallback(() => {\n    if (disabled) return\n\n    inputRef.current?.click()\n  }, [disabled])\n\n  const activateFrom = React.useCallback(\n    (element: HTMLElement) => {\n      openerRef.current = element\n      openPicker()\n    },\n    [openPicker]\n  )\n\n  const rememberDropzoneTrigger = React.useCallback((element: HTMLElement | null) => {\n    openerRef.current = element\n  }, [])\n\n  const requestFocus = React.useCallback((target: FileUploadFocusTarget) => {\n    focusNonceRef.current += 1\n    setFocusRequest({ target, nonce: focusNonceRef.current })\n  }, [])\n\n  React.useEffect(() => {\n    if (focusRequest?.target.kind !== \"fallback\" || disabled) return\n\n    const fallback = rootRef.current?.querySelector<HTMLElement>(\n      '[data-slot=\"file-upload-trigger\"]:not(:disabled), [data-slot=\"file-upload-dropzone\"]:not(:disabled)'\n    )\n\n    fallback?.focus()\n  }, [disabled, focusRequest])\n\n  React.useEffect(() => {\n    const nextStatuses = new Map(\n      value.map((item) => [item.id, item.status] as const)\n    )\n    const previousStatuses = previousStatusesRef.current\n\n    previousStatusesRef.current = nextStatuses\n\n    if (!previousStatuses) return\n\n    const messages = value.flatMap((item) => {\n      if (previousStatuses.get(item.id) === item.status) return []\n\n      if (item.status === \"success\") {\n        return [`${item.file.name} uploaded.`]\n      }\n\n      if (item.status === \"error\") {\n        const reason = item.error\n          ?.replace(/^upload failed\\.?\\s*/i, \"\")\n          .trim()\n\n        return [\n          `${item.file.name} upload failed.${reason ? ` ${reason}` : \"\"}`,\n        ]\n      }\n\n      return []\n    })\n\n    if (messages.length > 0) {\n      announce(messages.join(\" \"))\n    }\n  }, [announce, value])\n\n  const batchLimitMessage = React.useCallback(\n    () =>\n      `You can upload up to ${maxFiles} ${\n        maxFiles === 1 ? \"file\" : \"files\"\n      }. Remove a file before adding another.`,\n    [maxFiles]\n  )\n\n  /** Rejects a whole batch, keeping whatever was already selected intact. */\n  const rejectBatch = React.useCallback(\n    (files: File[], message: string) => {\n      const rejected = files.map((file) => ({\n        id: createFileUploadId(\"rejected\"),\n        file,\n        reason: message,\n      }))\n\n      setSelectionError(message)\n      setRejections((current) => [...current, ...rejected])\n      onReject?.(rejected)\n      announce(message)\n    },\n    [announce, onReject]\n  )\n\n  /**\n   * Single mode takes one file; extra files in the same gesture explain why\n   * they were left out instead of disappearing.\n   */\n  const acceptSingleFile = React.useCallback(\n    (files: File[]) => {\n      const checks = { accept, minSize, maxSize, validateFile }\n      const [first] = files\n      let rejectionMessage: string | null = null\n\n      if (!first) return\n\n      const result = checkFileAgainstRules(first, checks)\n\n      if (!result.valid) {\n        rejectBatch([first], result.reason ?? \"This file could not be added.\")\n        return\n      }\n\n      if (files.length > 1) {\n        const extras = files.slice(1).map((file) => ({\n          id: createFileUploadId(\"rejected\"),\n          file,\n          reason: \"Only one file can be added here. Replace the current selection.\",\n        }))\n\n        setRejections((current) => [...current, ...extras])\n        onReject?.(extras)\n        rejectionMessage = describeRejections(extras)\n      }\n\n      const accepted = {\n        id: createFileUploadId(\"file\"),\n        file: first,\n        status: \"pending\" as const,\n      }\n\n      setValue([accepted])\n      onAccept?.([accepted])\n      announce(\n        [`${first.name} added.`, rejectionMessage]\n          .filter(Boolean)\n          .join(\" \")\n      )\n    },\n    [accept, announce, maxSize, minSize, onAccept, onReject, rejectBatch, setValue, validateFile]\n  )\n\n  const acceptMultipleFiles = React.useCallback(\n    (files: File[]) => {\n      const checks = { accept, minSize, maxSize, validateFile }\n      const accepted: FileUploadItem[] = []\n      const rejected: FileUploadRejection[] = []\n\n      for (const file of files) {\n        const result = checkFileAgainstRules(file, checks)\n\n        if (result.valid) {\n          accepted.push({\n            id: createFileUploadId(\"file\"),\n            file,\n            status: \"pending\",\n          })\n        } else {\n          rejected.push({\n            id: createFileUploadId(\"rejected\"),\n            file,\n            reason: result.reason ?? \"This file could not be added.\",\n          })\n        }\n      }\n\n      const rejectionMessage = describeRejections(rejected)\n\n      if (rejected.length > 0) {\n        setRejections((current) => [...current, ...rejected])\n        onReject?.(rejected)\n      }\n\n      if (accepted.length === 0) {\n        if (rejectionMessage) announce(rejectionMessage)\n        return\n      }\n\n      setValue([...value, ...accepted])\n      onAccept?.(accepted)\n      const acceptedMessage =\n        accepted.length === 1\n          ? `${accepted[0].file.name} added.`\n          : `${accepted.length} files added.`\n\n      announce([acceptedMessage, rejectionMessage].filter(Boolean).join(\" \"))\n    },\n    [\n      accept,\n      announce,\n      maxSize,\n      minSize,\n      onAccept,\n      onReject,\n      setValue,\n      validateFile,\n      value,\n    ]\n  )\n\n  /** Shared entry point behind the picker, drops, and pastes. */\n  const selectFiles = React.useCallback(\n    (incoming: FileList | File[] | null | undefined) => {\n      const files = Array.from(incoming ?? [])\n\n      setSelectionError(null)\n\n      if (files.length === 0) return\n\n      if (!multiple) {\n        // Drops count as Replace here, matching the visible Replace action.\n        acceptSingleFile(files)\n        return\n      }\n\n      const remaining =\n        maxFiles === undefined ? Number.POSITIVE_INFINITY : maxFiles - value.length\n\n      if (remaining <= 0 || files.length > remaining) {\n        // Whole batch stays out; partial acceptance hides the real limit.\n        rejectBatch(files, batchLimitMessage())\n        return\n      }\n\n      acceptMultipleFiles(files)\n    },\n    [\n      acceptMultipleFiles,\n      acceptSingleFile,\n      batchLimitMessage,\n      maxFiles,\n      multiple,\n      rejectBatch,\n      value.length,\n    ]\n  )\n\n  const removeItems = React.useCallback(\n    (itemIds: string[], focusAfter = true) => {\n      if (itemIds.length === 0) return\n\n      const firstRemovedIndex = value.findIndex((entry) =>\n        itemIds.includes(entry.id)\n      )\n      const nextValue = value.filter((entry) => !itemIds.includes(entry.id))\n\n      setSelectionError(null)\n      setValue(nextValue)\n\n      for (const entry of value) {\n        if (itemIds.includes(entry.id)) {\n          onRemove?.(entry)\n        }\n      }\n\n      if (focusAfter && itemIds.length === 1 && firstRemovedIndex >= 0) {\n        const neighbor =\n          nextValue[firstRemovedIndex] ?? nextValue[firstRemovedIndex - 1]\n\n        requestFocus(\n          neighbor\n            ? { kind: \"item-remove\", itemId: neighbor.id }\n            : { kind: \"fallback\" }\n        )\n      }\n    },\n    [onRemove, requestFocus, setValue, value]\n  )\n\n  const removeItem = React.useCallback(\n    (itemId: string) => removeItems([itemId]),\n    [removeItems]\n  )\n\n  const clearAll = React.useCallback(\n    () => {\n      removeItems(\n        value.map((item) => item.id),\n        false\n      )\n      requestFocus({ kind: \"fallback\" })\n    },\n    [removeItems, requestFocus, value]\n  )\n\n  const removeRejection = React.useCallback(\n    (rejectionId: string) => {\n      const removedIndex = rejections.findIndex(\n        (entry) => entry.id === rejectionId\n      )\n      const nextRejections = rejections.filter(\n        (entry) => entry.id !== rejectionId\n      )\n      const neighbor =\n        nextRejections[removedIndex] ?? nextRejections[removedIndex - 1]\n\n      setRejections(nextRejections)\n\n      if (nextRejections.length === 0) {\n        setSelectionError(null)\n      }\n\n      if (neighbor) {\n        requestFocus({\n          kind: \"rejection-remove\",\n          rejectionId: neighbor.id,\n        })\n        return\n      }\n\n      const selectedItem = value.at(-1)\n\n      requestFocus(\n        selectedItem\n          ? { kind: \"item-remove\", itemId: selectedItem.id }\n          : { kind: \"fallback\" }\n      )\n    },\n    [rejections, requestFocus, value]\n  )\n\n  const retryItem = React.useCallback(\n    (item: FileUploadItem) => {\n      onRetry?.(item)\n    },\n    [onRetry]\n  )\n\n  // Paste stays scoped to this uploader so other fields keep theirs.\n  React.useEffect(() => {\n    if (!allowPaste) return\n\n    function handlePaste(event: ClipboardEvent) {\n      if (!rootRef.current || disabled) return\n      if (!rootRef.current.contains(document.activeElement)) return\n\n      const files = event.clipboardData?.files\n\n      if (files && files.length > 0) {\n        event.preventDefault()\n        selectFiles(files)\n      }\n    }\n\n    document.addEventListener(\"paste\", handlePaste)\n\n    return () => document.removeEventListener(\"paste\", handlePaste)\n  }, [allowPaste, disabled, selectFiles])\n\n  const controllerValue = React.useMemo<FileUploadControllerValue>(\n    () => ({\n      value,\n      rejections,\n      dragState,\n      disabled,\n      multiple,\n      canAddMore,\n      canRetry: onRetry !== undefined,\n      selectionError,\n      focusRequest,\n      inputProps: {\n        accept: toInputAcceptAttribute(accept),\n        capture,\n        multiple,\n        ...(directory ? ({ webkitdirectory: \"\" } as const) : {}),\n      },\n      openPicker,\n      activateFrom,\n      removeItem,\n      removeRejection,\n      clearAll,\n      retryItem,\n      rememberDropzoneTrigger,\n      requestFocus,\n      beginDrag(draggedCount) {\n        if (disabled) return\n\n        dragDepthRef.current += 1\n        const invalid = exceedsRemainingCapacity(draggedCount, value.length, {\n          multiple,\n          maxFiles,\n        })\n\n        setDragState(invalid ? \"invalid\" : \"valid\")\n\n        if (invalid) {\n          // Visible copy flips alongside; the region catches screen readers.\n          announce(batchLimitMessage())\n        }\n      },\n      refreshDragValidity(draggedCount) {\n        if (disabled) return\n\n        setDragState(\n          exceedsRemainingCapacity(draggedCount, value.length, {\n            multiple,\n            maxFiles,\n          })\n            ? \"invalid\"\n            : \"valid\"\n        )\n      },\n      endDrag() {\n        dragDepthRef.current = Math.max(0, dragDepthRef.current - 1)\n\n        if (dragDepthRef.current === 0) {\n          setDragState(\"idle\")\n        }\n      },\n      cancelDrag() {\n        dragDepthRef.current = 0\n        setDragState(\"idle\")\n      },\n      completeDrag(files) {\n        dragDepthRef.current = 0\n        setDragState(\"idle\")\n        selectFiles(files)\n      },\n      announce,\n    }),\n    [\n      accept,\n      activateFrom,\n      announce,\n      batchLimitMessage,\n      canAddMore,\n      capture,\n      clearAll,\n      directory,\n      disabled,\n      dragState,\n      focusRequest,\n      maxFiles,\n      multiple,\n      openPicker,\n      onRetry,\n      rejections,\n      rememberDropzoneTrigger,\n      removeItem,\n      removeRejection,\n      requestFocus,\n      retryItem,\n      selectFiles,\n      selectionError,\n      value,\n    ]\n  )\n\n  return (\n    <FileUploadControllerContext.Provider value={controllerValue}>\n      <div\n        ref={rootRef}\n        data-slot=\"file-upload-root\"\n        data-disabled={disabled ? \"\" : undefined}\n        className={cn(\"relative w-full min-w-0\", className)}\n        {...rootProps}\n      >\n        {children}\n        <input\n          ref={inputRef}\n          {...controllerValue.inputProps}\n          aria-hidden=\"true\"\n          tabIndex={-1}\n          type=\"file\"\n          data-slot=\"file-upload-input\"\n          className=\"hidden\"\n          onChange={(event) => {\n            selectFiles(event.target.files)\n            event.target.value = \"\"\n\n            const opener = openerRef.current\n\n            if (opener?.isConnected) {\n              opener.focus()\n              openerRef.current = null\n            }\n          }}\n        />\n        <div role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n          {announcement}\n        </div>\n      </div>\n    </FileUploadControllerContext.Provider>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Dropzone                                                                   */\n/* -------------------------------------------------------------------------- */\n\nexport interface FileUploadDropzoneProps\n  extends React.ComponentProps<\"button\"> {\n  /** Styles the layout wrapper that also holds the selection error. */\n  wrapperClassName?: string\n}\n\n/**\n * Clicking anywhere here opens the picker; Enter and Space do the same\n * through real button semantics. Child content joins the accessible name,\n * so keep the instruction inside rather than wiring extra labels.\n */\nfunction FileUploadDropzone({\n  className,\n  wrapperClassName,\n  children,\n  onClick,\n  onDragEnter,\n  onDragLeave,\n  onDragOver,\n  onDrop,\n  disabled: disabledProp,\n  \"aria-describedby\": describedByProp,\n  ...props\n}: FileUploadDropzoneProps) {\n  const controller = useFileUploadController(\"FileUploadDropzone\")\n  const buttonRef = React.useRef<HTMLButtonElement>(null)\n  const selectionErrorId = React.useId()\n  const disabled = controller.disabled || disabledProp\n  const describedBy = [\n    describedByProp,\n    controller.selectionError ? selectionErrorId : undefined,\n  ]\n    .filter(Boolean)\n    .join(\" \") || undefined\n\n  React.useEffect(() => {\n    controller.rememberDropzoneTrigger(buttonRef.current)\n\n    return () => controller.rememberDropzoneTrigger(null)\n    // Registration follows mount/unmount; controller identity churn is noise.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [])\n\n  React.useEffect(() => {\n    if (controller.focusRequest?.target.kind !== \"dropzone\") return\n    if (disabled) return\n\n    buttonRef.current?.focus()\n  }, [controller.focusRequest, disabled])\n\n  return (\n    <div className={cn(\"w-full min-w-0\", wrapperClassName)}>\n      <button\n        ref={buttonRef}\n        type=\"button\"\n        {...props}\n        data-slot=\"file-upload-dropzone\"\n        data-dragging={\n          controller.dragState === \"idle\" ? undefined : controller.dragState\n        }\n        data-disabled={disabled ? \"\" : undefined}\n        disabled={disabled}\n        aria-describedby={describedBy}\n        className={cn(\n          \"group/dropzone relative flex w-full cursor-pointer flex-col items-center justify-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-2)] border border-dashed bg-transparent px-[var(--hui-space-6)] py-[var(--hui-space-8)] text-center outline-none\",\n          \"[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]\",\n          \"min-h-[132px] md:min-h-[176px]\",\n          \"motion-safe:[transition:border-color_var(--hui-duration-fast)_var(--hui-ease-out),background-color_var(--hui-duration-fast)_var(--hui-ease-out)]\",\n          !disabled &&\n            controller.dragState === \"idle\" && [\n              \"border-[var(--hui-color-border-base-primary)] text-[var(--hui-color-foreground-base-primary)]\",\n              \"hover:border-[var(--hui-color-border-base-emphasis)]\",\n              \"focus-visible:[outline:var(--hui-focus-ring)] focus-visible:[outline-offset:var(--hui-focus-ring-offset-accent)]\",\n            ],\n          !disabled &&\n            controller.dragState === \"valid\" && [\n              \"border-[var(--hui-color-border-accent-emphasis)] bg-[var(--hui-color-background-accent-primary)] text-[var(--hui-color-foreground-accent-emphasis)]\",\n              \"focus-visible:[outline:var(--hui-focus-ring)] focus-visible:[outline-offset:var(--hui-focus-ring-offset-accent)]\",\n            ],\n          !disabled &&\n            controller.dragState === \"invalid\" && [\n              \"border-[var(--hui-color-border-danger-emphasis)] bg-[var(--hui-color-background-danger-primary)] text-[var(--hui-color-foreground-danger-emphasis)]\",\n              \"focus-visible:[outline:var(--hui-focus-ring)] focus-visible:[outline-offset:var(--hui-focus-ring-offset-accent)]\",\n            ],\n          disabled && [\n            \"cursor-not-allowed border-[var(--hui-color-border-base-secondary)] text-[var(--hui-color-foreground-base-tertiary)]\",\n          ],\n          className\n        )}\n        onDragEnter={(event) => {\n          onDragEnter?.(event)\n          if (event.defaultPrevented) return\n\n          const fileCount = countDraggedFiles(event.dataTransfer)\n\n          if (fileCount > 0) {\n            event.preventDefault()\n            controller.beginDrag(fileCount)\n          }\n        }}\n        onDragOver={(event) => {\n          onDragOver?.(event)\n          if (event.defaultPrevented) return\n\n          const fileCount = countDraggedFiles(event.dataTransfer)\n\n          if (fileCount === 0) return\n\n          event.preventDefault()\n          event.dataTransfer.dropEffect =\n            controller.dragState === \"invalid\" ? \"none\" : \"copy\"\n          controller.refreshDragValidity(fileCount)\n        }}\n        onDragLeave={(event) => {\n          onDragLeave?.(event)\n          if (event.defaultPrevented) return\n\n          controller.endDrag()\n        }}\n        onDrop={(event) => {\n          onDrop?.(event)\n          if (event.defaultPrevented) return\n\n          const files = event.dataTransfer?.files\n\n          controller.cancelDrag()\n\n          if (!files || files.length === 0) return\n\n          event.preventDefault()\n          controller.completeDrag(files)\n        }}\n        onClick={(event) => {\n          onClick?.(event)\n          if (event.defaultPrevented) return\n\n          controller.activateFrom(event.currentTarget)\n        }}\n      >\n        <span className=\"contents\">{children}</span>\n      </button>\n\n      {controller.selectionError ? (\n        <p\n          id={selectionErrorId}\n          data-slot=\"file-upload-selection-error\"\n          className=\"mt-[var(--hui-space-2)] text-[var(--hui-color-foreground-danger-primary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\"\n        >\n          {controller.selectionError}\n        </p>\n      ) : null}\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Dropzone content                                                           */\n/* -------------------------------------------------------------------------- */\n\nexport type FileUploadIconProps = React.ComponentProps<typeof UploadIcon>\n\n/** Small upload arrow; tint follows the active drag state. */\nfunction FileUploadIcon({ className, ...props }: FileUploadIconProps) {\n  const controller = useFileUploadController(\"FileUploadIcon\")\n\n  return (\n    <UploadIcon\n      aria-hidden=\"true\"\n      data-slot=\"file-upload-icon\"\n      className={cn(\n        \"size-6 shrink-0 text-[var(--hui-color-foreground-base-secondary)] motion-safe:[transition:color_var(--hui-duration-fast)_var(--hui-ease-out)]\",\n        !controller.disabled &&\n          controller.dragState === \"valid\" &&\n          \"text-[var(--hui-color-foreground-accent-emphasis)]\",\n        !controller.disabled &&\n          controller.dragState === \"invalid\" &&\n          \"text-[var(--hui-color-foreground-danger-emphasis)]\",\n        controller.disabled && \"text-[var(--hui-color-foreground-base-tertiary)]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/**\n * Primary instruction line such as \"Drop files here or click to browse\".\n * Swap its wording during drags; color never carries that state alone.\n */\nfunction FileUploadTitle({ className, ...props }: React.ComponentProps<\"span\">) {\n  const controller = useFileUploadController(\"FileUploadTitle\")\n\n  return (\n    <span\n      data-slot=\"file-upload-title\"\n      data-dragging={\n        controller.dragState === \"idle\" ? undefined : controller.dragState\n      }\n      className={cn(\n        \"block text-[var(--hui-color-foreground-base-primary)] [font-weight:var(--hui-font-weight-medium)]\",\n        !controller.disabled &&\n          controller.dragState === \"valid\" &&\n          \"text-[var(--hui-color-foreground-accent-emphasis)]\",\n        !controller.disabled &&\n          controller.dragState === \"invalid\" &&\n          \"text-[var(--hui-color-foreground-danger-emphasis)]\",\n        controller.disabled && \"text-[var(--hui-color-foreground-base-tertiary)]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/** Muted requirements line such as \"PNG, JPG, PDF up to 10 MB\". */\nfunction FileUploadDescription({ className, ...props }: React.ComponentProps<\"span\">) {\n  const controller = useFileUploadController(\"FileUploadDescription\")\n\n  return (\n    <span\n      data-slot=\"file-upload-description\"\n      data-dragging={\n        controller.dragState === \"idle\" ? undefined : controller.dragState\n      }\n      className={cn(\n        \"block text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\",\n        !controller.disabled &&\n          controller.dragState === \"invalid\" &&\n          \"text-[var(--hui-color-foreground-danger-primary)]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Triggers and list-free actions                                             */\n/* -------------------------------------------------------------------------- */\n\n/**\n * Renders any action that opens the picker: Choose file, Replace, or\n * + Add more files. Disables itself once the limit makes adding dishonest.\n */\nfunction FileUploadTrigger({\n  render,\n  children,\n  ...props\n}: useRender.ComponentProps<\"button\">) {\n  const controller = useFileUploadController(\"FileUploadTrigger\")\n\n  const typeValue: React.ButtonHTMLAttributes<HTMLButtonElement>[\"type\"] = render\n    ? undefined\n    : \"button\"\n\n  // Built outside the call so object freshness stops flagging custom attrs.\n  const triggerDefaults = {\n    \"data-slot\": \"file-upload-trigger\",\n    type: typeValue,\n    disabled: controller.disabled || !controller.canAddMore,\n    onClick: (event: React.MouseEvent<HTMLButtonElement>) => {\n      controller.activateFrom(event.currentTarget)\n    },\n  }\n\n  return useRender({\n    defaultTagName: \"button\",\n    render,\n    props: mergeProps<\"button\">(\n      triggerDefaults,\n      // Spread keeps React from excess-checking against the render-fn arm.\n      {\n        ...props,\n        children,\n      }\n    ),\n  })\n}\n\n/**\n * Quiet \"+ Add more files\" action for lists that already hold something.\n * Children default to that phrasing; pass your own to match product voice.\n */\nfunction FileUploadAddMore({\n  children,\n  ...props\n}: Omit<useRender.ComponentProps<\"button\">, \"render\">) {\n  return (\n    <FileUploadTrigger render={<Button variant=\"outline\" />} {...props}>\n      <PlusIcon aria-hidden=\"true\" />\n      {children ?? \"Add more files\"}\n    </FileUploadTrigger>\n  )\n}\n\n/** Shows itself above one file; local selections stay easy to rebuild. */\nfunction FileUploadClear({\n  className,\n  ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\">) {\n  const controller = useFileUploadController(\"FileUploadClear\")\n\n  if (controller.value.length <= 1 || controller.disabled) {\n    return null\n  }\n\n  return (\n    <Button\n      variant=\"link\"\n      size=\"sm\"\n      className={className}\n      onClick={() => {\n        controller.clearAll()\n      }}\n      {...props}\n    >\n      Clear all\n    </Button>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* List                                                                       */\n/* -------------------------------------------------------------------------- */\n\nexport interface FileUploadListProps extends React.ComponentProps<\"ul\"> {\n  /** Set false when file-type tiles fit the list better than image thumbnails. */\n  showImagePreviews?: boolean\n}\n\n/** Selected rows plus recent rejections; absent when both collections are. */\nfunction FileUploadList({\n  className,\n  showImagePreviews = true,\n  ...props\n}: FileUploadListProps) {\n  const controller = useFileUploadController(\"FileUploadList\")\n\n  if (controller.value.length === 0 && controller.rejections.length === 0) {\n    return null\n  }\n\n  return (\n    <ul\n      data-slot=\"file-upload-list\"\n      className={cn(\n        \"m-0 mt-[var(--hui-space-3)] w-full list-none rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-secondary)] p-0\",\n        \"[&>li+li]:border-t-[0.5px] [&>li+li]:border-t-[var(--hui-color-border-base-secondary)]\",\n        className\n      )}\n      {...props}\n    >\n      {controller.value.map((item) => (\n        <FileUploadItem key={item.id} item={item}>\n          <FileUploadItemPreview showImagePreview={showImagePreviews} />\n          <FileUploadItemContent>\n            <FileUploadItemName />\n            <FileUploadItemMetadata />\n            <FileUploadItemStatus />\n            <FileUploadItemProgress />\n          </FileUploadItemContent>\n          <FileUploadItemActions>\n            <FileUploadItemRetry />\n            <FileUploadItemRemove />\n          </FileUploadItemActions>\n        </FileUploadItem>\n      ))}\n      {controller.rejections.map((rejection) => (\n        <RejectedRow key={rejection.id} rejection={rejection} />\n      ))}\n    </ul>\n  )\n}\n\nconst FileUploadItemContext = React.createContext<FileUploadItem | null>(null)\n\nfunction useFileUploadItem(componentName: string) {\n  const item = React.useContext(FileUploadItemContext)\n\n  if (!item) {\n    throw new Error(`<${componentName}> must render inside <FileUploadItem>.`)\n  }\n\n  return item\n}\n\nexport interface FileUploadItemProps extends React.ComponentProps<\"li\"> {\n  item: FileUploadItem\n}\n\nfunction FileUploadItem({ item, className, children, ...props }: FileUploadItemProps) {\n  return (\n    <FileUploadItemContext.Provider value={item}>\n      <li\n        data-slot=\"file-upload-item\"\n        data-status={item.status}\n        className={cn(\n          \"grid min-h-[64px] grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-[var(--hui-space-3)] px-[var(--hui-space-4)] py-[var(--hui-space-3)]\",\n          className\n        )}\n        {...props}\n      >\n        {children}\n      </li>\n    </FileUploadItemContext.Provider>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Item parts                                                                 */\n/* -------------------------------------------------------------------------- */\n\nconst previewTileClass =\n  \"flex size-10 shrink-0 flex-col items-center justify-center gap-y-0.5 rounded-[var(--hui-radius-1)] border-[0.5px] border-[var(--hui-color-border-base-secondary)] bg-[var(--hui-color-background-neutral-primary)] text-[var(--hui-color-foreground-base-secondary)]\"\n\n/**\n * Image files get live thumbnails; everything else gets a quiet type tile.\n * Object URLs revoke themselves when rows unmount. Images larger than 50 MB\n * skip the thumbnail so one drop cannot stall the page.\n */\nexport interface FileUploadItemPreviewProps\n  extends Omit<React.ComponentProps<\"img\">, \"src\"> {\n  src?: string\n  /** Set false to render the file-type tile for images too. */\n  showImagePreview?: boolean\n}\n\nfunction FileUploadItemPreview({\n  className,\n  src,\n  showImagePreview = true,\n  ...props\n}: FileUploadItemPreviewProps) {\n  const item = useFileUploadItem(\"FileUploadItemPreview\")\n  const generatedUrl = useImagePreviewUrl(item.file, showImagePreview)\n  const previewUrl = showImagePreview ? (src ?? generatedUrl) : null\n\n  if (!previewUrl) {\n    return (\n      <span aria-hidden=\"true\" data-slot=\"file-upload-item-preview\" className={previewTileClass}>\n        <TileGlyph extension={getFileExtension(item.file.name)} />\n      </span>\n    )\n  }\n\n  return (\n    /* The filename carries identity below; alt=\"\" avoids repeating it. */\n    // Blob URLs cannot go through next/image, and the registry installs this\n    // component outside Next projects too.\n    // eslint-disable-next-line @next/next/no-img-element\n    <img\n      alt=\"\"\n      data-slot=\"file-upload-item-preview\"\n      src={previewUrl}\n      className={cn(\n        \"size-10 shrink-0 rounded-[var(--hui-radius-1)] border-[0.5px] border-[var(--hui-color-border-base-secondary)] object-cover\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction TileGlyph({ extension }: { extension: string }) {\n  const glyphs: Record<string, React.ReactNode> = {\n    PDF: <DocumentGlyph className=\"size-4\" />,\n    DOC: <DocumentGlyph className=\"size-4\" />,\n    DOCX: <DocumentGlyph className=\"size-4\" />,\n    TXT: <DocumentGlyph className=\"size-4\" />,\n    CSV: <SpreadsheetGlyph className=\"size-4\" />,\n    XLS: <SpreadsheetGlyph className=\"size-4\" />,\n    XLSX: <SpreadsheetGlyph className=\"size-4\" />,\n  }\n\n  return (\n    <>\n      {glyphs[extension] ?? <FileGlyph className=\"size-4\" />}\n      {extension.length <= 5 ? (\n        <span className=\"text-[calc(var(--hui-font-size-micro)*0.9)] font-medium leading-none tracking-wide\">\n          {extension}\n        </span>\n      ) : null}\n    </>\n  )\n}\n\nfunction useImagePreviewUrl(file: File, enabled: boolean) {\n  const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)\n\n  // Object URLs are external browser resources that preview rows mirror into\n  // state; the returned cleanup revokes them the moment rows leave.\n  /* eslint-disable react-hooks/set-state-in-effect */\n  React.useEffect(() => {\n    if (\n      !enabled ||\n      !file.type.startsWith(\"image/\") ||\n      file.size > 50 * 1024 * 1024\n    ) {\n      setPreviewUrl(null)\n      return\n    }\n\n    const url = URL.createObjectURL(file)\n\n    setPreviewUrl(url)\n\n    return () => URL.revokeObjectURL(url)\n  }, [enabled, file])\n  /* eslint-enable react-hooks/set-state-in-effect */\n\n  return previewUrl\n}\n\nexport type FileUploadItemContentProps = FileUploadPartProps\n\nfunction FileUploadItemContent({ className, ...props }: FileUploadItemContentProps) {\n  return (\n    <div\n      data-slot=\"file-upload-item-content\"\n      className={cn(\n        \"col-start-2 flex min-w-0 flex-wrap items-baseline justify-between gap-x-[var(--hui-space-4)] gap-y-[var(--hui-space-1)]\",\n        \"[&>[data-slot=file-upload-item-progress]]:basis-full [&>[data-slot=file-upload-item-reason]]:basis-full\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nexport type FileUploadItemActionsProps = FileUploadPartProps\n\nfunction FileUploadItemActions({\n  className,\n  ...props\n}: FileUploadItemActionsProps) {\n  return (\n    <div\n      data-slot=\"file-upload-item-actions\"\n      className={cn(\n        \"col-start-3 row-start-1 flex items-center gap-[var(--hui-space-1)]\",\n        className\n      )}\n      {...props}\n    />\n  )\n}\n\nfunction FileUploadItemName({ className, ...props }: React.ComponentProps<\"span\">) {\n  const item = useFileUploadItem(\"FileUploadItemName\")\n\n  return (\n    <span\n      data-slot=\"file-upload-item-name\"\n      title={item.file.name}\n      className={cn(\n        \"truncate text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]\",\n        className\n      )}\n      {...props}\n    >\n      {item.file.name}\n    </span>\n  )\n}\n\nfunction FileUploadItemMetadata({ className, ...props }: React.ComponentProps<\"span\">) {\n  const item = useFileUploadItem(\"FileUploadItemMetadata\")\n  const extension = getFileExtension(item.file.name)\n\n  return (\n    <span\n      data-slot=\"file-upload-item-metadata\"\n      className={cn(\n        \"shrink-0 whitespace-nowrap text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\",\n        className\n      )}\n      {...props}\n    >\n      {[formatFileSize(item.file.size), extension].filter(Boolean).join(\" · \")}\n    </span>\n  )\n}\n\nfunction statusTone(status: FileUploadStatus | undefined) {\n  switch (status) {\n    case \"success\":\n      return \"text-[var(--hui-color-foreground-success-primary)]\"\n    case \"error\":\n      return \"text-[var(--hui-color-foreground-danger-primary)]\"\n    default:\n      return \"text-[var(--hui-color-foreground-base-secondary)]\"\n  }\n}\n\nfunction FileUploadItemStatus({ className, ...props }: React.ComponentProps<\"span\">) {\n  const item = useFileUploadItem(\"FileUploadItemStatus\")\n\n  return (\n    <span\n      data-slot=\"file-upload-item-status\"\n      className={cn(\n        \"inline-flex items-center gap-[var(--hui-space-2)] whitespace-nowrap [font-size:var(--hui-font-size-mini)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\",\n        statusTone(item.status),\n        className\n      )}\n      {...props}\n    >\n      <StatusLine item={item} />\n    </span>\n  )\n}\n\nfunction StatusLine({ item }: { item: FileUploadItem }) {\n  if (item.status === \"uploading\") {\n    const percentKnown = typeof item.progress === \"number\"\n\n    return (\n      <>\n        {!percentKnown && (\n          <LoaderCircleIcon\n            aria-hidden=\"true\"\n            className=\"size-3.5 motion-safe:animate-spin\"\n          />\n        )}\n        {percentKnown ? \"Uploading\" : \"Uploading...\"}\n      </>\n    )\n  }\n\n  if (item.status === \"success\") {\n    return (\n      <>\n        <CircleCheckIcon aria-hidden=\"true\" className=\"size-3.5\" />\n        Uploaded\n      </>\n    )\n  }\n\n  if (item.status === \"error\") {\n    /* Applications own the sentence; show theirs verbatim. */\n    return (\n      <>\n        <TriangleAlertIcon aria-hidden=\"true\" className=\"size-3.5\" />\n        {item.error ?? \"Upload failed\"}\n      </>\n    )\n  }\n\n  /* No lifecycle attached yet: stay quiet instead of promising an upload. */\n  if (item.status === undefined) {\n    return null\n  }\n\n  return \"Waiting to upload\"\n}\n\n/** Thin bar for running uploads; indeterminate while percent stays unknown. */\nfunction FileUploadItemProgress({\n  className,\n  ...props\n}: Omit<React.ComponentProps<typeof Progress>, \"value\">) {\n  const item = useFileUploadItem(\"FileUploadItemProgress\")\n\n  if (item.status !== \"uploading\") {\n    return null\n  }\n\n  const percent =\n    typeof item.progress === \"number\"\n      ? Math.min(100, Math.max(0, item.progress))\n      : null\n\n  return (\n    <div\n      data-slot=\"file-upload-item-progress\"\n      className={cn(\n        \"flex w-full items-center gap-[var(--hui-space-3)] pt-[var(--hui-space-1)]\",\n        className\n      )}\n      {...props}\n    >\n      <Progress\n        value={percent}\n        aria-label={`Uploading ${item.file.name}`}\n        className=\"gap-0\"\n      >\n        <ProgressTrack className=\"h-[3px]\">\n          <ProgressIndicator />\n        </ProgressTrack>\n      </Progress>\n      {/* Percent reaches assistive tech through the progressbar itself. */}\n      {percent !== null ? (\n        <span\n          aria-hidden=\"true\"\n          className=\"shrink-0 tabular-nums text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\"\n        >\n          {Math.round(percent)}%\n        </span>\n      ) : null}\n    </div>\n  )\n}\n\n/** Muted reason line under the filename; visible text beats tooltips. */\nfunction FileUploadItemReason({ className, ...props }: React.ComponentProps<\"span\">) {\n  const item = useFileUploadItem(\"FileUploadItemReason\")\n\n  if (!item.error) return null\n\n  return (\n    <span\n      data-slot=\"file-upload-item-reason\"\n      className={cn(\n        \"text-[var(--hui-color-foreground-danger-primary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\",\n        className\n      )}\n      {...props}\n    >\n      {item.error}\n    </span>\n  )\n}\n\n/** Appears beside failed uploads only; your application owns the retry. */\nfunction FileUploadItemRetry({\n  className,\n  children,\n  ...props\n}: Omit<React.ComponentProps<typeof Button>, \"onClick\">) {\n  const item = useFileUploadItem(\"FileUploadItemRetry\")\n  const controller = useFileUploadController(\"FileUploadItemRetry\")\n\n  if (item.status !== \"error\" || !controller.canRetry) {\n    return null\n  }\n\n  return (\n    <Button\n      {...props}\n      variant=\"link\"\n      size=\"sm\"\n      disabled={controller.disabled}\n      className={cn(\"-ms-[var(--hui-space-2)] h-fit shrink-0 self-center\", className)}\n      onClick={() => {\n        controller.retryItem(item)\n      }}\n    >\n      {children ?? \"Retry\"}\n    </Button>\n  )\n}\n\nexport interface FileUploadItemRemoveProps\n  extends Omit<React.ComponentProps<typeof Button>, \"onClick\" | \"aria-label\"> {\n  /** Overrides the generated \"Remove {name}\" accessible label. */\n  \"aria-label\"?: string\n  onClick?: never\n}\n\n/** Icon control normally; swaps to plain Remove text beside Retry on error. */\nfunction FileUploadItemRemove({\n  className,\n  ...props\n}: FileUploadItemRemoveProps) {\n  const item = useFileUploadItem(\"FileUploadItemRemove\")\n  const controller = useFileUploadController(\"FileUploadItemRemove\")\n  const buttonRef = React.useRef<HTMLButtonElement>(null)\n  const failed = item.status === \"error\"\n\n  React.useEffect(() => {\n    if (\n      controller.focusRequest?.target.kind === \"item-remove\" &&\n      controller.focusRequest.target.itemId === item.id\n    ) {\n      buttonRef.current?.focus()\n    }\n  }, [controller.focusRequest, item.id])\n\n  return (\n    <Button\n      ref={buttonRef}\n      {...props}\n      variant={failed ? \"link\" : \"ghost\"}\n      size={failed ? \"sm\" : \"icon-sm\"}\n      disabled={controller.disabled}\n      aria-label={props[\"aria-label\"] ?? `Remove ${item.file.name}`}\n      className={cn(\n        failed && \"-ms-[var(--hui-space-2)] h-fit shrink-0 self-center\",\n        \"max-sm:size-8\",\n        className\n      )}\n      onClick={() => {\n        controller.removeItem(item.id)\n      }}\n    >\n      {failed ? \"Remove\" : <CloseIcon aria-hidden=\"true\" />}\n    </Button>\n  )\n}\n\n/** Rejected rows stay listed with their reason until someone removes them. */\nfunction RejectedRow({ rejection }: { rejection: FileUploadRejection }) {\n  const controller = useFileUploadController(\"FileUploadRejectedRow\")\n  const buttonRef = React.useRef<HTMLButtonElement>(null)\n\n  React.useEffect(() => {\n    if (\n      controller.focusRequest?.target.kind === \"rejection-remove\" &&\n      controller.focusRequest.target.rejectionId === rejection.id\n    ) {\n      buttonRef.current?.focus()\n    }\n  }, [controller.focusRequest, rejection.id])\n\n  return (\n    <li\n      data-slot=\"file-upload-rejected-row\"\n      className=\"grid min-h-[64px] grid-cols-[auto_minmax(0,1fr)_auto] items-start gap-x-[var(--hui-space-3)] px-[var(--hui-space-4)] py-[var(--hui-space-3)]\"\n    >\n      <span\n        aria-hidden=\"true\"\n        className={cn(\n          previewTileClass,\n          \"border-[var(--hui-color-border-danger-emphasis)] bg-[var(--hui-color-background-danger-primary)] text-[var(--hui-color-foreground-danger-primary)]\"\n        )}\n      >\n        <TriangleAlertIcon className=\"size-4\" />\n      </span>\n      <div className=\"col-start-2 flex min-w-0 flex-col gap-y-[var(--hui-space-1)]\">\n        <span\n          title={rejection.file.name}\n          className=\"truncate text-[var(--hui-color-foreground-base-primary)] [font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]\"\n        >\n          {rejection.file.name}\n        </span>\n        <span className=\"text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\">\n          {[formatFileSize(rejection.file.size), getFileExtension(rejection.file.name)]\n            .filter(Boolean)\n            .join(\" · \")}\n        </span>\n        <span className=\"text-[var(--hui-color-foreground-danger-primary)] [font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\">\n          {rejection.reason}\n        </span>\n      </div>\n      <Button\n        ref={buttonRef}\n        variant=\"ghost\"\n        size=\"icon-sm\"\n        disabled={controller.disabled}\n        aria-label={`Remove ${rejection.file.name}`}\n        className=\"max-sm:size-8\"\n        onClick={() => {\n          controller.removeRejection(rejection.id)\n        }}\n      >\n        <CloseIcon aria-hidden=\"true\" />\n      </Button>\n    </li>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Trigger companions                                                         */\n/* -------------------------------------------------------------------------- */\n\n/** Muted \"report.pdf · 2.4 MB\" summary next to button-only layouts. */\nfunction FileUploadSummary({ className }: { className?: string }) {\n  const controller = useFileUploadController(\"FileUploadSummary\")\n\n  return (\n    <span\n      data-slot=\"file-upload-summary\"\n      className={cn(\n        \"truncate text-[var(--hui-color-foreground-base-secondary)] [font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]\",\n        className\n      )}\n    >\n      {controller.value.length === 0\n        ? \"No file chosen\"\n        : controller.value\n            .map((item) =>\n              [item.file.name, formatFileSize(item.file.size)]\n                .filter(Boolean)\n                .join(\" · \")\n            )\n            .join(\", \")}\n    </span>\n  )\n}\n\nexport {\n  FileUpload,\n  FileUploadAddMore,\n  FileUploadClear,\n  FileUploadDescription,\n  FileUploadDropzone,\n  FileUploadIcon,\n  FileUploadItem,\n  FileUploadItemActions,\n  FileUploadItemContent,\n  FileUploadItemMetadata,\n  FileUploadItemName,\n  FileUploadItemPreview,\n  FileUploadItemProgress,\n  FileUploadItemReason,\n  FileUploadItemRemove,\n  FileUploadItemRetry,\n  FileUploadItemStatus,\n  FileUploadList,\n  FileUploadSummary,\n  FileUploadTitle,\n  FileUploadTrigger,\n}\n"},{"path":"file-upload-context.tsx","type":"registry:component","target":"components/ui/file-upload/file-upload-context.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\n\nimport type { FileUploadControllerValue } from \"./file-upload-types\"\n\nexport const FileUploadControllerContext =\n  React.createContext<FileUploadControllerValue | null>(null)\n\nFileUploadControllerContext.displayName = \"FileUploadControllerContext\"\n\nexport function useFileUploadController(\n  componentName: string\n): FileUploadControllerValue {\n  const controller = React.useContext(FileUploadControllerContext)\n\n  if (!controller) {\n    throw new Error(\n      `<${componentName}> must be rendered inside <FileUpload> so it can read selection state.`\n    )\n  }\n\n  return controller\n}\n"},{"path":"file-upload-types.ts","type":"registry:component","target":"components/ui/file-upload/file-upload-types.ts","content":"import type * as React from \"react\"\n\n/** Upload lifecycle reported by the application, not by FileUpload. */\nexport type FileUploadStatus = \"pending\" | \"uploading\" | \"success\" | \"error\"\n\n/**\n * One selected file plus whatever upload state the application attaches to\n * it. `status` stays optional so a freshly selected file needs no ceremony:\n * pick files first, enrich them once an upload starts.\n */\nexport interface FileUploadItem {\n  id: string\n  file: File\n  status?: FileUploadStatus\n  /** 0-100, or null while progress cannot be calculated. */\n  progress?: number | null\n  /** Reason the upload failed, shown beside the retry action. */\n  error?: string\n}\n\n/**\n * A file that failed validation. Rejected files stay listed until dismissed;\n * they never enter `value`.\n */\nexport interface FileUploadRejection {\n  id: string\n  file: File\n  /** Human-readable reason, safe to render as-is. */\n  reason: string\n}\n\n/**\n * Files currently dragged over the dropzone. `dragover` events expose how\n * many files are carried, never their names or sizes, so \"invalid\" means the\n * count already exceeds capacity. Type and size checks run on drop.\n */\nexport type FileUploadDragState = \"idle\" | \"valid\" | \"invalid\"\n\n/** Where keyboard focus should land after the next commit. */\nexport type FileUploadFocusTarget =\n  | { kind: \"fallback\" }\n  | { kind: \"dropzone\" }\n  | { kind: \"item-remove\"; itemId: string }\n  | { kind: \"rejection-remove\"; rejectionId: string }\n\n/** MIME-to-extension pairs such as {\"image/png\": [\".png\"]}, or an accept string. */\nexport type FileUploadAccept = Record<string, string[]> | string\n\n/** Attributes forwarded to the hidden native input. */\nexport interface FileUploadInputAttributes {\n  accept?: string\n  capture?: \"user\" | \"environment\"\n  multiple: boolean\n  \"webkitdirectory\"?: \"\"\n}\n\n/**\n * Everything the composed parts share. Applications rarely touch this\n * directly; parts consume it through context.\n */\nexport interface FileUploadControllerValue {\n  value: FileUploadItem[]\n  rejections: FileUploadRejection[]\n  dragState: FileUploadDragState\n  disabled: boolean\n  multiple: boolean\n  /** False once the limit blocks honest adding; drives trigger disabling. */\n  canAddMore: boolean\n  /** True when failed rows can call an application retry handler. */\n  canRetry: boolean\n  selectionError: string | null\n  focusRequest: { target: FileUploadFocusTarget; nonce: number } | null\n  inputProps: FileUploadInputAttributes\n  openPicker(): void\n  /** Registers the activating element so focus can return after picking. */\n  activateFrom(element: HTMLElement): void\n  removeItem(itemId: string): void\n  removeRejection(rejectionId: string): void\n  clearAll(): void\n  retryItem(item: FileUploadItem): void\n  rememberDropzoneTrigger(element: HTMLElement | null): void\n  requestFocus(target: FileUploadFocusTarget): void\n  beginDrag(draggedFileCount: number): void\n  refreshDragValidity(draggedFileCount: number): void\n  endDrag(): void\n  cancelDrag(): void\n  completeDrag(files: FileList): void\n  announce(message: string): void\n}\n\n/**\n * Base props shared by wrapper-style parts so each accepts `className` and\n * native attributes like `id`, which is also how Field labels attach.\n */\nexport type FileUploadPartProps = React.ComponentProps<\"div\">\n"},{"path":"file-upload-utils.ts","type":"registry:component","target":"components/ui/file-upload/file-upload-utils.ts","content":"import type { FileUploadAccept } from \"./file-upload-types\"\n\nlet idCounter = 0\n\n/** Stable ids without assuming crypto.randomUUID support. */\nexport function createFileUploadId(prefix: string) {\n  if (typeof crypto !== \"undefined\" && \"randomUUID\" in crypto) {\n    return `${prefix}-${crypto.randomUUID()}`\n  }\n\n  idCounter += 1\n\n  return `${prefix}-${Date.now().toString(36)}-${idCounter}`\n}\n\nconst KB = 1024\nconst MB = 1024 * 1024\n\n/** Compact human size: 820 KB, 1.4 MB, 2 GB. Binary units, one decimal. */\nexport function formatFileSize(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes < 0) {\n    return \"\"\n  }\n\n  const formatter = new Intl.NumberFormat(\"en\", { maximumFractionDigits: 1 })\n\n  if (bytes < KB) {\n    return `${bytes} B`\n  }\n\n  if (bytes < MB) {\n    return `${formatter.format(bytes / KB)} KB`\n  }\n\n  const gb = bytes / MB / 1024\n\n  return gb >= 1 ? `${formatter.format(gb)} GB` : `${formatter.format(bytes / MB)} MB`\n}\n\nexport function getFileExtension(fileName: string): string {\n  const dot = fileName.lastIndexOf(\".\")\n\n  if (dot <= 0 || dot === fileName.length - 1) {\n    return \"\"\n  }\n\n  return fileName.slice(dot + 1).toUpperCase()\n}\n\ninterface NormalizedAccept {\n  mimeTypes: Set<string>\n  wildcards: string[]\n  extensions: Set<string>\n}\n\nfunction normalizeAccept(accept?: FileUploadAccept | undefined): NormalizedAccept | null {\n  if (!accept) {\n    return null\n  }\n\n  const tokens =\n    typeof accept === \"string\"\n      ? accept.split(\",\").map((token) => token.trim())\n      : Object.entries(accept).flatMap(([mimeType, extensions]) => [\n          mimeType,\n          ...extensions.map((extension) => extension.trim()),\n        ])\n\n  const normalized: NormalizedAccept = {\n    mimeTypes: new Set(),\n    wildcards: [],\n    extensions: new Set(),\n  }\n\n  for (const token of tokens) {\n    if (!token) continue\n\n    if (token.startsWith(\".\")) {\n      normalized.extensions.add(token.slice(1).toLowerCase())\n    } else if (token.endsWith(\"/*\")) {\n      normalized.wildcards.push(token.slice(0, -2).toLowerCase())\n    } else {\n      normalized.mimeTypes.add(token.toLowerCase())\n    }\n  }\n\n  return normalized\n}\n\n/** Format aliases collapse in prose so reasons read \"JPG\", not \"JPEG or JPG\". */\nconst extensionDisplayAliases: Record<string, string> = {\n  jpeg: \"jpg\",\n  tiff: \"tif\",\n  html: \"htm\",\n}\n\n/** Human list built from an accept record, e.g. \"PNG, JPG, or PDF\". */\nexport function describeAcceptedTypes(\n  accept?: FileUploadAccept | undefined,\n): string | null {\n  if (!accept) {\n    return null\n  }\n\n  const labels: string[] = []\n\n  if (typeof accept !== \"string\") {\n    for (const extensions of Object.values(accept)) {\n      labels.push(...extensions)\n    }\n  } else {\n    labels.push(...accept.split(\",\").map((token) => token.trim()))\n  }\n\n  const unique = [\n    ...new Set(\n      labels\n        .filter(Boolean)\n        .map((label) => label.replace(/^\\./, \"\").toLowerCase())\n        .map((label) => extensionDisplayAliases[label] ?? label)\n        .map((label) => label.toUpperCase()),\n    ),\n  ]\n\n  if (unique.length === 0) {\n    return null\n  }\n\n  if (unique.length === 1) {\n    return unique[0]\n  }\n\n  if (unique.length === 2) {\n    return `${unique[0]} or ${unique[1]}`\n  }\n\n  return `${unique.slice(0, -1).join(\", \")}, or ${unique[unique.length - 1]}`\n}\n\nexport function isFileAccepted(file: File, accept?: FileUploadAccept | undefined) {\n  const normalized = normalizeAccept(accept)\n\n  if (!normalized) {\n    return true\n  }\n\n  const extension = file.name.includes(\".\")\n    ? file.name.slice(file.name.lastIndexOf(\".\") + 1).toLowerCase()\n    : \"\"\n\n  if (extension && normalized.extensions.has(extension)) {\n    return true\n  }\n\n  const mimeType = file.type.toLowerCase()\n\n  if (normalized.mimeTypes.has(mimeType)) {\n    return true\n  }\n\n  return normalized.wildcards.some((prefix) => mimeType.startsWith(`${prefix}/`))\n}\n\nexport interface FileUploadRuleCheckResult {\n  valid: boolean\n  reason?: string\n}\n\nexport interface FileUploadRuleChecks {\n  accept?: FileUploadAccept | undefined\n  minSize?: number | undefined\n  maxSize?: number | undefined\n  validateFile?: ((file: File) => string | null) | undefined\n}\n\n/**\n * Per-file rules only. File-count limits are selection-level decisions the\n * root owns, because they depend on what has already been chosen.\n */\nexport function checkFileAgainstRules(\n  file: File,\n  checks: FileUploadRuleChecks,\n): FileUploadRuleCheckResult {\n  if (file.size === 0) {\n    return { valid: false, reason: \"This file is empty.\" }\n  }\n\n  if (checks.minSize !== undefined && checks.minSize > 0 && file.size < checks.minSize) {\n    return {\n      valid: false,\n      reason: `This file is too small. Minimum size is ${formatFileSize(checks.minSize)}.`,\n    }\n  }\n\n  if (checks.maxSize !== undefined && file.size > checks.maxSize) {\n    return {\n      valid: false,\n      reason: `File is too large. Maximum is ${formatFileSize(checks.maxSize)}.`,\n    }\n  }\n\n  if (!isFileAccepted(file, checks.accept)) {\n    const allowed = describeAcceptedTypes(checks.accept)\n\n    return {\n      valid: false,\n      reason: allowed\n        ? `File type is not supported. Choose ${allowed}.`\n        : \"File type is not supported.\",\n    }\n  }\n\n  const custom = checks.validateFile?.(file)\n\n  if (custom) {\n    return { valid: false, reason: custom }\n  }\n\n  return { valid: true }\n}\n\n/**\n * HTML accept attribute for the hidden input. A record keeps its extension\n * pairs so pickers offer the right filter; strings pass through untouched.\n */\nexport function toInputAcceptAttribute(accept?: FileUploadAccept | undefined) {\n  if (!accept) {\n    return undefined\n  }\n\n  if (typeof accept === \"string\") {\n    return accept\n  }\n\n  return Object.entries(accept)\n    .map(([mimeType, extensions]) => [mimeType, ...extensions].join(\",\"))\n    .join(\",\")\n}\n"}],"dependencies":["@base-ui-components/react","honestui"],"registryDependencies":["https://www.honestui.com/r/button.json","https://www.honestui.com/r/progress.json"]}