{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-upload",
  "title": "文件上传",
  "description": "多文件选择与证件槽位，支持拖放、拍照、格式大小校验与受控上传状态，组件不发送请求。",
  "dependencies": [
    "lucide-react",
    "@base-ui/react"
  ],
  "registryDependencies": [
    "attachment",
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ui/file-upload/file-upload.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { RotateCw, X } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Attachment,\n  AttachmentGroup,\n  AttachmentMedia,\n  AttachmentContent,\n  AttachmentTitle,\n  AttachmentDescription,\n  AttachmentActions,\n  AttachmentAction,\n} from \"@/components/ui/attachment\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { FileUploadPreview } from \"./file-upload-parts\"\nimport type { FileUploadProps } from \"./types\"\nimport { useFileSelection, useFileUploadReset } from \"./use-file-selection\"\nimport { describeAccept, formatFileSize, interpolate } from \"./utils\"\n\nfunction FileUpload({\n  value: prop,\n  defaultValue = [],\n  onValueChange,\n  accept,\n  maxSize = Infinity,\n  maxFiles = Infinity,\n  multiple = true,\n  replace,\n  capture = \"environment\",\n  disabled,\n  error,\n  labels: overrides,\n  onReject,\n  onRetry,\n  className,\n  \"aria-describedby\": describedBy,\n  ...props\n}: FileUploadProps) {\n  const [items, setItems] = useControllableState({\n    prop,\n    defaultProp: defaultValue,\n    onChange: onValueChange,\n  })\n  const selection = useFileSelection({\n    accept,\n    maxSize,\n    maxFiles,\n    multiple,\n    replace,\n    labels: overrides,\n    onReject,\n  })\n  const { labels, input, camera, message } = selection\n  const [dragging, setDragging] = React.useState(false)\n  const depth = React.useRef(0)\n  useFileUploadReset(input, () => {\n    if (prop === undefined) setItems(defaultValue)\n    selection.clear()\n  })\n  const errorId = React.useId()\n  const hintId = React.useId()\n  // 被拒文件只在下方出一行说明，不把整个控件标成出错；出错态只跟随外部 error\n  const invalid = Boolean(error || props[\"aria-invalid\"])\n  function add(files: File[]) {\n    if (disabled) return\n    const next = selection.select(files, items.length)\n    if (next.length) setItems(selection.replacing ? next : [...items, ...next])\n  }\n  function change(event: React.ChangeEvent<HTMLInputElement>) {\n    add(Array.from(event.target.files ?? []))\n    event.target.value = \"\"\n  }\n  const canCapture =\n    capture !== false &&\n    (!accept ||\n      accept\n        .split(\",\")\n        .some((v) => /image\\/|\\.(jpe?g|png|webp|heic|heif|avif)/i.test(v)))\n  return (\n    <div\n      data-slot=\"file-upload\"\n      data-disabled={disabled || undefined}\n      className={cn(\"@container flex min-w-0 flex-col gap-3\", className)}\n      {...props}\n    >\n      <input\n        data-slot=\"file-upload-input\"\n        ref={input}\n        type=\"file\"\n        className=\"hidden\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={change}\n      />\n      <input\n        data-slot=\"file-upload-camera\"\n        ref={camera}\n        type=\"file\"\n        className=\"hidden\"\n        accept=\"image/*\"\n        capture={capture || undefined}\n        disabled={disabled}\n        onChange={change}\n      />\n      {/* 拖放是指针增强操作，键盘与读屏用户通过区域内的原生「选择文件」按钮选择文件 */}\n      <fieldset\n        data-slot=\"file-upload-dropzone\"\n        aria-label={labels.dropzone}\n        data-dragging={dragging || undefined}\n        data-invalid={invalid || undefined}\n        aria-describedby={[\n          describedBy,\n          hintId,\n          invalid || message ? errorId : null,\n        ]\n          .filter(Boolean)\n          .join(\" \")}\n        className=\"flex min-w-0 flex-col items-center gap-3 rounded-xl border border-dashed border-edge bg-muted/40 p-5 data-invalid:border-destructive data-invalid:ring-3 data-invalid:ring-destructive/20 data-[dragging]:border-solid data-[dragging]:bg-background dark:data-invalid:ring-destructive/40\"\n        onDragEnter={(event) => {\n          event.preventDefault()\n          if (!disabled) {\n            depth.current++\n            setDragging(true)\n          }\n        }}\n        onDragOver={(event) => event.preventDefault()}\n        onDragLeave={() => {\n          depth.current--\n          if (depth.current <= 0) setDragging(false)\n        }}\n        onDrop={(event) => {\n          event.preventDefault()\n          depth.current = 0\n          setDragging(false)\n          add(Array.from(event.dataTransfer.files))\n        }}\n      >\n        <div className=\"flex flex-wrap items-center justify-center gap-2 pointer-coarse:hidden\">\n          <span className=\"text-sm text-muted-foreground\">{labels.drop}</span>\n          <Button\n            type=\"button\"\n            size=\"sm\"\n            variant=\"outline\"\n            disabled={disabled}\n            aria-invalid={invalid || undefined}\n            aria-describedby={invalid || message ? errorId : undefined}\n            onClick={() => input.current?.click()}\n          >\n            {labels.choose}\n          </Button>\n        </div>\n        <div className=\"hidden w-full gap-2 pointer-coarse:flex\">\n          {canCapture && (\n            <Button\n              type=\"button\"\n              size=\"lg\"\n              variant=\"outline\"\n              className=\"min-w-0 flex-1\"\n              disabled={disabled}\n              onClick={() => camera.current?.click()}\n            >\n              {labels.camera}\n            </Button>\n          )}\n          <Button\n            type=\"button\"\n            size=\"lg\"\n            variant=\"outline\"\n            className=\"min-w-0 flex-1\"\n            disabled={disabled}\n            onClick={() => input.current?.click()}\n          >\n            {labels.album}\n          </Button>\n        </div>\n        <p id={hintId} className=\"text-center text-xs text-muted-foreground\">\n          {interpolate(labels.hint, {\n            accept: accept ? describeAccept(accept) : labels.any,\n            size: Number.isFinite(maxSize)\n              ? formatFileSize(maxSize)\n              : labels.unlimited,\n          })}\n        </p>\n      </fieldset>\n      {items.length > 0 && (\n        <AttachmentGroup className=\"flex-col overflow-visible\">\n          {items.map((item) => (\n            <Attachment\n              key={item.id}\n              state={\n                !item.status || item.status === \"pending\" ? \"idle\" : item.status\n              }\n              className=\"w-full flex-nowrap focus-within:ring-0\"\n            >\n              <AttachmentMedia variant=\"image\">\n                <FileUploadPreview file={item.file} />\n              </AttachmentMedia>\n              <AttachmentContent>\n                <AttachmentTitle title={item.file.name}>\n                  {item.file.name}\n                </AttachmentTitle>\n                <AttachmentDescription className=\"font-mono\">\n                  {formatFileSize(item.file.size)}\n                </AttachmentDescription>\n                {item.status === \"uploading\" && item.progress !== undefined && (\n                  <div\n                    data-slot=\"file-upload-progress\"\n                    aria-hidden=\"true\"\n                    className=\"mt-1 h-1 overflow-hidden rounded-full bg-muted\"\n                  >\n                    <div\n                      className=\"h-full rounded-full bg-primary transition-[width] duration-200 motion-reduce:transition-none\"\n                      style={{\n                        width: `${Math.max(0, Math.min(100, item.progress))}%`,\n                      }}\n                    />\n                  </div>\n                )}\n                {item.error && (\n                  <p className=\"text-xs text-destructive\">{item.error}</p>\n                )}\n              </AttachmentContent>\n              <AttachmentActions>\n                <span className=\"text-xs text-muted-foreground\">\n                  {labels[item.status ?? \"pending\"]}\n                  {item.status === \"uploading\" && item.progress !== undefined\n                    ? ` ${Math.round(Math.max(0, Math.min(100, item.progress)))}%`\n                    : \"\"}\n                </span>\n                {item.status === \"error\" && onRetry && (\n                  <AttachmentAction\n                    type=\"button\"\n                    size=\"icon-sm\"\n                    disabled={disabled}\n                    aria-label={interpolate(labels.retry, {\n                      name: item.file.name,\n                    })}\n                    onClick={() => onRetry(item)}\n                  >\n                    <RotateCw aria-hidden=\"true\" />\n                  </AttachmentAction>\n                )}\n                <AttachmentAction\n                  type=\"button\"\n                  size=\"icon-sm\"\n                  disabled={disabled}\n                  aria-label={interpolate(labels.remove, {\n                    name: item.file.name,\n                  })}\n                  onClick={() => {\n                    setItems(items.filter((v) => v.id !== item.id))\n                    selection.clear()\n                  }}\n                >\n                  <X aria-hidden=\"true\" />\n                </AttachmentAction>\n              </AttachmentActions>\n            </Attachment>\n          ))}\n        </AttachmentGroup>\n      )}\n      <div\n        data-slot=\"file-upload-error\"\n        id={errorId}\n        aria-live=\"polite\"\n        className=\"text-xs text-destructive\"\n      >\n        {error || message}\n      </div>\n    </div>\n  )\n}\nexport { FileUpload }\nexport { FileUploadSlot } from \"./file-upload-slot\"\nexport {\n  IdCardFrontIllustration,\n  IdCardBackIllustration,\n} from \"./file-upload-parts\"\nexport { appendFiles } from \"./utils\"\nexport type {\n  FileUploadItem,\n  FileUploadProps,\n  FileUploadSlotProps,\n  FileUploadRejection,\n} from \"./types\"\nexport type { FileUploadLabels } from \"./config\"\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/file-upload.tsx"
    },
    {
      "path": "src/registry/ui/file-upload/config.ts",
      "content": "const DEFAULT_LABELS = {\n  cameraAlign: \"将身份证{label}放入框内，对齐四角\",\n  cameraLight: \"保持光线充足、避免反光\",\n  cameraConfirm: \"确认照片清晰完整\",\n  cameraCancel: \"取消\",\n  cameraShoot: \"拍摄\",\n  cameraUse: \"使用这张\",\n  cameraFallback: \"无法打开相机，已改用系统相机\",\n  uploaded: \"已上传\",\n  dropzone: \"文件上传\",\n  drop: \"把文件拖到这里，或\",\n  choose: \"选择文件\",\n  camera: \"拍照\",\n  album: \"从相册选择\",\n  pending: \"待提交\",\n  uploading: \"上传中\",\n  error: \"上传失败\",\n  done: \"已完成\",\n  remove: \"删除 {name}\",\n  retry: \"重试 {name}\",\n  retryShort: \"重试\",\n  replace: \"重拍\",\n  delete: \"删除\",\n  upload: \"上传{label}\",\n  any: \"任意格式\",\n  hint: \"{accept} · 单个不超过 {size}\",\n  unlimited: \"不限大小\",\n  typeError: \"「{name}」格式不支持，请选择{accept}\",\n  sizeError: \"「{name}」超过 {size}\",\n  countError: \"最多可添加 {count} 个文件，超出的未添加\",\n}\ntype FileUploadLabels = typeof DEFAULT_LABELS\nexport { DEFAULT_LABELS }\nexport type { FileUploadLabels }\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/config.ts"
    },
    {
      "path": "src/registry/ui/file-upload/file-upload-parts.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { cn } from \"@/lib/utils\"\n\nfunction FileUploadPreview({\n  file,\n  className,\n}: {\n  file: File\n  className?: string\n}) {\n  const image = React.useRef<HTMLImageElement>(null)\n  const [failed, setFailed] = React.useState<File | null>(null)\n  React.useEffect(() => {\n    if (!file.type.startsWith(\"image/\") || !image.current) return\n    const url = URL.createObjectURL(file)\n    image.current.src = url\n    return () => URL.revokeObjectURL(url)\n  }, [file])\n  return (\n    <span\n      data-slot=\"file-upload-preview\"\n      className={cn(\n        \"flex size-full items-center justify-center overflow-hidden text-xs text-muted-foreground\",\n        className,\n      )}\n    >\n      {file.type.startsWith(\"image/\") && failed !== file ? (\n        // objectURL 在 effect 中赋给原生图片，避免渲染期分配资源。\n        // eslint-disable-next-line @next/next/no-img-element\n        <img\n          ref={image}\n          alt=\"\"\n          className=\"size-full object-cover\"\n          onError={() => setFailed(file)}\n        />\n      ) : (\n        <span className=\"font-mono uppercase\">\n          {file.name.split(\".\").pop()?.slice(0, 6)}\n        </span>\n      )}\n    </span>\n  )\n}\nfunction IdCardFrontIllustration(props: React.ComponentProps<\"svg\">) {\n  return (\n    <svg\n      data-slot=\"id-card-front-illustration\"\n      viewBox=\"0 0 158 100\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      aria-hidden=\"true\"\n      {...props}\n    >\n      <rect x=\"4\" y=\"4\" width=\"150\" height=\"92\" rx=\"8\" />\n      <circle cx=\"116\" cy=\"37\" r=\"12\" />\n      <path d=\"M96 74v-8a20 20 0 0 1 40 0v8M22 29h49M22 45h40M22 61h49M22 79h63\" />\n    </svg>\n  )\n}\nfunction IdCardBackIllustration(props: React.ComponentProps<\"svg\">) {\n  return (\n    <svg\n      data-slot=\"id-card-back-illustration\"\n      viewBox=\"0 0 158 100\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      aria-hidden=\"true\"\n      {...props}\n    >\n      <rect x=\"4\" y=\"4\" width=\"150\" height=\"92\" rx=\"8\" />\n      <circle cx=\"32\" cy=\"29\" r=\"15\" />\n      <circle cx=\"32\" cy=\"29\" r=\"8\" />\n      <path d=\"M35 65h88M49 80h60\" />\n    </svg>\n  )\n}\nexport { FileUploadPreview, IdCardFrontIllustration, IdCardBackIllustration }\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/file-upload-parts.tsx"
    },
    {
      "path": "src/registry/ui/file-upload/file-upload-slot.tsx",
      "content": "\"use client\"\nimport { Check } from \"lucide-react\"\nimport { FileUploadCamera } from \"./file-upload-camera\"\nimport * as React from \"react\"\nimport { RotateCw } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { useControllableState } from \"@/hooks/use-controllable-state\"\nimport { FileUploadPreview, IdCardFrontIllustration } from \"./file-upload-parts\"\nimport { useFileSelection, useFileUploadReset } from \"./use-file-selection\"\nimport { interpolate } from \"./utils\"\nimport type { FileUploadSlotProps } from \"./types\"\nfunction FileUploadSlot({\n  label,\n  viewfinder = \"id-card\",\n  onRetry,\n  placeholder,\n  value: prop,\n  defaultValue = null,\n  onValueChange,\n  accept = \"image/*\",\n  maxSize = Infinity,\n  capture = \"environment\",\n  error,\n  disabled,\n  labels: overrides,\n  onReject,\n  className,\n  ...props\n}: FileUploadSlotProps) {\n  const [item, setItem] = useControllableState({\n    prop,\n    defaultProp: defaultValue,\n    onChange: onValueChange,\n  })\n  const { input, labels, message, clear, select } = useFileSelection({\n    accept,\n    maxSize,\n    multiple: false,\n    labels: overrides,\n    onReject,\n  })\n  useFileUploadReset(input, () => {\n    if (prop === undefined) setItem(defaultValue)\n    clear()\n  })\n  const [cameraOpen, setCameraOpen] = React.useState(false)\n  const [cameraError, setCameraError] = React.useState(\"\")\n  const album = React.useRef<HTMLInputElement>(null)\n  const busy = disabled || item?.status === \"uploading\"\n  function choose() {\n    setCameraError(\"\")\n    if (\n      viewfinder &&\n      capture &&\n      matchMedia(\"(pointer: coarse)\").matches &&\n      typeof navigator.mediaDevices?.getUserMedia === \"function\" &&\n      window.isSecureContext\n    )\n      setCameraOpen(true)\n    else input.current?.click()\n  }\n  function receive(file: File) {\n    setCameraError(\"\")\n    const next = select([file], 0)\n    if (next[0]) setItem(next[0])\n  }\n  const errorId = React.useId()\n  // 被拒文件只在下方说明；外部错误或上传失败会把槽位标红。\n  const invalid = Boolean(\n    error || item?.status === \"error\" || props[\"aria-invalid\"],\n  )\n  return (\n    <div\n      data-slot=\"file-upload-slot\"\n      className={cn(\"@container min-w-0\", className)}\n      {...props}\n    >\n      {cameraOpen && (\n        <FileUploadCamera\n          label={label}\n          labels={labels}\n          placeholder={placeholder}\n          onClose={() => setCameraOpen(false)}\n          onSelect={(file) => {\n            receive(file)\n            setCameraOpen(false)\n          }}\n          onAlbum={() => {\n            setCameraOpen(false)\n            album.current?.click()\n          }}\n          onError={() => {\n            setCameraOpen(false)\n            setCameraError(labels.cameraFallback)\n            input.current?.click()\n          }}\n        />\n      )}\n      <input\n        ref={album}\n        type=\"file\"\n        accept={accept}\n        className=\"hidden\"\n        disabled={busy}\n        onChange={(event) => {\n          if (event.target.files?.[0]) receive(event.target.files[0])\n          event.target.value = \"\"\n        }}\n      />\n      <p className=\"mb-2 text-sm font-medium\">{label}</p>\n      <input\n        data-slot=\"file-upload-slot-input\"\n        ref={input}\n        type=\"file\"\n        className=\"hidden\"\n        accept={accept}\n        capture={capture || undefined}\n        disabled={busy}\n        onChange={(event) => {\n          setCameraError(\"\")\n          const next = select(Array.from(event.target.files ?? []), 0)\n          if (next[0]) setItem(next[0])\n          event.target.value = \"\"\n        }}\n      />\n      <div\n        data-slot=\"file-upload-slot-control\"\n        aria-invalid={invalid || undefined}\n        className={cn(\n          \"relative aspect-[1.58] rounded-xl border border-edge bg-muted/40 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40\",\n          !item && \"border-dashed\",\n        )}\n      >\n        {item ? (\n          <>\n            <FileUploadPreview file={item.file} className=\"rounded-xl\" />\n            {item.status === \"done\" && (\n              <span className=\"absolute top-2 right-2 rounded-full bg-primary p-1 text-primary-foreground\">\n                <Check className=\"size-3\" aria-hidden=\"true\" />\n                <span className=\"sr-only\">{labels.uploaded}</span>\n              </span>\n            )}\n            {item.status === \"uploading\" && (\n              <div\n                className=\"absolute inset-0 flex items-center justify-center overflow-hidden rounded-xl bg-background/70\"\n                role=\"progressbar\"\n                aria-label={labels.uploading}\n                aria-valuenow={Math.round(item.progress ?? 0)}\n                aria-valuemin={0}\n                aria-valuemax={100}\n              >\n                <span className=\"font-mono text-sm font-medium tabular-nums\">\n                  {Math.round(item.progress ?? 0)}%\n                </span>\n                <div className=\"absolute inset-x-0 bottom-0 h-1 bg-muted\">\n                  <div\n                    className=\"h-full bg-primary\"\n                    style={{\n                      width: `${Math.max(0, Math.min(100, item.progress ?? 0))}%`,\n                    }}\n                  />\n                </div>\n              </div>\n            )}\n            {/* 上传中不给操作，避免在请求途中被替换或删除 */}\n            {item.status !== \"uploading\" && (\n              <div className=\"absolute right-2 bottom-2 flex gap-2\">\n                {item.status === \"error\" && onRetry ? (\n                  <Button\n                    type=\"button\"\n                    size=\"sm\"\n                    variant=\"outline\"\n                    disabled={disabled}\n                    aria-label={interpolate(labels.retry, { name: label })}\n                    onClick={() => onRetry(item)}\n                  >\n                    <RotateCw aria-hidden=\"true\" />\n                    {labels.retryShort}\n                  </Button>\n                ) : (\n                  <Button\n                    type=\"button\"\n                    size=\"sm\"\n                    variant=\"outline\"\n                    disabled={busy}\n                    onClick={choose}\n                  >\n                    {labels.replace}\n                  </Button>\n                )}\n                <Button\n                  type=\"button\"\n                  size=\"sm\"\n                  variant=\"outline\"\n                  disabled={busy}\n                  aria-label={interpolate(labels.remove, {\n                    name: item.file.name,\n                  })}\n                  onClick={() => {\n                    setItem(null)\n                    clear()\n                  }}\n                >\n                  {labels.delete}\n                </Button>\n              </div>\n            )}\n          </>\n        ) : (\n          <button\n            data-slot=\"file-upload-slot-trigger\"\n            type=\"button\"\n            disabled={busy}\n            aria-describedby={invalid || message ? errorId : undefined}\n            className=\"flex size-full flex-col items-center justify-center gap-3 rounded-xl text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50\"\n            onClick={choose}\n          >\n            {placeholder ?? (\n              <IdCardFrontIllustration className=\"w-24 text-muted-foreground\" />\n            )}\n            <span>{interpolate(labels.upload, { label })}</span>\n          </button>\n        )}\n      </div>\n      <div\n        data-slot=\"file-upload-slot-error\"\n        id={errorId}\n        aria-live=\"polite\"\n        className=\"mt-2 text-xs text-destructive\"\n      >\n        {error || item?.error || cameraError || message}\n      </div>\n    </div>\n  )\n}\nexport { FileUploadSlot }\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/file-upload-slot.tsx"
    },
    {
      "path": "src/registry/ui/file-upload/types.ts",
      "content": "import type * as React from \"react\"\nimport type { FileUploadLabels } from \"./config\"\ntype FileUploadItem = {\n  id: string\n  file: File\n  status?: \"pending\" | \"uploading\" | \"error\" | \"done\"\n  progress?: number\n  error?: string\n}\ntype FileUploadRejection = { file: File; reason: \"type\" | \"size\" | \"count\" }\ntype FileUploadCommonProps = Omit<\n  React.ComponentProps<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  /** MIME 或扩展名列表，逗号分隔。@default undefined */\n  accept?: string\n  /** 单文件大小上限，字节。@default Infinity */\n  maxSize?: number\n  /** 手机拍照方向；false 隐藏拍照。@default \"environment\" */\n  capture?: \"user\" | \"environment\" | false\n  /** 整体错误提示。 */\n  error?: React.ReactNode\n  /** @default false */\n  disabled?: boolean\n  labels?: Partial<FileUploadLabels>\n  onReject?: (rejections: FileUploadRejection[]) => void\n}\ntype FileUploadProps = FileUploadCommonProps & {\n  value?: FileUploadItem[]\n  /** @default [] */\n  defaultValue?: FileUploadItem[]\n  onValueChange?: (value: FileUploadItem[]) => void\n  /** 总文件数上限。@default Infinity */\n  maxFiles?: number\n  /** @default true */\n  multiple?: boolean\n  /**\n   * 单文件模式下，新文件通过校验后直接替换旧文件；多文件模式忽略\n   * @default 单文件模式（`multiple={false}` 或 `maxFiles={1}`）时为 true\n   */\n  replace?: boolean\n  /** 传入后，上传失败的条目右侧出现「重试」按钮；重新上传由使用方完成 */\n  onRetry?: (item: FileUploadItem) => void\n}\ntype FileUploadSlotProps = FileUploadCommonProps & {\n  /** 触屏安全上下文中启用证件取景；false 始终走原生输入。@default \"id-card\" */\n  viewfinder?: \"id-card\" | false\n  /** 点击失败槽位的重试按钮，由使用方更新上传状态。 */\n  onRetry?: (item: FileUploadItem) => void\n  label: string\n  placeholder?: React.ReactNode\n  value?: FileUploadItem | null\n  /** @default null */\n  defaultValue?: FileUploadItem | null\n  onValueChange?: (value: FileUploadItem | null) => void\n}\nexport type {\n  FileUploadItem,\n  FileUploadRejection,\n  FileUploadProps,\n  FileUploadSlotProps,\n}\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/types.ts"
    },
    {
      "path": "src/registry/ui/file-upload/use-file-selection.ts",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { DEFAULT_LABELS } from \"./config\"\nimport type { FileUploadProps } from \"./types\"\nimport {\n  describeAccept,\n  formatFileSize,\n  interpolate,\n  validateFiles,\n} from \"./utils\"\nfunction useFileSelection({\n  accept,\n  maxSize = Infinity,\n  maxFiles = Infinity,\n  multiple = true,\n  replace = !multiple || maxFiles === 1,\n  labels: overrides,\n  onReject,\n}: FileUploadProps) {\n  const replacing = replace && (!multiple || maxFiles === 1)\n  const labels = { ...DEFAULT_LABELS, ...overrides }\n  const [message, setMessage] = React.useState(\"\")\n  const input = React.useRef<HTMLInputElement>(null)\n  const camera = React.useRef<HTMLInputElement>(null)\n  function select(files: File[], count: number) {\n    const result = validateFiles(\n      files,\n      accept,\n      maxSize,\n      Math.max(0, Math.min(maxFiles, multiple ? Infinity : 1) - (replacing ? 0 : count)),\n    )\n    setMessage(\n      result.rejected\n        .map(({ file, reason }) =>\n          interpolate(\n            reason === \"type\"\n              ? labels.typeError\n              : reason === \"size\"\n                ? labels.sizeError\n                : labels.countError,\n            {\n              name: file.name,\n              accept: accept ? describeAccept(accept) : labels.any,\n              size: formatFileSize(maxSize),\n              count: multiple ? maxFiles : 1,\n            },\n          ),\n        )\n        .filter((v, i, all) => all.indexOf(v) === i)\n        .join(\"；\"),\n    )\n    if (result.rejected.length) onReject?.(result.rejected)\n    return result.accepted\n  }\n  return { replacing, labels, message, clear: () => setMessage(\"\"), input, camera, select }\n}\nfunction useFileUploadReset(\n  input: React.RefObject<HTMLInputElement | null>,\n  onReset: () => void,\n) {\n  const callback = React.useEffectEvent(onReset)\n  React.useEffect(() => {\n    const form = input.current?.form\n    function reset(event: Event) {\n      queueMicrotask(() => {\n        if (!event.defaultPrevented) callback()\n      })\n    }\n    form?.addEventListener(\"reset\", reset)\n    return () => form?.removeEventListener(\"reset\", reset)\n  }, [input])\n}\nexport { useFileSelection, useFileUploadReset }\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/use-file-selection.ts"
    },
    {
      "path": "src/registry/ui/file-upload/utils.ts",
      "content": "import type { FileUploadItem, FileUploadRejection } from \"./types\"\nfunction formatFileSize(size: number) {\n  const unit = size >= 1048576 ? \"MB\" : size >= 1024 ? \"KB\" : \"B\"\n  return `${new Intl.NumberFormat(\"zh-CN\", { maximumFractionDigits: 1 }).format(size / (unit === \"MB\" ? 1048576 : unit === \"KB\" ? 1024 : 1))} ${unit}`\n}\nfunction acceptsFile(file: File, accept?: string) {\n  return (\n    !accept?.trim() ||\n    accept.split(\",\").some((part) => {\n      const rule = part.trim().toLowerCase()\n      if (!rule) return false\n      if (rule.startsWith(\".\")) return file.name.toLowerCase().endsWith(rule)\n      return rule.endsWith(\"/*\")\n        ? file.type.toLowerCase().startsWith(rule.slice(0, -1))\n        : file.type.toLowerCase() === rule\n    })\n  )\n}\nfunction validateFiles(\n  files: File[],\n  accept: string | undefined,\n  maxSize: number,\n  available: number,\n) {\n  const accepted: FileUploadItem[] = []\n  const rejected: FileUploadRejection[] = []\n  for (const file of files) {\n    const reason = !acceptsFile(file, accept)\n      ? \"type\"\n      : file.size > maxSize\n        ? \"size\"\n        : accepted.length >= available\n          ? \"count\"\n          : null\n    if (reason) rejected.push({ file, reason })\n    else accepted.push({ id: createId(), file, status: \"pending\" })\n  }\n  return { accepted, rejected }\n}\nlet seed = 0\n/** 局域网 http 页面（非安全上下文）没有 crypto.randomUUID，用自增序号兜底 */\nfunction createId() {\n  return typeof crypto !== \"undefined\" && \"randomUUID\" in crypto\n    ? crypto.randomUUID()\n    : `file-${Date.now().toString(36)}-${++seed}`\n}\n\nconst MIME_NAMES: Record<string, string> = {\n  \"image/*\": \"图片\",\n  \"video/*\": \"视频\",\n  \"audio/*\": \"音频\",\n  \"application/pdf\": \"PDF\",\n}\n\n/** 把 accept 转成给人看的格式说明：`image/*,.pdf` → 「图片、PDF」 */\nfunction describeAccept(accept: string) {\n  const names = accept\n    .split(\",\")\n    .map((part) => part.trim().toLowerCase())\n    .filter(Boolean)\n    .map(\n      (rule) =>\n        MIME_NAMES[rule] ??\n        (rule.startsWith(\".\")\n          ? rule.slice(1).toUpperCase()\n          : (rule.split(\"/\")[1] ?? rule).toUpperCase()),\n    )\n  return [...new Set(names)].join(\"、\")\n}\n\n/** 提交时显式追加文件，不修改原生 file input。 */\nfunction appendFiles(\n  formData: FormData,\n  name: string,\n  items: FileUploadItem[],\n) {\n  for (const item of items) formData.append(name, item.file, item.file.name)\n}\nfunction interpolate(\n  template: string,\n  values: Record<string, string | number>,\n) {\n  return template.replace(/\\{(\\w+)\\}/g, (match, key: string) =>\n    String(values[key] ?? match),\n  )\n}\nexport {\n  appendFiles,\n  acceptsFile,\n  describeAccept,\n  formatFileSize,\n  validateFiles,\n  interpolate,\n}\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/utils.ts"
    },
    {
      "path": "src/registry/hooks/use-controllable-state.ts",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\ntype UseControllableStateParams<T> = {\n  /** 受控值；传入后组件不再维护内部状态 */\n  prop?: T\n  /** 非受控时的初始值 */\n  defaultProp: T\n  /** 值变化时回调（受控与非受控都会触发） */\n  onChange?: (value: T) => void\n}\n\n/**\n * 同时支持受控与非受控的状态 hook。\n * 约定见 component-conventions 技能第 4 节：open/defaultOpen/onOpenChange 等成对 props 都用它实现。\n */\nfunction useControllableState<T>({\n  prop,\n  defaultProp,\n  onChange,\n}: UseControllableStateParams<T>) {\n  const [uncontrolled, setUncontrolled] = React.useState(defaultProp)\n  const isControlled = prop !== undefined\n  const value = isControlled ? prop : uncontrolled\n\n  // 用 ref 保存最新的回调和值，保证 setValue 引用稳定\n  const onChangeRef = React.useRef(onChange)\n  const valueRef = React.useRef(value)\n  React.useEffect(() => {\n    onChangeRef.current = onChange\n    valueRef.current = value\n  })\n\n  const setValue = React.useCallback(\n    (next: React.SetStateAction<T>) => {\n      const resolved =\n        typeof next === \"function\"\n          ? (next as (prev: T) => T)(valueRef.current)\n          : next\n      if (Object.is(resolved, valueRef.current)) return\n      valueRef.current = resolved\n      if (!isControlled) setUncontrolled(resolved)\n      onChangeRef.current?.(resolved)\n    },\n    [isControlled],\n  )\n\n  return [value, setValue] as const\n}\n\nexport { useControllableState }\n",
      "type": "registry:ui",
      "target": "hooks/use-controllable-state.ts"
    },
    {
      "path": "src/registry/ui/file-upload/file-upload-camera.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { Dialog } from \"@base-ui/react/dialog\"\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport { FileUploadPreview, IdCardFrontIllustration } from \"./file-upload-parts\"\nimport type { FileUploadLabels } from \"./config\"\n\n// 相机实时画面需固定黑白对比。\n// registry-lint-disable-next-line no-hardcoded-color\nconst cameraClass =\n  \"fixed inset-0 z-50 flex flex-col items-center justify-center overflow-hidden bg-black text-white outline-none\"\n// registry-lint-disable-next-line no-hardcoded-color\nconst actionsClass =\n  \"absolute inset-x-0 bottom-0 z-10 grid grid-cols-[1fr_auto_1fr] items-center gap-3 bg-black/60 px-4 pt-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]\"\n// registry-lint-disable-next-line no-hardcoded-color\nconst ghostClass = \"text-white hover:bg-white/10 hover:text-white\"\n// registry-lint-disable-next-line no-hardcoded-color\nconst outlineClass =\n  \"border-white/30 bg-transparent text-white hover:bg-white/10 hover:text-white\"\n// registry-lint-disable-next-line no-hardcoded-color\nconst primaryClass = \"bg-white text-black hover:bg-white/90\"\n// registry-lint-disable-next-line no-hardcoded-color\nconst shutterClass =\n  \"grid size-16 place-items-center rounded-full border-4 border-white outline-none focus-visible:ring-3 focus-visible:ring-white/60 disabled:opacity-50 [&>span]:transition-transform active:[&>span]:scale-90 motion-reduce:[&>span]:transition-none\"\n// registry-lint-disable-next-line no-hardcoded-color\nconst shutterDotClass = \"size-12 rounded-full bg-white\"\nfunction FileUploadCamera({\n  label,\n  labels,\n  placeholder,\n  onClose,\n  onSelect,\n  onAlbum,\n  onError,\n}: {\n  label: string\n  labels: FileUploadLabels\n  placeholder?: React.ReactNode\n  onClose: () => void\n  onSelect: (file: File) => void\n  onAlbum: () => void\n  onError: () => void\n}) {\n  const video = React.useRef<HTMLVideoElement>(null)\n  const frame = React.useRef<HTMLDivElement>(null)\n  const [photo, setPhoto] = React.useState<File | null>(null)\n  const [ready, setReady] = React.useState(false)\n  const fail = React.useEffectEvent(onError)\n  const close = React.useEffectEvent(onClose)\n  React.useEffect(() => {\n    if (photo) return\n    let disposed = false\n    let stream: MediaStream | undefined\n    navigator.mediaDevices\n      .getUserMedia({ video: { facingMode: \"environment\" }, audio: false })\n      .then(async (next) => {\n        if (disposed) {\n          for (const track of next.getTracks()) track.stop()\n          return\n        }\n        stream = next\n        if (video.current) {\n          video.current.srcObject = next\n          await video.current.play()\n          setReady(true)\n        }\n      })\n      .catch(() => {\n        if (!disposed) fail()\n      })\n    function hidden() {\n      if (document.hidden) close()\n    }\n    document.addEventListener(\"visibilitychange\", hidden)\n    return () => {\n      disposed = true\n      for (const track of stream?.getTracks() ?? []) track.stop()\n      document.removeEventListener(\"visibilitychange\", hidden)\n    }\n  }, [photo])\n  async function shoot() {\n    const source = video.current\n    const target = frame.current\n    if (!source || !target || !source.videoWidth) return\n    try {\n      const view = source.getBoundingClientRect()\n      const crop = target.getBoundingClientRect()\n      const scale = Math.max(\n        view.width / source.videoWidth,\n        view.height / source.videoHeight,\n      )\n      const x =\n        (crop.left - view.left + (source.videoWidth * scale - view.width) / 2) /\n        scale\n      const y =\n        (crop.top - view.top + (source.videoHeight * scale - view.height) / 2) /\n        scale\n      const canvas = document.createElement(\"canvas\")\n      canvas.width = Math.round(crop.width / scale)\n      canvas.height = Math.round(crop.height / scale)\n      const context = canvas.getContext(\"2d\")\n      if (!context) throw new Error()\n      context.drawImage(\n        source,\n        x,\n        y,\n        crop.width / scale,\n        crop.height / scale,\n        0,\n        0,\n        canvas.width,\n        canvas.height,\n      )\n      const blob = await new Promise<Blob | null>((resolve) =>\n        canvas.toBlob(resolve, \"image/jpeg\", 0.9),\n      )\n      if (!blob) throw new Error()\n      setReady(false)\n      setPhoto(new File([blob], `${label}.jpg`, { type: \"image/jpeg\" }))\n    } catch {\n      onError()\n    }\n  }\n  return (\n    <Dialog.Root\n      open\n      onOpenChange={(open) => {\n        if (!open) onClose()\n      }}\n    >\n      <Dialog.Portal>\n        <Dialog.Backdrop className=\"fixed inset-0 z-50 bg-background\" />\n        {/* 相机画面需要固定黑白对比，避免主题色在实时画面上不可读。 */}\n        <Dialog.Popup\n          data-slot=\"file-upload-camera-dialog\"\n          className={cameraClass}\n        >\n          {!photo && (\n            <video\n              ref={video}\n              muted\n              playsInline\n              className=\"absolute inset-0 size-full object-cover\"\n            />\n          )}\n          <div className=\"relative z-20 mb-6 w-[86%] text-center\">\n            <Dialog.Title className=\"text-base font-medium\">\n              {photo\n                ? labels.cameraConfirm\n                : labels.cameraAlign.replace(\"{label}\", label)}\n            </Dialog.Title>\n            <Dialog.Description className=\"mt-2 text-sm\">\n              {labels.cameraLight}\n            </Dialog.Description>\n          </div>\n          <div\n            ref={frame}\n            className=\"relative z-10 aspect-[85.6/54] w-[86%] rounded-xl\"\n          >\n            {photo ? (\n              <FileUploadPreview file={photo} className=\"rounded-xl\" />\n            ) : (\n              <>\n                <div className=\"absolute inset-0 rounded-xl shadow-[0_0_0_100vmax_rgb(0_0_0/0.6)]\" />\n                <div className=\"absolute inset-0 overflow-hidden rounded-xl opacity-30 [&>svg]:size-full [&>svg]:text-inherit\">\n                  {placeholder ?? <IdCardFrontIllustration />}\n                </div>\n                {[\n                  \"top-0 left-0 border-t-4 border-l-4 rounded-tl-xl\",\n                  \"top-0 right-0 border-t-4 border-r-4 rounded-tr-xl\",\n                  \"bottom-0 left-0 border-b-4 border-l-4 rounded-bl-xl\",\n                  \"bottom-0 right-0 border-b-4 border-r-4 rounded-br-xl\",\n                ].map((corner) => (\n                  <span\n                    key={corner}\n                    className={`absolute size-7 border-current ${corner}`}\n                  />\n                ))}\n              </>\n            )}\n          </div>\n          <div className={actionsClass}>\n            {photo ? (\n              // 确认页两个按钮等宽并排，占满三列\n              <div className=\"col-span-3 flex gap-3\">\n                <Button\n                  size=\"lg\"\n                  variant=\"outline\"\n                  className={cn(\"flex-1\", outlineClass)}\n                  onClick={() => setPhoto(null)}\n                >\n                  {labels.replace}\n                </Button>\n                <Button\n                  size=\"lg\"\n                  className={cn(\"flex-1\", primaryClass)}\n                  onClick={() => onSelect(photo)}\n                >\n                  {labels.cameraUse}\n                </Button>\n              </div>\n            ) : (\n              <>\n                <Button\n                  size=\"lg\"\n                  variant=\"ghost\"\n                  className={cn(\"justify-self-start\", ghostClass)}\n                  onClick={onClose}\n                >\n                  {labels.cameraCancel}\n                </Button>\n                {/* 快门是相机界面的专用控件，沿用系统相机的 64px 圆形外观，不走 Button 的三档高度 */}\n                <button\n                  type=\"button\"\n                  data-slot=\"file-upload-camera-shutter\"\n                  aria-label={labels.cameraShoot}\n                  disabled={!ready}\n                  onClick={() => void shoot()}\n                  className={shutterClass}\n                >\n                  <span aria-hidden=\"true\" className={shutterDotClass} />\n                </button>\n                <Button\n                  size=\"lg\"\n                  variant=\"ghost\"\n                  className={cn(\"justify-self-end\", ghostClass)}\n                  onClick={onAlbum}\n                >\n                  {labels.album}\n                </Button>\n              </>\n            )}\n          </div>\n        </Dialog.Popup>\n      </Dialog.Portal>\n    </Dialog.Root>\n  )\n}\nexport { FileUploadCamera }\n",
      "type": "registry:ui",
      "target": "@ui/file-upload/file-upload-camera.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-edge": "var(--edge)"
    },
    "light": {
      "edge": "color-mix(in oklch, var(--foreground) 12%, transparent)"
    },
    "dark": {
      "edge": "color-mix(in oklch, var(--foreground) 18%, transparent)"
    }
  },
  "type": "registry:ui"
}
