astralyx-ui 0.6.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astralyx-ui",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "309 accessible React components you copy into your repo, with a CLI and registry that resolve what each one needs.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -51,10 +51,11 @@
|
|
|
51
51
|
"sideEffects": false,
|
|
52
52
|
"scripts": {
|
|
53
53
|
"dev": "vite",
|
|
54
|
-
"build": "node scripts/build-props.mjs && tsc -b && vite build && node scripts/build-seo.mjs",
|
|
54
|
+
"build": "node scripts/build-props.mjs && node scripts/check-api.mjs && tsc -b && vite build && node scripts/build-seo.mjs",
|
|
55
55
|
"lint": "oxlint",
|
|
56
56
|
"preview": "vite preview",
|
|
57
57
|
"build:props": "node scripts/build-props.mjs",
|
|
58
|
+
"check:api": "node scripts/check-api.mjs",
|
|
58
59
|
"build:registry": "node scripts/build-registry.mjs",
|
|
59
60
|
"build:cli": "tsc -p tsconfig.cli.json",
|
|
60
61
|
"build:package": "npm run build:props && npm run build:registry && npm run build:cli",
|
package/registry/index.json
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
{
|
|
20
20
|
"path": "components/ui/dropzone.tsx",
|
|
21
21
|
"type": "registry:ui",
|
|
22
|
-
"content": "import {\n useRef,\n useState,\n type ComponentProps,\n type DragEvent,\n type ReactNode,\n} from 'react'\nimport { CheckCircle2, CloudUpload } from 'lucide-react'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport {\n formatBytes,\n useUploads,\n type FileUpload,\n type UploadHandler,\n} from '@/lib/use-uploads'\nimport { disabledState, focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The upload card: click it or drag onto it, and it runs the upload.\n *\n * Drag and drop is a pointer-only affordance, so the card is a real `<button>`\n * wrapping a visually hidden `<input type=\"file\">` — keyboard and screen-reader\n * users get the file picker, everyone else can drag onto it. A `div` with a\n * drop handler and no keyboard path is the usual version of this component and\n * it is unusable without a mouse.\n *\n * `dragDepth` counts enter/leave rather than tracking a boolean: dragging over\n * a child fires `dragleave` on the parent, so a boolean flickers the highlight\n * off every time the pointer crosses the icon inside the card.\n *\n * The upload itself lives in `useUploads`, shared with `InputFile`. Hand this\n * component `onUpload` and it moves each file through queued → uploading → done\n * or error, reports progress, and keeps failures on screen with a retry. While\n * anything is in flight the card swaps to a spinner naming the file, so the\n * component that took the drop is also the one that says what happened to it.\n *\n * Validation runs on drop as well as on pick. The browser filters the file\n * picker by `accept`, but a dropped file never went through the picker — so\n * without a check here, dragging a `.mov` onto an image-only zone uploads it.\n */\ntype DropzoneProps = Omit<ComponentProps<'div'>, 'onChange' | 'onSelect'> & {\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n accept?: string\n multiple?: boolean\n disabled?: boolean\n /** Rejected before the request is made, in bytes. */\n maxSize?: number\n label?: ReactNode\n hint?: ReactNode\n /** Show the per-file rows under the card. */\n showList?: boolean\n /**\n * Force the busy state from outside.\n *\n * For the case where the request is yours — you are not using `onUpload`, you\n * are posting the files yourself and want the card to say so. `true` shows\n * the spinner and blocks further picking. Left undefined, the component uses\n * its own upload state.\n */\n isUploading?: boolean\n /** Names the file being sent. Receives the file name, or a count. */\n uploadingLabel?: (name: string) => ReactNode\n /** Shown once everything has landed. Receives how many. */\n doneLabel?: (count: number) => ReactNode\n maxSizeLabel?: (limit: string) => string\n acceptLabel?: (accept: string) => string\n}\n\nfunction Dropzone({\n onUpload,\n onSelect,\n onUploadsChange,\n accept,\n multiple = false,\n disabled = false,\n maxSize,\n label = 'Click to upload, or drag and drop',\n hint,\n showList = true,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n doneLabel = (count) => `${count} file${count === 1 ? '' : 's'} uploaded`,\n maxSizeLabel,\n acceptLabel,\n className,\n ...props\n}: DropzoneProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [dragDepth, setDragDepth] = useState(0)\n\n const { uploads, select, remove, retry, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n acceptLabel,\n onSelect,\n onUploadsChange,\n })\n\n // The prop wins when given, so a caller running its own request can drive the\n // card without adopting `onUpload`.\n const busy = isUploading ?? uploading\n const over = dragDepth > 0 && !disabled && !busy\n\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n const settled = done > 0 && !busy && !failed\n\n function onDrop(event: DragEvent<HTMLElement>) {\n event.preventDefault()\n setDragDepth(0)\n if (disabled || busy) return\n select([...event.dataTransfer.files])\n }\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div data-slot=\"dropzone\" className={cn('flex w-full flex-col gap-2', className)} {...props}>\n <button\n type=\"button\"\n disabled={disabled || busy}\n data-dragging={over}\n data-busy={busy}\n onClick={() => inputRef.current?.click()}\n onDragEnter={(event) => {\n event.preventDefault()\n setDragDepth((depth) => depth + 1)\n }}\n onDragLeave={() => setDragDepth((depth) => Math.max(0, depth - 1))}\n onDragOver={(event) => event.preventDefault()}\n onDrop={onDrop}\n className={cn(\n 'flex w-full flex-col items-center justify-center gap-2 border border-dashed px-6 py-9 text-center',\n radius.surface,\n interactive,\n focusRing,\n disabledState,\n // Busy is not disabled-looking: the card is still the thing telling\n // you what is happening, so it keeps full contrast.\n busy && 'opacity-100',\n over\n ? 'border-primary bg-accent text-foreground'\n : failed\n ? 'border-destructive text-muted-foreground'\n : 'border-border text-muted-foreground hover:bg-accent/40 hover:text-foreground',\n )}\n >\n {busy ? (\n <>\n <Spinner size=\"sm\" label=\"Uploading\" />\n <span className=\"text-foreground max-w-full truncate text-sm font-medium\">\n {uploadingLabel(\n inFlight\n ? inFlight.name\n : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )}\n </span>\n </>\n ) : settled ? (\n <>\n <CheckCircle2\n className=\"size-5 shrink-0 text-[var(--green-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n <span className=\"text-foreground text-sm font-medium\">{doneLabel(done)}</span>\n <span className=\"text-muted-foreground text-xs\">{label}</span>\n </>\n ) : (\n <>\n <CloudUpload className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-foreground text-sm font-medium\">{label}</span>\n {hint ? (\n <span className=\"text-muted-foreground text-xs\">{hint}</span>\n ) : (\n (accept || maxSize !== undefined) && (\n <span className=\"text-muted-foreground text-xs\">\n {[accept, maxSize !== undefined ? `up to ${formatBytes(maxSize)}` : null]\n .filter(Boolean)\n .join(' · ')}\n </span>\n )\n )}\n </>\n )}\n </button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n // The button above is the control; this stays out of the tab order so\n // there is one stop, not two.\n tabIndex={-1}\n aria-hidden=\"true\"\n className=\"sr-only\"\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n />\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { Dropzone }\nexport type { DropzoneProps }\n"
|
|
22
|
+
"content": "import {\n useRef,\n useState,\n type ComponentProps,\n type DragEvent,\n type ReactNode,\n} from 'react'\nimport { CheckCircle2, CloudUpload } from 'lucide-react'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport {\n formatBytes,\n useUploads,\n type FileUpload,\n type UploadHandler,\n} from '@/lib/use-uploads'\nimport { disabledState, focusRing, interactive, radius } from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * The upload card: click it or drag onto it, and it runs the upload.\n *\n * Drag and drop is a pointer-only affordance, so the card is a real `<button>`\n * wrapping a visually hidden `<input type=\"file\">` — keyboard and screen-reader\n * users get the file picker, everyone else can drag onto it. A `div` with a\n * drop handler and no keyboard path is the usual version of this component and\n * it is unusable without a mouse.\n *\n * `dragDepth` counts enter/leave rather than tracking a boolean: dragging over\n * a child fires `dragleave` on the parent, so a boolean flickers the highlight\n * off every time the pointer crosses the icon inside the card.\n *\n * The upload itself lives in `useUploads`, shared with `InputFile`. Hand this\n * component `onUpload` and it moves each file through queued → uploading → done\n * or error, reports progress, and keeps failures on screen with a retry. While\n * anything is in flight the card swaps to a spinner naming the file, so the\n * component that took the drop is also the one that says what happened to it.\n *\n * Validation runs on drop as well as on pick. The browser filters the file\n * picker by `accept`, but a dropped file never went through the picker — so\n * without a check here, dragging a `.mov` onto an image-only zone uploads it.\n */\ntype DropzoneProps = Omit<ComponentProps<'div'>, 'onChange' | 'onSelect'> & {\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n accept?: string\n multiple?: boolean\n disabled?: boolean\n /** Rejected before the request is made, in bytes. */\n maxSize?: number\n label?: ReactNode\n hint?: ReactNode\n /** Show the per-file rows under the card. */\n showList?: boolean\n /**\n * Force the busy state from outside.\n *\n * For the case where the request is yours — you are not using `onUpload`, you\n * are posting the files yourself and want the card to say so. `true` shows\n * the spinner and blocks further picking. Left undefined, the component uses\n * its own upload state.\n */\n isUploading?: boolean\n /** Names the file being sent. Receives the file name, or a count. */\n uploadingLabel?: (name: string) => ReactNode\n /** Shown once everything has landed. Receives how many. */\n doneLabel?: (count: number) => ReactNode\n maxSizeLabel?: (limit: string) => string\n acceptLabel?: (accept: string) => string\n}\n\nfunction Dropzone({\n onUpload,\n onSelect,\n onUploadsChange,\n accept,\n multiple = false,\n disabled = false,\n maxSize,\n label = 'Click to upload, or drag and drop',\n hint,\n showList = true,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n doneLabel = (count) => `${count} file${count === 1 ? '' : 's'} uploaded`,\n maxSizeLabel,\n acceptLabel,\n className,\n ...props\n}: DropzoneProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const [dragDepth, setDragDepth] = useState(0)\n\n const { uploads, select, remove, retry, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n acceptLabel,\n onSelect,\n onUploadsChange,\n })\n\n // The prop wins when given, so a caller running its own request can drive the\n // card without adopting `onUpload`.\n const busy = isUploading ?? uploading\n const over = dragDepth > 0 && !disabled && !busy\n\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n const settled = done > 0 && !busy && !failed\n\n function onDrop(event: DragEvent<HTMLElement>) {\n event.preventDefault()\n setDragDepth(0)\n if (disabled || busy) return\n select([...event.dataTransfer.files])\n }\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div data-slot=\"dropzone\" className={cn('flex w-full flex-col gap-2', className)} {...props}>\n <button\n type=\"button\"\n disabled={disabled || busy}\n data-dragging={over}\n data-busy={busy}\n onClick={() => inputRef.current?.click()}\n onDragEnter={(event) => {\n event.preventDefault()\n setDragDepth((depth) => depth + 1)\n }}\n onDragLeave={() => setDragDepth((depth) => Math.max(0, depth - 1))}\n onDragOver={(event) => event.preventDefault()}\n onDrop={onDrop}\n className={cn(\n 'flex w-full flex-col items-center justify-center gap-2 border border-dashed px-6 py-9 text-center',\n radius.surface,\n interactive,\n focusRing,\n disabledState,\n // Busy is not disabled-looking: the card is still the thing telling\n // you what is happening, so it keeps full contrast.\n busy && 'opacity-100',\n over\n ? 'border-primary bg-accent text-foreground'\n : failed\n ? 'border-destructive text-muted-foreground'\n : 'border-border text-muted-foreground hover:bg-accent/40 hover:text-foreground',\n )}\n >\n {busy ? (\n <>\n <Spinner size=\"sm\" label=\"Uploading\" />\n <span className=\"text-foreground max-w-full truncate text-sm font-medium\">\n {uploadingLabel(\n inFlight\n ? inFlight.name\n : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )}\n </span>\n </>\n ) : settled ? (\n <>\n <CheckCircle2\n className=\"size-5 shrink-0 text-[var(--green-soft-foreground)]\"\n aria-hidden=\"true\"\n />\n <span className=\"text-foreground text-sm font-medium\">{doneLabel(done)}</span>\n <span className=\"text-muted-foreground text-xs\">{label}</span>\n </>\n ) : (\n <>\n <CloudUpload className=\"size-5 shrink-0\" aria-hidden=\"true\" />\n <span className=\"text-foreground text-sm font-medium\">{label}</span>\n {hint ? (\n <span className=\"text-muted-foreground text-xs\">{hint}</span>\n ) : (\n (accept || maxSize !== undefined) && (\n <span className=\"text-muted-foreground text-xs\">\n {[accept, maxSize !== undefined ? `up to ${formatBytes(maxSize)}` : null]\n .filter(Boolean)\n .join(' · ')}\n </span>\n )\n )}\n </>\n )}\n </button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n // The button above is the control; this stays out of the tab order so\n // there is one stop, not two.\n tabIndex={-1}\n aria-hidden=\"true\"\n className=\"sr-only\"\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n />\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { Dropzone }\nexport type { DropzoneProps }\nexport type { FileUpload, UploadControl, UploadHandler } from '@/lib/use-uploads'\n"
|
|
23
23
|
}
|
|
24
24
|
]
|
|
25
25
|
}
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
{
|
|
21
21
|
"path": "components/ui/input-file.tsx",
|
|
22
22
|
"type": "registry:ui",
|
|
23
|
-
"content": "import { useId, useRef, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Paperclip, Upload } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport { useUploads, type FileUpload, type UploadHandler } from '@/lib/use-uploads'\nimport {\n disabledState,\n fieldBase,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A file picker shaped like an Input, which also runs the upload.\n *\n * The counterpart to `Dropzone`, not a replacement: this is the control you put\n * on a settings form beside a Label, where a full drop card would be absurd.\n * Dropzone is the better default for a standalone uploader — dropping is the\n * primary gesture there, and it has room to say what is happening.\n *\n * The native control is replaced rather than restyled. `input[type=file]` gives\n * you a UA button whose text is not settable and whose layout is not reachable\n * except through a vendor pseudo-element, so matching the kit's field metrics\n * means driving a hidden input from our own trigger.\n *\n * The upload lives in `useUploads`, shared with Dropzone, so the two shapes\n * cannot drift in behaviour.\n */\ntype InputFileProps = Omit<\n ComponentProps<'input'>,\n 'size' | 'type' | 'value' | 'onChange' | 'onSelect'\n> & {\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n /** Text shown when nothing is picked. */\n placeholder?: string\n buttonLabel?: ReactNode\n clearLabel?: ReactNode\n /** Show the per-file rows under the field. */\n showList?: boolean\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n /** Reject anything larger, in bytes, before the upload starts. */\n maxSize?: number\n maxSizeLabel?: (limit: string) => string\n /** Force the busy state from outside, when the request is yours. */\n isUploading?: boolean\n /** Names the file being sent. */\n uploadingLabel?: (name: string) => string\n}\n\nconst VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent hover:bg-accent',\n} as const\n\n/**\n * The trigger, sized from the field rather than from the button scale.\n *\n * Two rules, both derived from the box it sits in: it is 4px shorter than the\n * field, so the inset above and below matches the trailing one; and its radius\n * is half its own height, which is the rule every control in the kit follows.\n * `xs` is the exception the field itself makes — that size is a true pill\n * (`rounded-full [corner-shape:round]`), so its trigger is one too.\n *\n * Mapping straight onto `controlSize` is what produced the original bug: sizes\n * `xs` and `sm` both took the `xs` button, which is a pill by design, and a\n * lozenge inside a 16px-radius field reads as a mistake.\n */\nconst TRIGGER = {\n xs: { size: 'sm', className: 'h-6 px-2.5 rounded-full [corner-shape:round]' },\n sm: { size: 'sm', className: 'h-7 px-3 rounded-[14px]' },\n md: { size: 'sm', className: '' },\n lg: { size: 'default', className: '' },\n xl: { size: 'default', className: 'h-11 px-5 rounded-[22px]' },\n} as const\n\nfunction InputFile({\n size = 'md',\n variant = 'default',\n error = false,\n placeholder = 'No file selected',\n buttonLabel = 'Browse',\n clearLabel = 'Clear',\n showList = true,\n multiple = false,\n disabled = false,\n maxSize,\n maxSizeLabel,\n accept,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n className,\n onUpload,\n onSelect,\n onUploadsChange,\n ...props\n}: InputFileProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const id = useId()\n\n const { uploads, select, remove, retry, clear, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n onSelect,\n onUploadsChange,\n })\n\n const busy = isUploading ?? uploading\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n\n let summary: ReactNode = placeholder\n if (busy) {\n summary = uploadingLabel(\n inFlight ? inFlight.name : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )\n } else if (uploads.length === 1) summary = uploads[0].name\n else if (uploads.length > 1) summary = `${uploads.length} files`\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div className=\"flex w-full flex-col gap-2\">\n <div\n data-slot=\"input-file\"\n data-busy={busy}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT[variant],\n (error || failed) && 'border-destructive',\n // The trailing inset belongs to text, not to a button. Every size\n // leaves 2px above and below the trigger, so the trailing side\n // matches it — the field's own text inset left a gap three times\n // that, and the button read as floating off the edge.\n 'pe-0.5',\n // The wrapper is the focus surface: focus lands on the hidden input,\n // so the ring is drawn from `focus-within` on the box around it.\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n {busy ? (\n <Spinner size=\"sm\" label=\"Uploading\" className=\"shrink-0\" />\n ) : done > 0 && !failed ? (\n <Check className=\"shrink-0 text-[var(--green-soft-foreground)]\" aria-hidden=\"true\" />\n ) : (\n <Paperclip className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n )}\n\n <span\n id={id}\n className={cn(\n 'min-w-0 flex-1 truncate text-start',\n uploads.length === 0 && !busy && 'text-muted-foreground/70',\n )}\n >\n {summary}\n </span>\n\n {uploads.length > 0 && !busy && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={TRIGGER[size].size}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => {\n if (inputRef.current) inputRef.current.value = ''\n clear()\n }}\n >\n {clearLabel}\n </Button>\n )}\n\n <Button\n type=\"button\"\n variant=\"secondary\"\n size={TRIGGER[size].size}\n disabled={busy}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => inputRef.current?.click()}\n >\n <Upload />\n {buttonLabel}\n </Button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n aria-labelledby={id}\n className={cn('sr-only', focusRing, disabledState, radius.control)}\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n {...props}\n />\n </div>\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { InputFile }\nexport type { InputFileProps }\n"
|
|
23
|
+
"content": "import { useId, useRef, type ComponentProps, type ReactNode } from 'react'\nimport { Check, Paperclip, Upload } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Spinner } from '@/components/ui/spinner'\nimport { UploadList } from '@/components/ui/upload-list'\nimport type { Attachment } from '@/components/ui/attachment-preview'\nimport { useUploads, type FileUpload, type UploadHandler } from '@/lib/use-uploads'\nimport {\n disabledState,\n fieldBase,\n fieldSize,\n focusRing,\n radius,\n} from '@/lib/styles'\nimport { cn } from '@/lib/utils'\n\n/**\n * A file picker shaped like an Input, which also runs the upload.\n *\n * The counterpart to `Dropzone`, not a replacement: this is the control you put\n * on a settings form beside a Label, where a full drop card would be absurd.\n * Dropzone is the better default for a standalone uploader — dropping is the\n * primary gesture there, and it has room to say what is happening.\n *\n * The native control is replaced rather than restyled. `input[type=file]` gives\n * you a UA button whose text is not settable and whose layout is not reachable\n * except through a vendor pseudo-element, so matching the kit's field metrics\n * means driving a hidden input from our own trigger.\n *\n * The upload lives in `useUploads`, shared with Dropzone, so the two shapes\n * cannot drift in behaviour.\n */\ntype InputFileProps = Omit<\n ComponentProps<'input'>,\n 'size' | 'type' | 'value' | 'onChange' | 'onSelect'\n> & {\n size?: keyof typeof fieldSize\n variant?: 'default' | 'secondary' | 'ghost'\n error?: boolean\n /** Text shown when nothing is picked. */\n placeholder?: string\n buttonLabel?: ReactNode\n clearLabel?: ReactNode\n /** Show the per-file rows under the field. */\n showList?: boolean\n /**\n * Runs the upload. Resolve to succeed — the value lands on `upload.result`;\n * throw to fail, and the message shows on the row with a retry.\n */\n onUpload?: UploadHandler\n /** Fires on selection, before any upload starts. */\n onSelect?: (uploads: FileUpload[]) => void\n /** Fires on every transition — start, progress, finish, failure, removal. */\n onUploadsChange?: (uploads: FileUpload[]) => void\n /** Reject anything larger, in bytes, before the upload starts. */\n maxSize?: number\n maxSizeLabel?: (limit: string) => string\n /** Force the busy state from outside, when the request is yours. */\n isUploading?: boolean\n /** Names the file being sent. */\n uploadingLabel?: (name: string) => string\n}\n\nconst VARIANT = {\n default: 'border-border bg-background border',\n secondary: 'bg-secondary border border-transparent',\n ghost: 'border border-transparent bg-transparent hover:bg-accent',\n} as const\n\n/**\n * The trigger, sized from the field rather than from the button scale.\n *\n * Two rules, both derived from the box it sits in: it is 4px shorter than the\n * field, so the inset above and below matches the trailing one; and its radius\n * is half its own height, which is the rule every control in the kit follows.\n * `xs` is the exception the field itself makes — that size is a true pill\n * (`rounded-full [corner-shape:round]`), so its trigger is one too.\n *\n * Mapping straight onto `controlSize` is what produced the original bug: sizes\n * `xs` and `sm` both took the `xs` button, which is a pill by design, and a\n * lozenge inside a 16px-radius field reads as a mistake.\n */\nconst TRIGGER = {\n xs: { size: 'sm', className: 'h-6 px-2.5 rounded-full [corner-shape:round]' },\n sm: { size: 'sm', className: 'h-7 px-3 rounded-[14px]' },\n md: { size: 'sm', className: '' },\n lg: { size: 'default', className: '' },\n xl: { size: 'default', className: 'h-11 px-5 rounded-[22px]' },\n} as const\n\nfunction InputFile({\n size = 'md',\n variant = 'default',\n error = false,\n placeholder = 'No file selected',\n buttonLabel = 'Browse',\n clearLabel = 'Clear',\n showList = true,\n multiple = false,\n disabled = false,\n maxSize,\n maxSizeLabel,\n accept,\n isUploading,\n uploadingLabel = (name) => `Uploading ${name}…`,\n className,\n onUpload,\n onSelect,\n onUploadsChange,\n ...props\n}: InputFileProps) {\n const inputRef = useRef<HTMLInputElement>(null)\n const id = useId()\n\n const { uploads, select, remove, retry, clear, uploading, failed, done } = useUploads({\n onUpload,\n multiple,\n maxSize,\n maxSizeLabel,\n accept,\n onSelect,\n onUploadsChange,\n })\n\n const busy = isUploading ?? uploading\n const inFlight = uploads.find((upload) => upload.status === 'uploading')\n\n let summary: ReactNode = placeholder\n if (busy) {\n summary = uploadingLabel(\n inFlight ? inFlight.name : `${uploads.length} file${uploads.length === 1 ? '' : 's'}`,\n )\n } else if (uploads.length === 1) summary = uploads[0].name\n else if (uploads.length > 1) summary = `${uploads.length} files`\n\n /** `UploadList` is presentational and takes `Attachment`, so map into it. */\n const attachments: Attachment[] = uploads.map((upload) => ({\n id: upload.id,\n name: upload.name,\n type: upload.type,\n size: upload.size,\n progress: upload.status === 'done' ? undefined : upload.progress,\n error: upload.error,\n }))\n\n return (\n <div className=\"flex w-full flex-col gap-2\">\n <div\n data-slot=\"input-file\"\n data-busy={busy}\n className={cn(\n fieldBase,\n fieldSize[size],\n VARIANT[variant],\n (error || failed) && 'border-destructive',\n // The trailing inset belongs to text, not to a button. Every size\n // leaves 2px above and below the trigger, so the trailing side\n // matches it — the field's own text inset left a gap three times\n // that, and the button read as floating off the edge.\n 'pe-0.5',\n // The wrapper is the focus surface: focus lands on the hidden input,\n // so the ring is drawn from `focus-within` on the box around it.\n 'focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]',\n disabled && 'pointer-events-none opacity-50',\n className,\n )}\n >\n {busy ? (\n <Spinner size=\"sm\" label=\"Uploading\" className=\"shrink-0\" />\n ) : done > 0 && !failed ? (\n <Check className=\"shrink-0 text-[var(--green-soft-foreground)]\" aria-hidden=\"true\" />\n ) : (\n <Paperclip className=\"text-muted-foreground shrink-0\" aria-hidden=\"true\" />\n )}\n\n <span\n id={id}\n className={cn(\n 'min-w-0 flex-1 truncate text-start',\n uploads.length === 0 && !busy && 'text-muted-foreground/70',\n )}\n >\n {summary}\n </span>\n\n {uploads.length > 0 && !busy && (\n <Button\n type=\"button\"\n variant=\"ghost\"\n size={TRIGGER[size].size}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => {\n if (inputRef.current) inputRef.current.value = ''\n clear()\n }}\n >\n {clearLabel}\n </Button>\n )}\n\n <Button\n type=\"button\"\n variant=\"secondary\"\n size={TRIGGER[size].size}\n disabled={busy}\n className={cn('shrink-0', TRIGGER[size].className)}\n onClick={() => inputRef.current?.click()}\n >\n <Upload />\n {buttonLabel}\n </Button>\n\n <input\n ref={inputRef}\n type=\"file\"\n accept={accept}\n multiple={multiple}\n disabled={disabled || busy}\n aria-labelledby={id}\n className={cn('sr-only', focusRing, disabledState, radius.control)}\n onChange={(event) => {\n select([...(event.target.files ?? [])])\n // Reset so picking the same file twice still fires a change.\n event.target.value = ''\n }}\n {...props}\n />\n </div>\n\n {showList && uploads.length > 0 && (\n <UploadList uploads={attachments} onRemove={remove} onRetry={retry} />\n )}\n </div>\n )\n}\n\nexport { InputFile }\nexport type { InputFileProps }\nexport type { FileUpload, UploadControl, UploadHandler } from '@/lib/use-uploads'\n"
|
|
24
24
|
}
|
|
25
25
|
]
|
|
26
26
|
}
|