From fd08d7a552426f7c4c56089a980f1e3ceb1edc27 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sat, 11 Apr 2026 08:00:21 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20add=20FileInput=20primitive=20=E2=80=94?= =?UTF-8?q?=20drag-and-drop=20file=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashed-border dropzone with icon, filename display, clear button, and imperative handle (file/clear). Replaces native file inputs. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../primitives/FileInput/FileInput.module.css | 64 ++++++++++++ .../primitives/FileInput/FileInput.tsx | 99 +++++++++++++++++++ src/design-system/primitives/index.ts | 2 + 3 files changed, 165 insertions(+) create mode 100644 src/design-system/primitives/FileInput/FileInput.module.css create mode 100644 src/design-system/primitives/FileInput/FileInput.tsx diff --git a/src/design-system/primitives/FileInput/FileInput.module.css b/src/design-system/primitives/FileInput/FileInput.module.css new file mode 100644 index 0000000..52e78ba --- /dev/null +++ b/src/design-system/primitives/FileInput/FileInput.module.css @@ -0,0 +1,64 @@ +.wrap { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border: 1.5px dashed var(--border); + border-radius: var(--radius-sm); + background: var(--bg-inset); + cursor: pointer; + transition: border-color 0.15s, background 0.15s; +} + +.wrap:hover { + border-color: var(--text-muted); +} + +.dragOver { + border-color: var(--amber); + background: var(--amber-bg); +} + +.icon { + color: var(--text-faint); + flex-shrink: 0; + line-height: 0; +} + +.label { + font-family: var(--font-body); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.placeholder { + color: var(--text-faint); +} + +.fileName { + color: var(--text-primary); +} + +.clearBtn { + margin-left: auto; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border: none; + background: none; + color: var(--text-muted); + cursor: pointer; + border-radius: var(--radius-sm); + padding: 0; + transition: color 0.1s, background 0.1s; +} + +.clearBtn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} diff --git a/src/design-system/primitives/FileInput/FileInput.tsx b/src/design-system/primitives/FileInput/FileInput.tsx new file mode 100644 index 0000000..ac4ec41 --- /dev/null +++ b/src/design-system/primitives/FileInput/FileInput.tsx @@ -0,0 +1,99 @@ +import styles from './FileInput.module.css' +import { forwardRef, useRef, useState, useImperativeHandle, type ReactNode } from 'react' +import { X } from 'lucide-react' + +export interface FileInputProps { + /** File type filter, e.g. ".pem,.crt,.cer" */ + accept?: string + /** Icon rendered before the label */ + icon?: ReactNode + /** Placeholder text when no file is selected */ + placeholder?: string + /** Additional CSS class */ + className?: string + /** Called when a file is selected or cleared */ + onChange?: (file: File | null) => void +} + +export interface FileInputHandle { + /** The currently selected File, or null */ + file: File | null + /** Programmatically clear the selection */ + clear: () => void +} + +export const FileInput = forwardRef( + ({ accept, icon, placeholder = 'Drop file or click to browse', className, onChange }, ref) => { + const inputRef = useRef(null) + const [fileName, setFileName] = useState(null) + const [dragOver, setDragOver] = useState(false) + const fileRef = useRef(null) + + function select(file: File | null) { + fileRef.current = file + setFileName(file?.name ?? null) + onChange?.(file) + } + + function handleInputChange() { + select(inputRef.current?.files?.[0] ?? null) + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault() + setDragOver(false) + const file = e.dataTransfer.files[0] + if (file && inputRef.current) { + const dt = new DataTransfer() + dt.items.add(file) + inputRef.current.files = dt.files + select(file) + } + } + + function handleClear(e: React.MouseEvent) { + e.stopPropagation() + if (inputRef.current) inputRef.current.value = '' + select(null) + } + + useImperativeHandle(ref, () => ({ + get file() { return fileRef.current }, + clear() { + if (inputRef.current) inputRef.current.value = '' + select(null) + }, + })) + + const wrapClass = [styles.wrap, dragOver && styles.dragOver, className].filter(Boolean).join(' ') + + return ( +
inputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setDragOver(true) }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + > + {icon && {icon}} + + {fileName ?? placeholder} + + {fileName && ( + + )} + +
+ ) + }, +) + +FileInput.displayName = 'FileInput' diff --git a/src/design-system/primitives/index.ts b/src/design-system/primitives/index.ts index 06cdb0b..caf6687 100644 --- a/src/design-system/primitives/index.ts +++ b/src/design-system/primitives/index.ts @@ -11,6 +11,8 @@ export { Collapsible } from './Collapsible/Collapsible' export { DateRangePicker } from './DateRangePicker/DateRangePicker' export { DateTimePicker } from './DateTimePicker/DateTimePicker' export { EmptyState } from './EmptyState/EmptyState' +export { FileInput } from './FileInput/FileInput' +export type { FileInputProps, FileInputHandle } from './FileInput/FileInput' export { FilterPill } from './FilterPill/FilterPill' export { FormField } from './FormField/FormField' export { InfoCallout } from './InfoCallout/InfoCallout'