CSV importNew
Contacts → Import, done properly: your file's columns thread themselves to your fields, bad cells become fields you fix in place, and the rows go to your server in batches.
Used forCRM and contactsProduct catalogues and inventoryOnboardingAdmin tools
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/csv-import.jsonWhere it’s used
CRM and contacts
Import contacts from a spreadsheet exported by another tool.
Product catalogues and inventory
Bring in thousands of products with their prices and stock.
Onboarding
Let new customers move their data in from the tool they used before.
Admin tools
Add many users, members or records at once.
What it handles for you
CSV imports usually fail at the end with “row 1,284 is invalid” and nothing to do about it. This reads the file in the browser, matches the columns, checks every row first and lets people fix problems before anything is sent.
- 01
Big files don’t freeze the page
The file is read in slices with a pause between them. 200,000 rows take about a quarter of a second.
- 02
Columns match themselves
Columns that match a field by name, ignoring case and punctuation, are already threaded. Carry a column onto a field, or use its dropdown, to change it.
- 03
Checked before anything is sent
Every row is checked first, and the counts read like “2,996 ready · 4 need a look”.
- 04
Fix problems in place
Rows with problems open with their bad cells as fields. Fix one and it folds away, or skip what is left.
- 05
Sent in batches
Rows go to your onImport function in batches with a progress bar. Cancel stops after the batch in flight.
- 06
Skipped rows come back as a CSV
Anything skipped or refused downloads as a file with the reason on each row.
Overview
CSV imports usually fail at the end, with "row 1,284 is invalid" and nothing to do about it. Here the file is read in the browser in slices, so even a big one never freezes the page. Its columns line up against your fields and the ones that match by name are already threaded together; carry a column onto a field to change it, or pick it from the field's own dropdown, which grows out of its pill. Before anything is sent, every row is checked: "2,996 ready · 4 need a look", and the rows with problems open with their bad cells as fields. Fix one and it says so and folds away while the counts roll, or skip what's left. Rows then go to onImport in batches, the bar filling and "1,250 of 2,996" rolling, and Cancel stops after the batch in flight. At the end a ring and a check draw, and anything skipped or refused downloads as a CSV with the reason on each row.
States
- drop
- Dragging a file over the target turns its edge blue and lifts the icon 4px with a slight overshoot; the line morphs to "Drop to read it". A file that isn't CSV, is empty or is over maxRows shakes the target and says why.
- read
- The file is parsed in 200,000-character slices with a pause between (200,000 rows take about a quarter of a second), the icon spinning. The separator is taken from the first line.
- match
- Steps slide in 28px from the right while the card eases to its height. Columns that match a field by name, ignoring case and punctuation ("E-mail address" → Email), are threaded on arrival; each thread draws itself in 520ms. Carrying a column trails a dashed thread and lights the field under the pointer. Each field's pill grows into the list of columns, a sample under every name; narrow cards show only the fields and their pills.
- review
- Every row is checked (required fields, then validate). The ready and need-a-look counts roll. Rows with problems show their bad cells as inputs with a red hairline and the reason; when a row is right its badge morphs to Fixed, and 650ms later it folds away.
- import
- Ready rows go to onImport in batches of batchSize. The bar fills by transform; the count rolls. Cancel aborts the signal and stops after the batch in flight. A batch that throws marks its rows as skipped with the error's message.
- done
- A ring draws in 640ms, then a check. If anything was skipped or refused, "Skipped rows" downloads them as CSV with a column saying why.
Usage
import { CsvImport } from "@/components/csv-import";<CsvImport noun={{ one: "contact", many: "contacts" }} fields={[ { key: "name", label: "Name", required: true }, { key: "email", label: "Email", required: true, match: ["e-mail"] }, ]} onImport={(rows, { signal }) => api.importContacts(rows, { signal })}/>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
Installs with it
- Text morphText that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.
- Number rollNumbers that change like an odometer: every digit rolls in the direction the number moved.
- Status buttonSave, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.
Batches into Postgres through a route
Each batch is one request; the route validates again on the server (never trust the browser), inserts what it can in one statement, and returns the rows it refused by their index in the batch. Those come back in the skipped file with your reason. With Supabase or D1, the route is a single bulk insert the same way.
// app/api/contacts/import/route.tsimport { z } from "zod";import { db } from "@/lib/db";const Row = z.object({ name: z.string().min(1), email: z.string().email(), company: z.string().optional(), plan: z.string().optional() });export async function POST(req: Request) { const user = await requireUser(req); const rows: unknown[] = await req.json(); const failed: { index: number; reason: string }[] = []; const good = rows.flatMap((r, index) => { const parsed = Row.safeParse(r); if (!parsed.success) { failed.push({ index, reason: parsed.error.issues[0].message }); return []; } return [{ ...parsed.data, workspaceId: user.workspaceId, index }]; }); const existing = await db.contact.findMany({ where: { workspaceId: user.workspaceId, email: { in: good.map((g) => g.email) } }, select: { email: true } }); const taken = new Set(existing.map((e) => e.email)); for (const g of good) if (taken.has(g.email)) failed.push({ index: g.index, reason: "Already a contact." }); await db.contact.createMany({ data: good.filter((g) => !taken.has(g.email)).map(({ index, ...c }) => c) }); return Response.json({ failed });}// app/contacts/import.tsx"use client";<CsvImport title="Import contacts" noun={{ one: "contact", many: "contacts" }} fields={fields} templateHref="/templates/contacts.csv" onImport={async (rows, { signal }) => { const res = await fetch("/api/contacts/import", { method: "POST", body: JSON.stringify(rows), signal }); if (!res.ok) throw new Error("The server didn't take this batch."); return res.json(); }} onDone={({ imported }) => imported && router.refresh()}/>Props
| Prop | Type | Description |
|---|---|---|
| fields | CsvField[] | { key, label, required?, match?, validate? }. match lists other column names to recognise; validate returns why a value won't do. |
| onImport | (rows, { signal, start }) => Promise<{ failed? } | void> | Saves one batch of rows shaped { [key]: value }. Return failed: [{ index, reason }] for rows your server refused; throw to mark the whole batch skipped. |
| batchSize | numberdefault 250 | Rows per onImport call. |
| maxRows | numberdefault 50000 | Files with more rows are refused before matching. |
| noun | { one, many }default { one: "row", many: "rows" } | What a row is, for the counts and buttons. |
| templateHref | string | A "Download a template" link under the drop target. |
| title | stringdefault "Import" | The card's heading. |
| description | string | The line under the heading. |
| onDone | ({ imported, skipped, cancelled }) => void | After the last batch, e.g. to refresh the list behind it. |
| ref | Ref<CsvImportHandle> | load(text, name?) reads text as if it were a dropped file, e.g. from a paste or a sample. |
Accessibility and motion
- The drop target is the label of a real file input: Tab reaches it, Enter or Space opens the picker, and its focus ring shows on the target.
- Matching works without a pointer: every field's pill opens a listbox (arrows, typing a name, Enter), and clicking a column then a field matches them too. Threads are drawn for the eye; screen readers hear the pills.
- Problem cells are real inputs marked aria-invalid with their reason under them. The import bar is a progressbar with the row counts as its values. Each step's result is announced in a status region.
- Nothing leaves the browser until onImport: parsing, matching and checking all happen on the page.
- Reduced motion swaps steps in place and draws threads and checks at once.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Text morph, Number roll, Status button.
Source
"use client";import * as React from "react";import { Download, FileSpreadsheet } from "lucide-react";import { CsvMapper } from "./csv-mapper";import { autoMatch, parseCsv, toCsv } from "./csv-parse";import { NumberRoll } from "./number-roll";import { StatusButton } from "./status-button";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * CSV IMPORT: Contacts → Import, done properly * * drop drop a file (the target lifts to meet it) or * choose one; it's read in slices with the row * count rolling, so a big file never freezes the page * match the file's columns line up against your fields; * the ones that match by name are threaded together * already. Carry a column onto a field to change it, * or use the field's select * review "2,996 ready · 4 need a look": the rows with * problems open with their bad cells as fields; * fix one and it ticks and folds away, the counts * rolling. Or skip what's left * import rows go to onImport in batches; the bar fills and * "1,250 of 2,996" rolls; Cancel stops after the * batch in flight * done a ring and a check draw; anything skipped or * refused downloads as a CSV with the reason per row * * The file never leaves the browser until onImport sends your * rows, already shaped as { [field]: value }. * ───────────────────────────────────────────────────────── */export interface CsvField { key: string; label: string; required?: boolean; /** Other names the column might have in a file, e.g. ["e-mail", "email address"]. */ match?: string[]; /** Return why a value won't do. */ validate?: (value: string, row: Record<string, string>) => string | null | undefined | void;}export interface CsvImportResult { /** Rows in this batch your server refused, by their index in the batch. */ failed?: { index: number; reason: string }[];}export interface CsvImportHandle { /** Reads text as if it were a dropped file. */ load: (text: string, name?: string) => void;}export interface CsvImportProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> { fields: CsvField[]; /** Saves one batch of rows. Throw to mark the whole batch as not imported. */ onImport: (rows: Record<string, string>[], options: { signal: AbortSignal; start: number }) => Promise<CsvImportResult | void>; /** Rows per onImport call. */ batchSize?: number; maxRows?: number; /** What a row is: { one: "contact", many: "contacts" }. */ noun?: { one: string; many: string }; /** A link to a template file to fill in. */ templateHref?: string; title?: string; description?: string; onDone?: (summary: { imported: number; skipped: number; cancelled: boolean }) => void; ref?: React.Ref<CsvImportHandle>;}type Step = "drop" | "map" | "review" | "import" | "done";type Skipped = { row: Record<string, string>; reason: string };const EASE = "cubic-bezier(0.16,1,0.3,1)";const THROW = "cubic-bezier(0.34,1.36,0.64,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const ORDER: Step[] = ["drop", "map", "review", "import", "done"];const SHOWN = 40;const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(reducedQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange);};const useReducedMotion = () => React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);const message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);const count = (n: number) => n.toLocaleString("en-GB");function Face({ out, enter, dir, reduced, onGone, children }: { out: boolean; enter: boolean; dir: number; reduced: boolean; onGone: () => void; children: React.ReactNode }) { const ref = React.useRef<HTMLDivElement>(null); React.useLayoutEffect(() => { const el = ref.current; if (!el) return; el.getAnimations().forEach((a) => a.cancel()); if (reduced) return; if (out) { const a = el.animate([{ opacity: 1, transform: "none", filter: "blur(0px)" }, { opacity: 0, transform: `translateX(${-dir * 28}px)`, filter: "blur(4px)" }], { duration: 240, easing: EASE, fill: "forwards" }); a.onfinish = onGone; return; } if (enter) el.animate([{ opacity: 0, transform: `translateX(${dir * 28}px)`, filter: "blur(4px)" }, { opacity: 1, transform: "none", filter: "blur(0px)" }], { duration: 440, delay: 80, easing: EASE, fill: "backwards" }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [out]); return ( <div ref={ref} inert={out} aria-hidden={out || undefined} className={out ? "pointer-events-none absolute inset-x-0 top-0" : ""}> {children} </div> );}/** Steps slide in from the side you went; the card eases to the new height. */function Slides({ step, dir, reduced, render }: { step: Step; dir: number; reduced: boolean; render: (s: Step) => React.ReactNode }) { const [faces, setFaces] = React.useState<{ s: Step; out: boolean; dir: number; enter: boolean }[]>([{ s: step, out: false, dir, enter: false }]); const [seen, setSeen] = React.useState(step); const boxRef = React.useRef<HTMLDivElement>(null); const height = React.useRef(0); // Clipped only while a step slides; at rest a menu can open past its edge. const [moving, setMoving] = React.useState(false); React.useEffect(() => { if (!moving) return; const timer = window.setTimeout(() => setMoving(false), 560); return () => window.clearTimeout(timer); }, [moving, step]); if (seen !== step) { setSeen(step); if (!reduced) setMoving(true); setFaces((f) => [...f.filter((x) => x.out && x.s !== step && x.s !== seen), ...(reduced ? [] : [{ s: seen, out: true, dir, enter: false }]), { s: step, out: false, dir, enter: true }]); } React.useLayoutEffect(() => { const el = boxRef.current; if (!el) return; height.current = el.offsetHeight; const observer = new ResizeObserver(() => (height.current = el.offsetHeight)); observer.observe(el); return () => observer.disconnect(); }, []); React.useLayoutEffect(() => { const el = boxRef.current; if (!el || reduced) return; const to = el.offsetHeight; if (Math.abs(to - height.current) > 0.5) el.animate([{ height: `${height.current}px` }, { height: `${to}px` }], { duration: 460, easing: EASE }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); return ( <div ref={boxRef} className="relative [overflow-clip-margin:8px]" style={{ overflowY: moving ? "clip" : "visible" }}> {faces.map((f) => ( <Face key={f.s} out={f.out} enter={f.enter} dir={f.dir} reduced={reduced} onGone={() => setFaces((all) => all.filter((x) => !(x.s === f.s && x.out)))}> {render(f.s)} </Face> ))} </div> );}function Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) { const kept = React.useRef(children); if (open) kept.current = children; return ( <div className="grid" inert={!open} aria-hidden={!open || undefined} style={{ gridTemplateRows: open ? "1fr" : "0fr", opacity: open ? 1 : 0, transition: reduced ? "none" : `grid-template-rows 420ms ${EASE}, opacity ${open ? 300 : 160}ms ${EASE}` }}> <div className="min-h-0 min-w-0 overflow-clip [overflow-clip-margin:6px]">{open ? children : kept.current}</div> </div> );}function Drawn({ reduced, className = "size-12", tone = "text-emerald-600 dark:text-emerald-400" }: { reduced: boolean; className?: string; tone?: string }) { const ring = React.useRef<SVGCircleElement>(null); const tick = React.useRef<SVGPathElement>(null); React.useLayoutEffect(() => { if (reduced) return; ring.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 640, delay: 120, easing: EASE, fill: "backwards" }); tick.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 420, delay: 560, easing: EASE, fill: "backwards" }); }, [reduced]); return ( <svg viewBox="0 0 48 48" fill="none" aria-hidden="true" className={`${className} ${tone}`}> <circle cx="24" cy="24" r="22.5" stroke="var(--border)" strokeWidth="1" /> <circle ref={ring} cx="24" cy="24" r="22.5" stroke="currentColor" strokeWidth="1.25" pathLength={1} strokeDasharray={1} strokeDashoffset={0} transform="rotate(-90 24 24)" /> <path ref={tick} d="M16.5 24.5 21.5 29.5 31.5 18.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray={1} strokeDashoffset={0} /> </svg> );}function Switch({ on, onToggle, label, reduced }: { on: boolean; onToggle: () => void; label: string; reduced: boolean }) { return ( <button type="button" role="switch" aria-checked={on} onClick={onToggle} className={`inline-flex cursor-pointer items-center gap-2.5 rounded-full py-1 pr-2 pl-1 text-left text-[12.5px] transition-colors hover:bg-accent/60 ${FOCUS}`}> <span className={`relative h-5 w-9 shrink-0 rounded-full transition-[background-color,box-shadow] duration-300 ${on ? "bg-primary" : "bg-muted shadow-[inset_0_0_0_1px_var(--border)]"}`}> <span className="absolute top-0.5 left-0.5 size-4 rounded-full bg-background shadow-[0_0_0_1px_var(--border)]" style={{ transform: on ? "translateX(16px)" : "none", transition: reduced ? "none" : `transform 420ms ${THROW}` }} /> </span> {label} </button> );}/** One row with problems: its bad cells as fields; once they're right it ticks and folds away. */function ProblemRow({ index, row, problems, fields, fixed, reduced, onEdit,}: { index: number; row: Record<string, string>; problems: Record<string, string>; fields: CsvField[]; fixed: boolean; reduced: boolean; onEdit: (index: number, key: string, value: string) => void;}) { const keys = Object.keys(problems); const [shownKeys] = React.useState(keys); // Fixed: it says so for a moment, then folds away. const [gone, setGone] = React.useState(false); React.useEffect(() => { if (!fixed) return setGone(false); const timer = window.setTimeout(() => setGone(true), 650); return () => window.clearTimeout(timer); }, [fixed]); const context = fields.filter((f) => !shownKeys.includes(f.key) && row[f.key]).slice(0, 2); return ( <Fold open={!gone} reduced={reduced}> <div className="mb-2 rounded-[12px] px-3 py-2.5 shadow-[inset_0_0_0_1px_var(--border)]"> <p className="flex items-center gap-2 text-[11.5px] text-muted-foreground"> <span className="font-mono">Row {count(index + 2)}</span> {context.length > 0 && <span className="min-w-0 truncate">· {context.map((f) => row[f.key]).join(" · ")}</span>} <span className={`ml-auto inline-flex shrink-0 items-center gap-1 transition-colors ${fixed ? "text-emerald-600 dark:text-emerald-400" : "text-amber-700 dark:text-amber-400"}`}> <TextMorph>{fixed ? "Fixed" : keys.length === 1 ? "1 problem" : `${keys.length} problems`}</TextMorph> </span> </p> <div className="mt-2 grid gap-2 @md:grid-cols-2"> {shownKeys.map((k) => { const f = fields.find((x) => x.key === k)!; const bad = problems[k]; return ( <label key={k} className="block min-w-0"> <span className="block text-[11.5px] font-medium">{f.label}</span> <input value={row[k] ?? ""} onChange={(e) => onEdit(index, k, e.target.value)} aria-invalid={Boolean(bad) || undefined} className={`mt-1 h-8 w-full rounded-[8px] bg-transparent px-2.5 text-[16px] text-foreground outline-none transition-shadow [@media(pointer:fine)]:text-[12.5px] ${ bad ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_45%,transparent)]" : "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-emerald-500)_45%,transparent)]" }`} /> <span className={`mt-1 block text-[11px] transition-colors ${bad ? "text-red-600 dark:text-red-400" : "text-emerald-600 dark:text-emerald-400"}`}>{bad ?? "Looks right"}</span> </label> ); })} </div> </div> </Fold> );}export function CsvImport({ fields, onImport, batchSize = 250, maxRows = 50_000, noun = { one: "row", many: "rows" }, templateHref, title = "Import", description = "Bring rows in from a spreadsheet. Nothing is saved until you import.", onDone, ref, className = "", ...props}: CsvImportProps) { const reduced = useReducedMotion(); const id = React.useId(); const [step, setStep] = React.useState<Step>("drop"); const [dir, setDir] = React.useState(1); const [fileName, setFileName] = React.useState(""); const [headers, setHeaders] = React.useState<string[]>([]); const [data, setData] = React.useState<string[][]>([]); const [mapping, setMapping] = React.useState<Record<string, number | null>>({}); const [reading, setReading] = React.useState<{ name: string } | null>(null); const [dropError, setDropError] = React.useState<string | null>(null); const [over, setOver] = React.useState(false); const [edits, setEdits] = React.useState<Record<number, Record<string, string>>>({}); const [fixing, setFixing] = React.useState<number[]>([]); const [skipBad, setSkipBad] = React.useState(true); const [progress, setProgress] = React.useState({ done: 0, total: 0 }); const [result, setResult] = React.useState<{ imported: number; skipped: Skipped[]; cancelled: boolean } | null>(null); const [announce, setAnnounce] = React.useState(""); const inputRef = React.useRef<HTMLInputElement>(null); const dropRef = React.useRef<HTMLLabelElement>(null); const ctrl = React.useRef<AbortController | null>(null); const many = noun.many; const go = (next: Step) => { setDir(ORDER.indexOf(next) >= ORDER.indexOf(step) ? 1 : -1); setStep(next); }; const read = async (text: string, name: string) => { setDropError(null); setReading({ name }); try { const rows = await parseCsv(text); const [head, ...body] = rows; if (!head || !body.length) throw new Error("That file has no rows under its header."); if (body.length > maxRows) throw new Error(`That file has ${count(body.length)} rows; up to ${count(maxRows)} can be imported at once.`); const width = head.length; setHeaders(head.map((h) => h.trim())); setData(body.map((r) => (r.length === width ? r : [...r, ...Array(Math.max(0, width - r.length)).fill("")].slice(0, width)))); setMapping(autoMatch(head, fields)); setEdits({}); setFixing([]); setFileName(name); setReading(null); go("map"); setAnnounce(`${name}: ${count(body.length)} ${body.length === 1 ? noun.one : many}, ${width} columns. Match them to your fields.`); } catch (e) { setReading(null); setDropError(message(e, "That file couldn't be read.")); if (!reduced) dropRef.current?.animate([{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }); } }; const take = async (file: File | undefined) => { if (!file) return; if (!/\.(csv|tsv|txt)$/i.test(file.name) && !/^text\//.test(file.type)) { setDropError(`“${file.name}” isn't a CSV file. Export your sheet as CSV first.`); return; } read(await file.text(), file.name); }; React.useImperativeHandle(ref, () => ({ load: (text, name = "pasted.csv") => read(text, name) })); const rowAt = React.useCallback( (i: number) => { const out: Record<string, string> = {}; for (const f of fields) { const col = mapping[f.key]; out[f.key] = edits[i]?.[f.key] ?? (col === null || col === undefined ? "" : (data[i]?.[col] ?? "").trim()); } return out; }, [fields, mapping, edits, data], ); const problemsOf = React.useCallback( (row: Record<string, string>) => { const out: Record<string, string> = {}; for (const f of fields) { const v = row[f.key]; if (f.required && !v) out[f.key] = `${f.label} is missing.`; else if (v && f.validate) { const why = f.validate(v, row); if (why) out[f.key] = why; } } return out; }, [fields], ); // Every row is checked once per mapping; an edit re-checks only its own row. const checking = step === "review" || step === "import"; const base = React.useMemo(() => { if (!checking) return []; return data.map((r) => { const row: Record<string, string> = {}; for (const f of fields) { const col = mapping[f.key]; row[f.key] = col === null || col === undefined ? "" : (r[col] ?? "").trim(); } return problemsOf(row); }); }, [checking, data, mapping, fields, problemsOf]); const checks = React.useMemo(() => { const next = base.slice(); for (const i of Object.keys(edits)) next[Number(i)] = problemsOf(rowAt(Number(i))); return next; }, [base, edits, problemsOf, rowAt]); const bad = React.useMemo(() => checks.map((p, i) => (Object.keys(p).length ? i : -1)).filter((i) => i >= 0), [checks]); const ready = data.length - bad.length; const missing = fields.filter((f) => f.required && (mapping[f.key] === null || mapping[f.key] === undefined)); const matched = fields.filter((f) => mapping[f.key] !== null && mapping[f.key] !== undefined).length; const samples = React.useMemo(() => headers.map((_, c) => data.slice(0, 6).map((r) => r[c] ?? "")), [headers, data]); const listed = [...new Set([...bad.slice(0, SHOWN), ...fixing])].sort((a, b) => a - b); const edit = (index: number, key: string, value: string) => { setEdits((e) => ({ ...e, [index]: { ...e[index], [key]: value } })); const row = { ...rowAt(index), [key]: value }; if (!Object.keys(problemsOf(row)).length && !fixing.includes(index)) { // Fixed: it stays to say so, then folds away. setFixing((f) => [...f, index]); window.setTimeout(() => setFixing((f) => f.filter((x) => x !== index)), 1400); } }; const run = async () => { const indices = data.map((_, i) => i).filter((i) => !bad.includes(i)); const rows = indices.map(rowAt); const skipped: Skipped[] = bad.map((i) => ({ row: rowAt(i), reason: Object.values(checks[i]).join(" ") })); const c = new AbortController(); ctrl.current = c; setProgress({ done: 0, total: rows.length }); go("import"); let imported = 0; for (let s = 0; s < rows.length; s += batchSize) { if (c.signal.aborted) break; const batch = rows.slice(s, s + batchSize); try { const r = await onImport(batch, { signal: c.signal, start: s }); const failed = (r ? r.failed : undefined) ?? []; failed.forEach((f) => batch[f.index] && skipped.push({ row: batch[f.index], reason: f.reason })); imported += batch.length - failed.length; } catch (e) { if (c.signal.aborted) break; const why = message(e, "Your server didn't take this batch."); batch.forEach((row) => skipped.push({ row, reason: why })); } setProgress({ done: Math.min(rows.length, s + batch.length), total: rows.length }); } const cancelled = c.signal.aborted; setResult({ imported, skipped, cancelled }); go("done"); setAnnounce(`${count(imported)} ${imported === 1 ? noun.one : many} imported${skipped.length ? `, ${count(skipped.length)} skipped` : ""}${cancelled ? ". Stopped early" : ""}.`); onDone?.({ imported, skipped: skipped.length, cancelled }); }; const reset = () => { setResult(null); setData([]); setHeaders([]); setFileName(""); go("drop"); requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true })); }; // The skipped rows, as a file to fix and import again. const skippedHref = React.useMemo(() => { if (!result?.skipped.length) return null; const csv = toCsv([[...fields.map((f) => f.label), "Why it was skipped"], ...result.skipped.map((s) => [...fields.map((f) => s.row[f.key] ?? ""), s.reason])]); return URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" })); }, [result, fields]); React.useEffect(() => () => void (skippedHref && URL.revokeObjectURL(skippedHref)), [skippedHref]); const header = ( <div className="flex items-center gap-2.5"> <span className="grid size-8 shrink-0 place-items-center rounded-[9px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]"> <FileSpreadsheet className="size-4" strokeWidth={1.75} aria-hidden="true" /> </span> <span className="min-w-0 flex-1"> <span className="block truncate text-[13px] font-medium">{fileName}</span> <span className="block text-[11.5px] text-muted-foreground tabular-nums"> {count(data.length)} {data.length === 1 ? noun.one : many} · {headers.length} columns </span> </span> </div> ); const render = (s: Step): React.ReactNode => { if (s === "drop") return ( <div className="px-4 pb-4 @md:px-5 @md:pb-5"> <input ref={inputRef} id={`${id}-file`} type="file" accept=".csv,.tsv,.txt,text/csv,text/tab-separated-values,text/plain" className="peer sr-only" onChange={(e) => { take(e.target.files?.[0]); e.target.value = ""; }} /> <label ref={dropRef} htmlFor={`${id}-file`} onDragEnter={(e) => { e.preventDefault(); setOver(true); }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setOver(false); }} onDrop={(e) => { e.preventDefault(); setOver(false); take(e.dataTransfer.files[0]); }} className={`flex cursor-pointer flex-col items-center justify-center rounded-[16px] border border-dashed px-4 py-9 text-center transition-[background-color,border-color] duration-300 peer-focus-visible:ring-2 peer-focus-visible:ring-ring/40 ${ over ? "border-primary bg-accent" : dropError ? "border-red-500/50" : "border-border hover:bg-accent/40" }`} > <span className="grid size-11 place-items-center rounded-[12px] bg-background text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]" style={{ transform: over ? "translateY(-4px) scale(1.06)" : "none", transition: reduced ? "none" : `transform 420ms ${THROW}` }} > {reading ? <span className="size-4 animate-spin rounded-full border-[1.5px] border-current border-t-transparent motion-reduce:animate-none" /> : <FileSpreadsheet className="size-5" strokeWidth={1.5} aria-hidden="true" />} </span> <span className="mt-3 text-[13.5px] font-medium"> <TextMorph>{reading ? `Reading ${reading.name}` : over ? "Drop to read it" : "Drop a CSV file here"}</TextMorph> </span> <span className="mt-1 text-[12px] text-muted-foreground"> or <span className="font-medium text-foreground underline underline-offset-2">choose a file</span> </span> <span className="mt-3 text-[11.5px] text-muted-foreground">Up to {count(maxRows)} rows. Comma, semicolon or tab separated.</span> </label> <Fold open={Boolean(dropError)} reduced={reduced}> <p role="alert" className="pt-2.5 text-[12px] text-red-600 dark:text-red-400"> {dropError} </p> </Fold> {templateHref && ( <a href={templateHref} download className={`mt-3 inline-flex items-center gap-1.5 rounded-sm text-[12px] font-medium text-muted-foreground underline-offset-2 hover:text-foreground hover:underline ${FOCUS}`}> <Download className="size-3.5" strokeWidth={1.75} aria-hidden="true" /> Download a template </a> )} </div> ); if (s === "map") return ( <div className="px-4 pb-4 @md:px-5 @md:pb-5"> {header} <div className="mt-4 mb-2.5 flex items-baseline justify-between gap-3"> <p className="text-[12.5px] font-medium">Match your columns</p> <p className="text-[11.5px] text-muted-foreground tabular-nums"> <NumberRoll value={matched} /> of {fields.length} fields matched </p> </div> <CsvMapper headers={headers} samples={samples} fields={fields} mapping={mapping} onChange={setMapping} reduced={reduced} /> <div className="mt-4 flex flex-wrap items-center justify-end gap-x-3 gap-y-2"> <p className={`mr-auto min-w-0 text-[11.5px] ${missing.length ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground"}`}> {missing.length ? `Match ${missing.map((f) => f.label).join(" and ")} to continue.` : "Carry a column onto a field, or pick it from the list."} </p> <button type="button" onClick={reset} className={`h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}> Change file </button> <StatusButton size="sm" labels={{ idle: "Continue" }} onClick={() => !missing.length && go("review")} aria-disabled={missing.length > 0 || undefined} className={missing.length ? "pointer-events-none opacity-50" : ""} /> </div> </div> ); if (s === "review") { const blocked = !skipBad && bad.length > 0; return ( <div className="px-4 pb-4 @md:px-5 @md:pb-5"> {header} <div className="mt-4 flex flex-wrap gap-2 text-[12px] tabular-nums"> <span className="inline-flex items-center gap-1.5 rounded-full py-1 pr-2.5 pl-2 text-emerald-700 shadow-[inset_0_0_0_1px_var(--border)] dark:text-emerald-400"> <span className="size-1.5 rounded-full bg-emerald-500" aria-hidden="true" /> <NumberRoll value={ready} /> ready </span> <span className="inline-grid" style={{ gridTemplateColumns: bad.length ? "1fr" : "0fr", opacity: bad.length ? 1 : 0, transition: reduced ? "none" : `grid-template-columns 420ms ${EASE}, opacity 260ms ${EASE}` }} > <span className="min-w-0 overflow-clip"> <span className="inline-flex items-center gap-1.5 rounded-full py-1 pr-2.5 pl-2 whitespace-nowrap text-amber-800 shadow-[inset_0_0_0_1px_var(--border)] dark:text-amber-400"> <span className="size-1.5 rounded-full bg-amber-500" aria-hidden="true" /> <NumberRoll value={bad.length || 1} /> need a look </span> </span> </span> </div> <Fold open={listed.length > 0} reduced={reduced}> <div className="mt-3 max-h-[19rem] overflow-y-auto overscroll-contain pr-1"> {listed.map((i) => ( <ProblemRow key={i} index={i} row={rowAt(i)} problems={checks[i] ?? {}} fields={fields} fixed={!Object.keys(checks[i] ?? {}).length} reduced={reduced} onEdit={edit} /> ))} {bad.length > SHOWN && <p className="py-1 text-center text-[11.5px] text-muted-foreground">and {count(bad.length - SHOWN)} more like these</p>} </div> <div className="mt-2"> <Switch on={skipBad} onToggle={() => setSkipBad((v) => !v)} label="Skip the rows that still need a look" reduced={reduced} /> </div> </Fold> <Fold open={!bad.length && !fixing.length} reduced={reduced}> <div className="mt-4 flex items-center gap-3 rounded-[12px] px-3 py-3 shadow-[inset_0_0_0_1px_var(--border)]"> <Drawn reduced={reduced} className="size-7" /> <p className="text-[12.5px]">Every row looks right.</p> </div> </Fold> <div className="mt-4 flex flex-wrap items-center justify-end gap-x-3 gap-y-2"> {blocked && <p className="mr-auto text-[11.5px] text-amber-700 dark:text-amber-400">Fix the rows above, or skip them.</p>} <button type="button" onClick={() => go("map")} className={`h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}> Back </button> <StatusButton size="sm" labels={{ idle: `Import ${count(ready)} ${ready === 1 ? noun.one : many}` }} onClick={() => !blocked && ready > 0 && run()} aria-disabled={blocked || !ready || undefined} className={blocked || !ready ? "pointer-events-none opacity-50" : ""} /> </div> </div> ); } if (s === "import") { const share = progress.total ? progress.done / progress.total : 0; return ( <div className="px-4 pb-5 @md:px-5"> {header} <div className="mt-5 h-1 overflow-hidden rounded-full bg-muted" role="progressbar" aria-label={`Importing ${fileName}`} aria-valuemin={0} aria-valuemax={progress.total} aria-valuenow={progress.done} > <span className="block h-full origin-left rounded-full bg-primary" style={{ transform: `scaleX(${share})`, transition: reduced ? "none" : `transform 360ms ${EASE}` }} /> </div> <div className="mt-3 flex items-center justify-between gap-3 text-[12.5px]"> <p className="inline-flex items-baseline text-muted-foreground tabular-nums"> <span className="font-medium text-foreground"> <NumberRoll value={progress.done} /> </span> of {count(progress.total)} {many} </p> <button type="button" onClick={() => ctrl.current?.abort()} className={`h-7 cursor-pointer rounded-full px-2.5 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground ${FOCUS}`}> Cancel </button> </div> </div> ); } const r = result; return ( <div className="flex flex-col items-center px-4 pt-2 pb-5 text-center @md:px-5"> <Drawn reduced={reduced} tone={r?.imported ? "text-emerald-600 dark:text-emerald-400" : "text-amber-600 dark:text-amber-400"} /> <p className="mt-3 text-[15px] font-medium tracking-[-0.01em] tabular-nums"> {count(r?.imported ?? 0)} {r?.imported === 1 ? noun.one : many} imported </p> <p className="mt-1 text-[12px] text-muted-foreground"> {r?.cancelled ? "You stopped it early; the rest weren't sent." : r?.skipped.length ? `${count(r.skipped.length)} skipped. Download them with the reason for each, fix them, and import that file.` : `Everything in ${fileName} is in.`} </p> <div className="mt-4 flex flex-wrap items-center justify-center gap-2"> {skippedHref && ( <a href={skippedHref} download={`${fileName.replace(/\.[^.]+$/, "")}-skipped.csv`} className={`inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-[12.5px] font-medium shadow-[inset_0_0_0_1px_var(--border)] transition-colors hover:bg-accent ${FOCUS}`}> <Download className="size-3.5" strokeWidth={1.75} aria-hidden="true" /> Skipped rows </a> )} <button type="button" onClick={reset} className={`inline-flex h-8 cursor-pointer items-center rounded-full bg-primary px-3.5 text-[12.5px] font-medium text-primary-foreground transition-colors hover:bg-primary/90 ${FOCUS}`}> Import another file </button> </div> </div> ); }; return ( <div className={`@container w-full rounded-[22px] border border-border bg-background text-foreground ${className}`} {...props}> <div className="px-4 pt-4 pb-3.5 @md:px-5 @md:pt-5"> <p className="text-[14px] font-medium tracking-[-0.01em]">{title}</p> <p className="mt-0.5 text-[12px] text-muted-foreground">{description}</p> </div> <Slides step={step} dir={dir} reduced={reduced} render={render} /> <span className="sr-only" role="status"> {announce} </span> </div> );}"use client";import * as React from "react";import { PillSelect, type PillOption } from "./pill-select";/* ───────────────────────────────────────────────────────── * CSV MAPPER: your file's columns against your fields * * threads on a wide card each match is a hairline thread * from the file's column to your field, drawn in * when it's made * drag carry a column (or its dot) onto a field to match * them; the thread follows the pointer. Or click a * column, then a field * select every field also has a plain select, the whole * mapping on a narrow card and for the keyboard * ───────────────────────────────────────────────────────── */export interface MapperField { key: string; label: string; required?: boolean;}export interface CsvMapperProps { headers: string[]; /** A few values from each column. */ samples: string[][]; fields: MapperField[]; mapping: Record<string, number | null>; onChange: (mapping: Record<string, number | null>) => void; reduced: boolean;}const EASE = "cubic-bezier(0.16,1,0.3,1)";const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const curve = (x1: number, y1: number, x2: number, y2: number) => { const dx = Math.max(24, (x2 - x1) / 2); return `M${x1} ${y1} C${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;};function Thread({ d, reduced, lit }: { d: string; reduced: boolean; lit: boolean }) { const ref = React.useRef<SVGPathElement>(null); React.useLayoutEffect(() => { if (!reduced) ref.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 520, easing: EASE, fill: "backwards" }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <path ref={ref} d={d} fill="none" stroke="var(--primary)" strokeWidth={lit ? 1.5 : 1} strokeOpacity={lit ? 0.9 : 0.5} pathLength={1} strokeDasharray={1} strokeDashoffset={0} style={{ transition: reduced ? "none" : "stroke-opacity 200ms, stroke-width 200ms" }} /> );}export function CsvMapper({ headers, samples, fields, mapping, onChange, reduced }: CsvMapperProps) { const id = React.useId(); const boxRef = React.useRef<HTMLDivElement>(null); const tempRef = React.useRef<SVGPathElement>(null); const [wide, setWide] = React.useState(false); const [lines, setLines] = React.useState<{ key: string; col: number; d: string }[]>([]); const [picked, setPicked] = React.useState<number | null>(null); const [carrying, setCarrying] = React.useState<number | null>(null); const [hover, setHover] = React.useState<string | null>(null); const [size, setSize] = React.useState({ w: 0, h: 0 }); const assign = (key: string, col: number | null) => { const next: Record<string, number | null> = { ...mapping }; if (col !== null) for (const k of Object.keys(next)) if (next[k] === col) next[k] = null; next[key] = col; onChange(next); }; const owner = (col: number) => fields.find((f) => mapping[f.key] === col); // Where each thread runs: from a column's dot to its field's dot. const measure = React.useCallback(() => { const box = boxRef.current; if (!box) return; const w = box.offsetWidth; const isWide = w >= 520; setWide((v) => (v === isWide ? v : isWide)); setSize((s) => (s.w === w && s.h === box.offsetHeight ? s : { w, h: box.offsetHeight })); if (!isWide) return setLines((l) => (l.length ? [] : l)); const r = box.getBoundingClientRect(); const next: { key: string; col: number; d: string }[] = []; for (const f of fields) { const col = mapping[f.key]; if (col === null || col === undefined) continue; const a = box.querySelector<HTMLElement>(`[data-col-dot="${col}"]`)?.getBoundingClientRect(); const b = box.querySelector<HTMLElement>(`[data-field-dot="${CSS.escape(f.key)}"]`)?.getBoundingClientRect(); if (!a || !b) continue; next.push({ key: f.key, col, d: curve(a.left + a.width / 2 - r.left, a.top + a.height / 2 - r.top, b.left + b.width / 2 - r.left, b.top + b.height / 2 - r.top) }); } setLines((l) => (l.length === next.length && l.every((x, i) => x.d === next[i].d && x.key === next[i].key) ? l : next)); }, [fields, mapping]); React.useLayoutEffect(() => { measure(); const box = boxRef.current; if (!box) return; const observer = new ResizeObserver(measure); observer.observe(box); return () => observer.disconnect(); }, [measure]); // Carrying a column: a loose thread follows the pointer, written straight to the path. const carry = (col: number, e: React.PointerEvent) => { if (!wide || e.button !== 0) return; const box = boxRef.current; const dot = box?.querySelector<HTMLElement>(`[data-col-dot="${col}"]`); if (!box || !dot) return; const sx = e.clientX; const sy = e.clientY; let started = false; const move = (ev: PointerEvent) => { if (!started && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return; if (!started) { started = true; setCarrying(col); } const r = box.getBoundingClientRect(); const a = dot.getBoundingClientRect(); tempRef.current?.setAttribute("d", curve(a.left + a.width / 2 - r.left, a.top + a.height / 2 - r.top, ev.clientX - r.left, ev.clientY - r.top)); const over = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest<HTMLElement>("[data-field]"); setHover((h) => (h === (over?.dataset.field ?? null) ? h : (over?.dataset.field ?? null))); }; const up = (ev: PointerEvent) => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up); if (!started) return; const over = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest<HTMLElement>("[data-field]"); if (over?.dataset.field) assign(over.dataset.field, col); setCarrying(null); setHover(null); tempRef.current?.setAttribute("d", ""); // The click that ends a carry shouldn't also pick the column. window.addEventListener("click", (c) => c.stopPropagation(), { capture: true, once: true }); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", up); window.addEventListener("pointercancel", up); }; React.useEffect(() => { if (picked === null) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null); window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [picked]); const lit = hover ?? (picked !== null ? owner(picked)?.key ?? null : null); const options = React.useMemo<PillOption[]>( () => [ { value: "", label: "Don’t import" }, ...headers.map((h, i) => ({ value: String(i), label: h || `Column ${i + 1}`, description: samples[i]?.filter(Boolean).slice(0, 2).join(" · ") || "Empty" })), ], [headers, samples], ); return ( <div ref={boxRef} className={`relative ${wide ? "grid grid-cols-[minmax(0,1fr)_48px_minmax(0,1.1fr)]" : ""}`}> {wide && ( <> <ol aria-label="Columns in your file" className="space-y-1.5"> {headers.map((h, i) => { const used = owner(i); const on = picked === i || carrying === i; return ( <li key={i}> <button type="button" data-col={i} aria-pressed={picked === i} onPointerDown={(e) => carry(i, e)} onClick={() => setPicked((p) => (p === i ? null : i))} className={`relative flex w-full cursor-grab items-center gap-2 rounded-[10px] py-1.5 pr-5 pl-2.5 text-left transition-[background-color,box-shadow] active:cursor-grabbing ${FOCUS} ${ on ? "bg-accent shadow-[inset_0_0_0_1px_var(--ring)]" : "shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent/60" }`} > <span className="min-w-0 flex-1"> <span className={`block truncate font-mono text-[12px] ${used ? "text-foreground" : "text-muted-foreground"}`}>{h || `Column ${i + 1}`}</span> <span className="block truncate text-[11px] text-muted-foreground">{samples[i]?.filter(Boolean).slice(0, 2).join(" · ") || "Empty"}</span> </span> <span data-col-dot={i} aria-hidden="true" className={`absolute top-1/2 -right-[5px] size-2.5 -translate-y-1/2 rounded-full transition-colors duration-300 ${used ? "bg-primary" : "bg-background shadow-[inset_0_0_0_1px_var(--muted-foreground)]"}`} /> </button> </li> ); })} </ol> <span aria-hidden="true" /> </> )} <ol aria-label="Your fields" className="space-y-1.5"> {fields.map((f) => { const col = mapping[f.key]; const target = picked !== null || carrying !== null; const isLit = lit === f.key; return ( <li key={f.key} data-field={f.key} onClick={(e) => { if (picked === null || (e.target as HTMLElement).closest('[aria-haspopup="listbox"], [role="listbox"]')) return; assign(f.key, picked); setPicked(null); }} className={`relative flex items-center gap-2 rounded-[10px] py-1.5 pr-1.5 pl-3 transition-[background-color,box-shadow] duration-200 ${ isLit ? "bg-accent shadow-[inset_0_0_0_1px_var(--ring)]" : target ? "cursor-pointer shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent/60" : "shadow-[inset_0_0_0_1px_var(--border)]" }`} > {wide && ( <span data-field-dot={f.key} aria-hidden="true" className={`absolute top-1/2 -left-[5px] size-2.5 -translate-y-1/2 rounded-full transition-colors duration-300 ${col !== null && col !== undefined ? "bg-primary" : "bg-background shadow-[inset_0_0_0_1px_var(--muted-foreground)]"}`} /> )} <span className="min-w-0 flex-1"> <span id={`${id}-${f.key}`} className="block truncate text-[12.5px] font-medium"> {f.label} {f.required && <span className="text-muted-foreground"> *</span>} </span> {!wide && <span className="block truncate text-[11px] text-muted-foreground">{col !== null && col !== undefined ? samples[col]?.filter(Boolean).slice(0, 2).join(" · ") || "Empty" : f.required ? "Needed to import" : "Not imported"}</span>} </span> <span className="flex w-[45%] max-w-[11rem] shrink-0"> <PillSelect block mono align="end" width={250} label={`Column for ${f.label}`} options={options} value={col === null || col === undefined ? "" : String(col)} onValueChange={(v) => assign(f.key, v === "" ? null : Number(v))} muted={col === null || col === undefined} /> </span> </li> ); })} </ol> {wide && ( <svg aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-visible" width={size.w} height={size.h}> {lines.map((l) => ( <Thread key={`${l.key}:${l.col}`} d={l.d} reduced={reduced} lit={lit === l.key} /> ))} <path ref={tempRef} d="" fill="none" stroke="var(--primary)" strokeWidth={1.5} strokeDasharray="3 3" style={{ opacity: carrying !== null ? 0.9 : 0 }} /> </svg> )} <p className="sr-only" aria-live="polite"> {picked !== null ? `${headers[picked] || `Column ${picked + 1}`} picked. Choose the field it fills.` : ""} </p> </div> );}/* ───────────────────────────────────────────────────────── * CSV PARSE: reading, matching and writing CSV for csv-import * * parse RFC 4180: quoted fields, doubled quotes, commas * and line breaks inside quotes, CRLF or LF, a * byte-order mark. The separator (comma, semicolon, * tab) is taken from the first line. Long files are * read in slices with a pause between, so the page * keeps drawing * match a column matches a field when their names agree * once case, spaces and punctuation are ignored * ("E-mail address" → email), or one contains the * other * write the rows you skipped, back out as CSV * ───────────────────────────────────────────────────────── */export interface CsvMatchable { key: string; label: string; /** Other names the column might have, e.g. ["e-mail", "email address"]. */ match?: string[];}const SLICE = 200_000;const pause = () => new Promise<void>((r) => setTimeout(r, 0));/** The separator the first line uses most, outside quotes. */export function detectDelimiter(text: string): string { const counts: Record<string, number> = { ",": 0, ";": 0, "\t": 0 }; let quoted = false; for (let i = 0; i < text.length && i < 10_000; i++) { const c = text[i]; if (c === '"') quoted = !quoted; else if (!quoted && (c === "\n" || c === "\r")) break; else if (!quoted && c in counts) counts[c]++; } return Object.entries(counts).sort((a, b) => b[1] - a[1])[0][1] ? Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0] : ",";}/** Every row as an array of fields; empty lines are dropped. */export async function parseCsv(input: string, options: { delimiter?: string; onProgress?: (fraction: number) => void; signal?: AbortSignal } = {}): Promise<string[][]> { const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input; const d = options.delimiter ?? detectDelimiter(text); const rows: string[][] = []; let row: string[] = []; let field = ""; let quoted = false; let start = 0; const n = text.length; for (let i = 0; i < n; i++) { const c = text.charCodeAt(i); if (quoted) { if (c === 34) { if (text.charCodeAt(i + 1) === 34) { field += text.slice(start, i + 1); i++; start = i + 1; } else { field += text.slice(start, i); quoted = false; start = i + 1; } } } else if (c === 34 && field === "" && start === i) { quoted = true; start = i + 1; } else if (text[i] === d) { row.push(field + text.slice(start, i)); field = ""; start = i + 1; } else if (c === 10 || c === 13) { row.push(field + text.slice(start, i)); if (row.length > 1 || row[0] !== "") rows.push(row); row = []; field = ""; if (c === 13 && text.charCodeAt(i + 1) === 10) i++; start = i + 1; } if (i % SLICE === 0 && i) { if (options.signal?.aborted) throw new DOMException("Aborted", "AbortError"); options.onProgress?.(i / n); await pause(); } } if (start < n || field || row.length) { row.push(field + text.slice(start, quoted ? n : n)); if (row.length > 1 || row[0] !== "") rows.push(row); } options.onProgress?.(1); return rows;}const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");/** Which column (by index) each field should take, or null. Each column is used once. */export function autoMatch(headers: string[], fields: CsvMatchable[]): Record<string, number | null> { const used = new Set<number>(); const out: Record<string, number | null> = {}; const heads = headers.map(norm); for (const f of fields) { const names = [f.key, f.label, ...(f.match ?? [])].map(norm).filter(Boolean); let hit = heads.findIndex((h, i) => !used.has(i) && names.includes(h)); if (hit === -1) hit = heads.findIndex((h, i) => !used.has(i) && h.length > 2 && names.some((x) => x.length > 2 && (h.includes(x) || x.includes(h)))); out[f.key] = hit === -1 ? null : hit; if (hit !== -1) used.add(hit); } return out;}/** Rows back out as CSV text, quoting what needs it. */export function toCsv(rows: string[][]): string { const esc = (v: string) => (/[",\r\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v); return rows.map((r) => r.map(esc).join(",")).join("\r\n");}"use client";import * as React from "react";import { createPortal } from "react-dom";import { TextMorph } from "./text-morph";/* ───────────────────────────────────────────────────────── * PILL SELECT: a select that's the same surface as its pill * * open the pill grows into its list (size and corners * ease) from the edge it's lined up with, while its * label fades into the options; it opens upward when * there's no room below, and long lists scroll * pick the list folds back into the pill and the label * morphs to the new choice; the current option * carries a drawn check * keys arrows move a highlight that glides between rows, * Home and End jump, typing a few letters finds an * option, Enter picks, Escape folds it back * * The list lives in a portal on the page's body, placed over * the pill, so a clipping toolbar, fold or scroller never cuts * it off; it carries the theme's colours with it. Scrolling * the page closes it. * * Used wherever a component needs a choice from a list; never * the browser's own dropdown, which can't follow the theme. * ───────────────────────────────────────────────────────── */export interface PillOption { value: string; label: string; /** A second line in the list, e.g. a sample value. */ description?: string;}export interface PillSelectProps { options: PillOption[]; value: string | null | undefined; onValueChange: (value: string) => void; /** Accessible name, e.g. "Column for Email". */ label: string; /** Shown while nothing is chosen. */ placeholder?: string; /** What the pill shows for a choice; the option's label by default. */ display?: (option: PillOption | undefined) => string; /** The pill's contents, when it needs more than text (an icon that turns). */ renderValue?: (option: PillOption | undefined, open: boolean) => React.ReactNode; /** Monospace labels, for code-like values (event names, column headers). */ mono?: boolean; /** Which edge of the pill the list lines up with. */ align?: "start" | "end"; /** The list's width; never narrower than the pill. */ width?: number; /** Fill the width it's given. */ block?: boolean; /** Dims the pill's text, e.g. for "Don't import". */ muted?: boolean; disabled?: boolean; className?: string;}type Phase = "closed" | "opening" | "open" | "closing";const EASE = "cubic-bezier(0.16,1,0.3,1)";const DURATION = 420;const MAX_LIST = 280;// The theme travels with the list, so it matches the pill wherever the pill's colours were set.const TOKENS = ["--background", "--foreground", "--popover", "--popover-foreground", "--border", "--accent", "--accent-foreground", "--primary", "--primary-foreground", "--muted", "--muted-foreground", "--ring"];const FOCUS = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40";const reducedQuery = "(prefers-reduced-motion: reduce)";const subscribeReduced = (onChange: () => void) => { const query = window.matchMedia(reducedQuery); query.addEventListener("change", onChange); return () => query.removeEventListener("change", onChange);};const useReducedMotion = () => React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);function Chevron({ up, reduced }: { up: boolean; reduced: boolean }) { return ( <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="size-3 shrink-0 text-muted-foreground" style={{ transform: up ? "rotate(180deg)" : "none", transition: reduced ? "none" : `transform 320ms ${EASE}` }}> <path d="M4.5 6.25 8 9.75l3.5-3.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}export function PillSelect({ options, value, onValueChange, label, placeholder = "Choose", display, renderValue, mono = false, align = "start", width = 240, block = false, muted = false, disabled = false, className = "",}: PillSelectProps) { const id = React.useId(); const reduced = useReducedMotion(); const [phase, setPhase] = React.useState<Phase>("closed"); const [active, setActive] = React.useState(0); const [pill, setPill] = React.useState({ width: 0, height: 28 }); const [panelHeight, setPanelHeight] = React.useState(0); const [fit, setFit] = React.useState({ up: false, width, shift: 0, x: 0, y: 0, theme: {} as Record<string, string> }); const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false }); const [quiet, setQuiet] = React.useState(false); const wrapRef = React.useRef<HTMLDivElement>(null); const triggerRef = React.useRef<HTMLButtonElement>(null); const panelRef = React.useRef<HTMLDivElement>(null); const surfaceRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLDivElement>(null); const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]); const timer = React.useRef(0); const typed = React.useRef({ text: "", at: 0 }); const selected = options.find((o) => o.value === value); const expanded = phase === "open"; const mounted = phase !== "closed"; const text = display ? display(selected) : (selected?.label ?? placeholder); const measureFit = () => { const wrap = wrapRef.current; if (!wrap) return; const rect = wrap.getBoundingClientRect(); const vw = document.documentElement.clientWidth; const w = Math.min(Math.max(width, rect.width), vw - 24); // Lined up with the pill's edge, nudged back on screen. const shift = align === "start" ? Math.min(0, vw - 12 - (rect.left + w)) : Math.max(0, 12 - (rect.right - w)); const estimate = Math.min(MAX_LIST, options.length * (options.some((o) => o.description) ? 46 : 32)) + 12; const vh = window.innerHeight; const below = vh - 8 - rect.top; const above = rect.bottom - 8; const up = below < estimate + 8 && above > below; // Fixed coordinates of the pill's corner the list grows from. const x = align === "start" ? rect.left : vw - rect.right; const y = up ? vh - rect.bottom : rect.top; const cs = getComputedStyle(wrap); const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily }; for (const t of TOKENS) { const v = cs.getPropertyValue(t); if (v) theme[t] = v; } setFit({ up, width: w, shift, x, y, theme }); }; const open = () => { if (disabled || phase === "open" || phase === "opening") return; window.clearTimeout(timer.current); measureFit(); setActive(Math.max(0, options.findIndex((o) => o.value === value))); setPhase(reduced ? "open" : "opening"); }; const close = React.useCallback( (refocus = true) => { window.clearTimeout(timer.current); if (refocus) triggerRef.current?.focus({ preventScroll: true }); if (reduced) return setPhase("closed"); setPhase("closing"); timer.current = window.setTimeout(() => setPhase("closed"), DURATION); }, [reduced], ); React.useEffect(() => { if (phase !== "opening") return; let inner = 0; const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase("open")))); return () => { cancelAnimationFrame(outer); cancelAnimationFrame(inner); }; }, [phase]); React.useEffect(() => () => window.clearTimeout(timer.current), []); React.useLayoutEffect(() => { const trigger = triggerRef.current; if (!trigger) return; const measure = () => setPill((p) => (p.width === trigger.offsetWidth && p.height === trigger.offsetHeight ? p : { width: trigger.offsetWidth, height: trigger.offsetHeight })); measure(); const observer = new ResizeObserver(measure); observer.observe(trigger); return () => observer.disconnect(); }, []); React.useLayoutEffect(() => { const panel = panelRef.current; if (panel) setPanelHeight(panel.offsetHeight); }, [mounted, options]); // The highlight glides to the active row, and the list scrolls to keep it in view. React.useLayoutEffect(() => { const row = rowRefs.current[active]; const list = listRef.current; if (!row || !list) return; setHighlight((h) => (h.top === row.offsetTop && h.height === row.offsetHeight && h.ready === (h.ready || expanded) ? h : { top: row.offsetTop, height: row.offsetHeight, ready: h.ready || expanded })); const scroller = list.parentElement; if (scroller && scroller.scrollHeight > scroller.clientHeight) { if (row.offsetTop < scroller.scrollTop) scroller.scrollTop = row.offsetTop - 4; else if (row.offsetTop + row.offsetHeight > scroller.scrollTop + scroller.clientHeight) scroller.scrollTop = row.offsetTop + row.offsetHeight - scroller.clientHeight + 4; } }, [active, expanded, mounted]); React.useEffect(() => { if (!mounted) setHighlight({ top: 0, height: 0, ready: false }); }, [mounted]); React.useEffect(() => { if (expanded) listRef.current?.focus({ preventScroll: true }); }, [expanded]); React.useEffect(() => { if (!expanded) return; const inside = (n: Node) => Boolean(wrapRef.current?.contains(n) || surfaceRef.current?.contains(n)); const onDown = (e: PointerEvent) => { if (!inside(e.target as Node)) close(false); }; // The list sits where the pill was; if the page moves under it, it goes at once rather than fold in the wrong place. const gone = () => { window.clearTimeout(timer.current); setPhase("closed"); }; const onScroll = (e: Event) => { if (!surfaceRef.current?.contains(e.target as Node)) gone(); }; const onResize = gone; document.addEventListener("pointerdown", onDown); window.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); return () => { document.removeEventListener("pointerdown", onDown); window.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); }; }, [expanded, close]); const choose = (o: PillOption) => { close(); if (o.value !== value) onValueChange(o.value); }; const onListKey = (e: React.KeyboardEvent) => { const last = options.length - 1; const move = (to: number) => { e.preventDefault(); setActive(Math.max(0, Math.min(last, to))); }; if (e.key === "ArrowDown") move(active === last ? 0 : active + 1); else if (e.key === "ArrowUp") move(active === 0 ? last : active - 1); else if (e.key === "Home") move(0); else if (e.key === "End") move(last); else if (e.key === "PageDown") move(active + 6); else if (e.key === "PageUp") move(active - 6); else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); if (options[active]) choose(options[active]); } else if (e.key === "Tab") close(false); else if (e.key.length === 1 && /\S/.test(e.key)) { // A few letters typed quickly find the option that starts with them. const now = performance.now(); typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : "") + e.key.toLowerCase(), at: now }; const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text)); if (hit !== -1) setActive(hit); } }; const w = expanded ? fit.width : pill.width; const h = expanded ? panelHeight : pill.height; const listId = `${id}-list`; const pillText = renderValue ? null : ( <> <span className={`min-w-0 flex-1 truncate text-left ${mono ? "font-mono text-[11.5px]" : ""} ${muted || !selected ? "text-muted-foreground" : "text-foreground"}`}> {block ? text : <TextMorph>{text}</TextMorph>} </span> <Chevron up={expanded} reduced={reduced} /> </> ); return ( <div ref={wrapRef} className={`relative ${block ? "flex w-full min-w-0" : "inline-flex"} ${className}`} onKeyDown={(e) => { setQuiet(false); if (e.key === "Escape" && expanded) { e.preventDefault(); e.stopPropagation(); close(); } }} onBlur={(e) => { const to = e.relatedTarget as Node | null; if (expanded && document.hasFocus() && !(to && (wrapRef.current?.contains(to) || surfaceRef.current?.contains(to)))) close(false); }} > <button ref={triggerRef} type="button" aria-haspopup="listbox" aria-expanded={expanded} aria-controls={mounted ? listId : undefined} aria-label={`${label}: ${selected?.label ?? placeholder}`} disabled={disabled} onClick={() => (expanded ? close() : open())} onKeyDown={(e) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); open(); } }} onPointerDown={() => setQuiet(true)} className={`inline-flex h-7 min-w-0 cursor-pointer items-center gap-1.5 rounded-full px-2.5 text-[12px] font-medium whitespace-nowrap shadow-[inset_0_0_0_1px_var(--border)] transition-colors duration-150 hover:bg-accent disabled:cursor-default disabled:opacity-50 disabled:hover:bg-transparent ${block ? "w-full" : ""} ${quiet ? "outline-none" : FOCUS}`} > {renderValue ? renderValue(selected, expanded) : pillText} </button> {mounted && createPortal( <div ref={surfaceRef} onKeyDown={(e) => { if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); close(); } }} onPointerDown={(e) => { if (!(e.target as HTMLElement).closest("[data-scroll]")) e.preventDefault(); setQuiet(true); }} className="fixed z-[60] overflow-hidden bg-popover text-popover-foreground" style={{ ...fit.theme, [fit.up ? "bottom" : "top"]: fit.y, [align === "start" ? "left" : "right"]: fit.x + (expanded ? (align === "start" ? fit.shift : -fit.shift) : 0), width: w, height: h, borderRadius: expanded ? 16 : pill.height / 2, // The pill's own hairline, the whole way: it is the same surface. boxShadow: "inset 0 0 0 1px var(--border)", transition: reduced ? "none" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`, }} > {/* The pill's label, fading out as the surface grows and back in as it folds. */} <span aria-hidden="true" className={`pointer-events-none absolute inline-flex h-7 items-center gap-1.5 px-2.5 text-[12px] font-medium whitespace-nowrap ${fit.up ? "bottom-0" : "top-0"} ${align === "start" ? "left-0" : "right-0"}`} style={{ width: pill.width, opacity: expanded ? 0 : 1, transition: reduced ? "none" : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms` }} > {renderValue ? renderValue(selected, expanded) : pillText} </span> <div ref={panelRef} className={`absolute p-1 ${fit.up ? "bottom-0" : "top-0"} ${align === "start" ? "left-0" : "right-0"}`} style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? "none" : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms` }} > <div data-scroll="" className="overflow-y-auto overscroll-contain" style={{ maxHeight: MAX_LIST }}> <div ref={listRef} id={listId} role="listbox" tabIndex={-1} aria-label={label} aria-activedescendant={`${id}-o${active}`} onKeyDown={onListKey} className="relative outline-none" > <span aria-hidden="true" className="pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent" style={{ height: highlight.height, transform: `translateY(${highlight.top}px)`, opacity: highlight.ready ? 1 : 0, transition: highlight.ready && !reduced ? `transform 260ms ${EASE}, height 260ms ${EASE}, opacity 200ms` : "none", }} /> {options.map((o, i) => { const on = o.value === value; return ( <div key={o.value} ref={(el) => { rowRefs.current[i] = el; }} id={`${id}-o${i}`} role="option" aria-selected={on} onPointerMove={() => setActive(i)} onClick={() => choose(o)} className="relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-1.5" > <span className="min-w-0 flex-1"> <span className={`block truncate ${mono ? "font-mono text-[12px]" : "text-[12.5px] font-medium"}`}>{o.label}</span> {o.description && <span className="mt-0.5 block truncate text-[11.5px] text-muted-foreground">{o.description}</span>} </span> <svg viewBox="0 0 16 16" fill="none" aria-hidden="true" className="mt-0.5 size-3.5 shrink-0"> <path d="M3.5 8.5 6.5 11.5 12.5 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" pathLength={1} strokeDasharray={1} style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? "none" : `stroke-dashoffset 380ms ${EASE} 160ms` }} /> </svg> </div> ); })} </div> </div> </div> </div>, document.body, )} </div> );}Registry item: /r/csv-import.json