{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"csv-import","type":"registry:component","title":"CSV import","description":"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.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/text-morph.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency"],"files":[{"path":"registry/csv-import.tsx","type":"registry:component","content":"// Hairline UI · csv-import · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Download, FileSpreadsheet } from \"lucide-react\";\nimport { CsvMapper } from \"./csv-mapper\";\nimport { autoMatch, parseCsv, toCsv } from \"./csv-parse\";\nimport { NumberRoll } from \"./number-roll\";\nimport { StatusButton } from \"./status-button\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * CSV IMPORT: Contacts → Import, done properly\n *\n *   drop      drop a file (the target lifts to meet it) or\n *             choose one; it's read in slices with the row\n *             count rolling, so a big file never freezes the page\n *   match     the file's columns line up against your fields;\n *             the ones that match by name are threaded together\n *             already. Carry a column onto a field to change it,\n *             or use the field's select\n *   review    \"2,996 ready · 4 need a look\": the rows with\n *             problems open with their bad cells as fields;\n *             fix one and it ticks and folds away, the counts\n *             rolling. Or skip what's left\n *   import    rows go to onImport in batches; the bar fills and\n *             \"1,250 of 2,996\" rolls; Cancel stops after the\n *             batch in flight\n *   done      a ring and a check draw; anything skipped or\n *             refused downloads as a CSV with the reason per row\n *\n * The file never leaves the browser until onImport sends your\n * rows, already shaped as { [field]: value }.\n * ───────────────────────────────────────────────────────── */\n\nexport interface CsvField {\n  key: string;\n  label: string;\n  required?: boolean;\n  /** Other names the column might have in a file, e.g. [\"e-mail\", \"email address\"]. */\n  match?: string[];\n  /** Return why a value won't do. */\n  validate?: (value: string, row: Record<string, string>) => string | null | undefined | void;\n}\n\nexport interface CsvImportResult {\n  /** Rows in this batch your server refused, by their index in the batch. */\n  failed?: { index: number; reason: string }[];\n}\n\nexport interface CsvImportHandle {\n  /** Reads text as if it were a dropped file. */\n  load: (text: string, name?: string) => void;\n}\n\nexport interface CsvImportProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"title\"> {\n  fields: CsvField[];\n  /** Saves one batch of rows. Throw to mark the whole batch as not imported. */\n  onImport: (rows: Record<string, string>[], options: { signal: AbortSignal; start: number }) => Promise<CsvImportResult | void>;\n  /** Rows per onImport call. */\n  batchSize?: number;\n  maxRows?: number;\n  /** What a row is: { one: \"contact\", many: \"contacts\" }. */\n  noun?: { one: string; many: string };\n  /** A link to a template file to fill in. */\n  templateHref?: string;\n  title?: string;\n  description?: string;\n  onDone?: (summary: { imported: number; skipped: number; cancelled: boolean }) => void;\n  ref?: React.Ref<CsvImportHandle>;\n}\n\ntype Step = \"drop\" | \"map\" | \"review\" | \"import\" | \"done\";\ntype Skipped = { row: Record<string, string>; reason: string };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst THROW = \"cubic-bezier(0.34,1.36,0.64,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst ORDER: Step[] = [\"drop\", \"map\", \"review\", \"import\", \"done\"];\nconst SHOWN = 40;\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nconst message = (e: unknown, fallback: string) => (e instanceof Error && e.message ? e.message : fallback);\nconst count = (n: number) => n.toLocaleString(\"en-GB\");\n\nfunction Face({ out, enter, dir, reduced, onGone, children }: { out: boolean; enter: boolean; dir: number; reduced: boolean; onGone: () => void; children: React.ReactNode }) {\n  const ref = React.useRef<HTMLDivElement>(null);\n  React.useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    el.getAnimations().forEach((a) => a.cancel());\n    if (reduced) return;\n    if (out) {\n      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\" });\n      a.onfinish = onGone;\n      return;\n    }\n    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\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [out]);\n  return (\n    <div ref={ref} inert={out} aria-hidden={out || undefined} className={out ? \"pointer-events-none absolute inset-x-0 top-0\" : \"\"}>\n      {children}\n    </div>\n  );\n}\n\n/** Steps slide in from the side you went; the card eases to the new height. */\nfunction Slides({ step, dir, reduced, render }: { step: Step; dir: number; reduced: boolean; render: (s: Step) => React.ReactNode }) {\n  const [faces, setFaces] = React.useState<{ s: Step; out: boolean; dir: number; enter: boolean }[]>([{ s: step, out: false, dir, enter: false }]);\n  const [seen, setSeen] = React.useState(step);\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const height = React.useRef(0);\n  // Clipped only while a step slides; at rest a menu can open past its edge.\n  const [moving, setMoving] = React.useState(false);\n  React.useEffect(() => {\n    if (!moving) return;\n    const timer = window.setTimeout(() => setMoving(false), 560);\n    return () => window.clearTimeout(timer);\n  }, [moving, step]);\n  if (seen !== step) {\n    setSeen(step);\n    if (!reduced) setMoving(true);\n    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 }]);\n  }\n  React.useLayoutEffect(() => {\n    const el = boxRef.current;\n    if (!el) return;\n    height.current = el.offsetHeight;\n    const observer = new ResizeObserver(() => (height.current = el.offsetHeight));\n    observer.observe(el);\n    return () => observer.disconnect();\n  }, []);\n  React.useLayoutEffect(() => {\n    const el = boxRef.current;\n    if (!el || reduced) return;\n    const to = el.offsetHeight;\n    if (Math.abs(to - height.current) > 0.5) el.animate([{ height: `${height.current}px` }, { height: `${to}px` }], { duration: 460, easing: EASE });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [step]);\n  return (\n    <div ref={boxRef} className=\"relative [overflow-clip-margin:8px]\" style={{ overflowY: moving ? \"clip\" : \"visible\" }}>\n      {faces.map((f) => (\n        <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)))}>\n          {render(f.s)}\n        </Face>\n      ))}\n    </div>\n  );\n}\n\nfunction Fold({ open, reduced, children }: { open: boolean; reduced: boolean; children: React.ReactNode }) {\n  const kept = React.useRef(children);\n  if (open) kept.current = children;\n  return (\n    <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}` }}>\n      <div className=\"min-h-0 min-w-0 overflow-clip [overflow-clip-margin:6px]\">{open ? children : kept.current}</div>\n    </div>\n  );\n}\n\nfunction Drawn({ reduced, className = \"size-12\", tone = \"text-emerald-600 dark:text-emerald-400\" }: { reduced: boolean; className?: string; tone?: string }) {\n  const ring = React.useRef<SVGCircleElement>(null);\n  const tick = React.useRef<SVGPathElement>(null);\n  React.useLayoutEffect(() => {\n    if (reduced) return;\n    ring.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 640, delay: 120, easing: EASE, fill: \"backwards\" });\n    tick.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 420, delay: 560, easing: EASE, fill: \"backwards\" });\n  }, [reduced]);\n  return (\n    <svg viewBox=\"0 0 48 48\" fill=\"none\" aria-hidden=\"true\" className={`${className} ${tone}`}>\n      <circle cx=\"24\" cy=\"24\" r=\"22.5\" stroke=\"var(--border)\" strokeWidth=\"1\" />\n      <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)\" />\n      <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} />\n    </svg>\n  );\n}\n\nfunction Switch({ on, onToggle, label, reduced }: { on: boolean; onToggle: () => void; label: string; reduced: boolean }) {\n  return (\n    <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}`}>\n      <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)]\"}`}>\n        <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}` }} />\n      </span>\n      {label}\n    </button>\n  );\n}\n\n/** One row with problems: its bad cells as fields; once they're right it ticks and folds away. */\nfunction ProblemRow({\n  index,\n  row,\n  problems,\n  fields,\n  fixed,\n  reduced,\n  onEdit,\n}: {\n  index: number;\n  row: Record<string, string>;\n  problems: Record<string, string>;\n  fields: CsvField[];\n  fixed: boolean;\n  reduced: boolean;\n  onEdit: (index: number, key: string, value: string) => void;\n}) {\n  const keys = Object.keys(problems);\n  const [shownKeys] = React.useState(keys);\n  // Fixed: it says so for a moment, then folds away.\n  const [gone, setGone] = React.useState(false);\n  React.useEffect(() => {\n    if (!fixed) return setGone(false);\n    const timer = window.setTimeout(() => setGone(true), 650);\n    return () => window.clearTimeout(timer);\n  }, [fixed]);\n  const context = fields.filter((f) => !shownKeys.includes(f.key) && row[f.key]).slice(0, 2);\n  return (\n    <Fold open={!gone} reduced={reduced}>\n      <div className=\"mb-2 rounded-[12px] px-3 py-2.5 shadow-[inset_0_0_0_1px_var(--border)]\">\n        <p className=\"flex items-center gap-2 text-[11.5px] text-muted-foreground\">\n          <span className=\"font-mono\">Row {count(index + 2)}</span>\n          {context.length > 0 && <span className=\"min-w-0 truncate\">· {context.map((f) => row[f.key]).join(\" · \")}</span>}\n          <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\"}`}>\n            <TextMorph>{fixed ? \"Fixed\" : keys.length === 1 ? \"1 problem\" : `${keys.length} problems`}</TextMorph>\n          </span>\n        </p>\n        <div className=\"mt-2 grid gap-2 @md:grid-cols-2\">\n          {shownKeys.map((k) => {\n            const f = fields.find((x) => x.key === k)!;\n            const bad = problems[k];\n            return (\n              <label key={k} className=\"block min-w-0\">\n                <span className=\"block text-[11.5px] font-medium\">{f.label}</span>\n                <input\n                  value={row[k] ?? \"\"}\n                  onChange={(e) => onEdit(index, k, e.target.value)}\n                  aria-invalid={Boolean(bad) || undefined}\n                  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] ${\n                    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)]\"\n                  }`}\n                />\n                <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>\n              </label>\n            );\n          })}\n        </div>\n      </div>\n    </Fold>\n  );\n}\n\nexport function CsvImport({\n  fields,\n  onImport,\n  batchSize = 250,\n  maxRows = 50_000,\n  noun = { one: \"row\", many: \"rows\" },\n  templateHref,\n  title = \"Import\",\n  description = \"Bring rows in from a spreadsheet. Nothing is saved until you import.\",\n  onDone,\n  ref,\n  className = \"\",\n  ...props\n}: CsvImportProps) {\n  const reduced = useReducedMotion();\n  const id = React.useId();\n  const [step, setStep] = React.useState<Step>(\"drop\");\n  const [dir, setDir] = React.useState(1);\n  const [fileName, setFileName] = React.useState(\"\");\n  const [headers, setHeaders] = React.useState<string[]>([]);\n  const [data, setData] = React.useState<string[][]>([]);\n  const [mapping, setMapping] = React.useState<Record<string, number | null>>({});\n  const [reading, setReading] = React.useState<{ name: string } | null>(null);\n  const [dropError, setDropError] = React.useState<string | null>(null);\n  const [over, setOver] = React.useState(false);\n  const [edits, setEdits] = React.useState<Record<number, Record<string, string>>>({});\n  const [fixing, setFixing] = React.useState<number[]>([]);\n  const [skipBad, setSkipBad] = React.useState(true);\n  const [progress, setProgress] = React.useState({ done: 0, total: 0 });\n  const [result, setResult] = React.useState<{ imported: number; skipped: Skipped[]; cancelled: boolean } | null>(null);\n  const [announce, setAnnounce] = React.useState(\"\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const dropRef = React.useRef<HTMLLabelElement>(null);\n  const ctrl = React.useRef<AbortController | null>(null);\n  const many = noun.many;\n\n  const go = (next: Step) => {\n    setDir(ORDER.indexOf(next) >= ORDER.indexOf(step) ? 1 : -1);\n    setStep(next);\n  };\n\n  const read = async (text: string, name: string) => {\n    setDropError(null);\n    setReading({ name });\n    try {\n      const rows = await parseCsv(text);\n      const [head, ...body] = rows;\n      if (!head || !body.length) throw new Error(\"That file has no rows under its header.\");\n      if (body.length > maxRows) throw new Error(`That file has ${count(body.length)} rows; up to ${count(maxRows)} can be imported at once.`);\n      const width = head.length;\n      setHeaders(head.map((h) => h.trim()));\n      setData(body.map((r) => (r.length === width ? r : [...r, ...Array(Math.max(0, width - r.length)).fill(\"\")].slice(0, width))));\n      setMapping(autoMatch(head, fields));\n      setEdits({});\n      setFixing([]);\n      setFileName(name);\n      setReading(null);\n      go(\"map\");\n      setAnnounce(`${name}: ${count(body.length)} ${body.length === 1 ? noun.one : many}, ${width} columns. Match them to your fields.`);\n    } catch (e) {\n      setReading(null);\n      setDropError(message(e, \"That file couldn't be read.\"));\n      if (!reduced) dropRef.current?.animate([{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }], { duration: 380, easing: \"ease-out\" });\n    }\n  };\n\n  const take = async (file: File | undefined) => {\n    if (!file) return;\n    if (!/\\.(csv|tsv|txt)$/i.test(file.name) && !/^text\\//.test(file.type)) {\n      setDropError(`“${file.name}” isn't a CSV file. Export your sheet as CSV first.`);\n      return;\n    }\n    read(await file.text(), file.name);\n  };\n\n  React.useImperativeHandle(ref, () => ({ load: (text, name = \"pasted.csv\") => read(text, name) }));\n\n  const rowAt = React.useCallback(\n    (i: number) => {\n      const out: Record<string, string> = {};\n      for (const f of fields) {\n        const col = mapping[f.key];\n        out[f.key] = edits[i]?.[f.key] ?? (col === null || col === undefined ? \"\" : (data[i]?.[col] ?? \"\").trim());\n      }\n      return out;\n    },\n    [fields, mapping, edits, data],\n  );\n\n  const problemsOf = React.useCallback(\n    (row: Record<string, string>) => {\n      const out: Record<string, string> = {};\n      for (const f of fields) {\n        const v = row[f.key];\n        if (f.required && !v) out[f.key] = `${f.label} is missing.`;\n        else if (v && f.validate) {\n          const why = f.validate(v, row);\n          if (why) out[f.key] = why;\n        }\n      }\n      return out;\n    },\n    [fields],\n  );\n\n  // Every row is checked once per mapping; an edit re-checks only its own row.\n  const checking = step === \"review\" || step === \"import\";\n  const base = React.useMemo(() => {\n    if (!checking) return [];\n    return data.map((r) => {\n      const row: Record<string, string> = {};\n      for (const f of fields) {\n        const col = mapping[f.key];\n        row[f.key] = col === null || col === undefined ? \"\" : (r[col] ?? \"\").trim();\n      }\n      return problemsOf(row);\n    });\n  }, [checking, data, mapping, fields, problemsOf]);\n  const checks = React.useMemo(() => {\n    const next = base.slice();\n    for (const i of Object.keys(edits)) next[Number(i)] = problemsOf(rowAt(Number(i)));\n    return next;\n  }, [base, edits, problemsOf, rowAt]);\n  const bad = React.useMemo(() => checks.map((p, i) => (Object.keys(p).length ? i : -1)).filter((i) => i >= 0), [checks]);\n  const ready = data.length - bad.length;\n  const missing = fields.filter((f) => f.required && (mapping[f.key] === null || mapping[f.key] === undefined));\n  const matched = fields.filter((f) => mapping[f.key] !== null && mapping[f.key] !== undefined).length;\n  const samples = React.useMemo(() => headers.map((_, c) => data.slice(0, 6).map((r) => r[c] ?? \"\")), [headers, data]);\n  const listed = [...new Set([...bad.slice(0, SHOWN), ...fixing])].sort((a, b) => a - b);\n\n  const edit = (index: number, key: string, value: string) => {\n    setEdits((e) => ({ ...e, [index]: { ...e[index], [key]: value } }));\n    const row = { ...rowAt(index), [key]: value };\n    if (!Object.keys(problemsOf(row)).length && !fixing.includes(index)) {\n      // Fixed: it stays to say so, then folds away.\n      setFixing((f) => [...f, index]);\n      window.setTimeout(() => setFixing((f) => f.filter((x) => x !== index)), 1400);\n    }\n  };\n\n  const run = async () => {\n    const indices = data.map((_, i) => i).filter((i) => !bad.includes(i));\n    const rows = indices.map(rowAt);\n    const skipped: Skipped[] = bad.map((i) => ({ row: rowAt(i), reason: Object.values(checks[i]).join(\" \") }));\n    const c = new AbortController();\n    ctrl.current = c;\n    setProgress({ done: 0, total: rows.length });\n    go(\"import\");\n    let imported = 0;\n    for (let s = 0; s < rows.length; s += batchSize) {\n      if (c.signal.aborted) break;\n      const batch = rows.slice(s, s + batchSize);\n      try {\n        const r = await onImport(batch, { signal: c.signal, start: s });\n        const failed = (r ? r.failed : undefined) ?? [];\n        failed.forEach((f) => batch[f.index] && skipped.push({ row: batch[f.index], reason: f.reason }));\n        imported += batch.length - failed.length;\n      } catch (e) {\n        if (c.signal.aborted) break;\n        const why = message(e, \"Your server didn't take this batch.\");\n        batch.forEach((row) => skipped.push({ row, reason: why }));\n      }\n      setProgress({ done: Math.min(rows.length, s + batch.length), total: rows.length });\n    }\n    const cancelled = c.signal.aborted;\n    setResult({ imported, skipped, cancelled });\n    go(\"done\");\n    setAnnounce(`${count(imported)} ${imported === 1 ? noun.one : many} imported${skipped.length ? `, ${count(skipped.length)} skipped` : \"\"}${cancelled ? \". Stopped early\" : \"\"}.`);\n    onDone?.({ imported, skipped: skipped.length, cancelled });\n  };\n\n  const reset = () => {\n    setResult(null);\n    setData([]);\n    setHeaders([]);\n    setFileName(\"\");\n    go(\"drop\");\n    requestAnimationFrame(() => inputRef.current?.focus({ preventScroll: true }));\n  };\n\n  // The skipped rows, as a file to fix and import again.\n  const skippedHref = React.useMemo(() => {\n    if (!result?.skipped.length) return null;\n    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])]);\n    return URL.createObjectURL(new Blob([csv], { type: \"text/csv;charset=utf-8\" }));\n  }, [result, fields]);\n  React.useEffect(() => () => void (skippedHref && URL.revokeObjectURL(skippedHref)), [skippedHref]);\n\n  const header = (\n    <div className=\"flex items-center gap-2.5\">\n      <span className=\"grid size-8 shrink-0 place-items-center rounded-[9px] text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\">\n        <FileSpreadsheet className=\"size-4\" strokeWidth={1.75} aria-hidden=\"true\" />\n      </span>\n      <span className=\"min-w-0 flex-1\">\n        <span className=\"block truncate text-[13px] font-medium\">{fileName}</span>\n        <span className=\"block text-[11.5px] text-muted-foreground tabular-nums\">\n          {count(data.length)} {data.length === 1 ? noun.one : many} · {headers.length} columns\n        </span>\n      </span>\n    </div>\n  );\n\n  const render = (s: Step): React.ReactNode => {\n    if (s === \"drop\")\n      return (\n        <div className=\"px-4 pb-4 @md:px-5 @md:pb-5\">\n          <input\n            ref={inputRef}\n            id={`${id}-file`}\n            type=\"file\"\n            accept=\".csv,.tsv,.txt,text/csv,text/tab-separated-values,text/plain\"\n            className=\"peer sr-only\"\n            onChange={(e) => {\n              take(e.target.files?.[0]);\n              e.target.value = \"\";\n            }}\n          />\n          <label\n            ref={dropRef}\n            htmlFor={`${id}-file`}\n            onDragEnter={(e) => {\n              e.preventDefault();\n              setOver(true);\n            }}\n            onDragOver={(e) => {\n              e.preventDefault();\n              e.dataTransfer.dropEffect = \"copy\";\n            }}\n            onDragLeave={(e) => {\n              if (!e.currentTarget.contains(e.relatedTarget as Node)) setOver(false);\n            }}\n            onDrop={(e) => {\n              e.preventDefault();\n              setOver(false);\n              take(e.dataTransfer.files[0]);\n            }}\n            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 ${\n              over ? \"border-primary bg-accent\" : dropError ? \"border-red-500/50\" : \"border-border hover:bg-accent/40\"\n            }`}\n          >\n            <span\n              className=\"grid size-11 place-items-center rounded-[12px] bg-background text-muted-foreground shadow-[inset_0_0_0_1px_var(--border)]\"\n              style={{ transform: over ? \"translateY(-4px) scale(1.06)\" : \"none\", transition: reduced ? \"none\" : `transform 420ms ${THROW}` }}\n            >\n              {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\" />}\n            </span>\n            <span className=\"mt-3 text-[13.5px] font-medium\">\n              <TextMorph>{reading ? `Reading ${reading.name}` : over ? \"Drop to read it\" : \"Drop a CSV file here\"}</TextMorph>\n            </span>\n            <span className=\"mt-1 text-[12px] text-muted-foreground\">\n              or <span className=\"font-medium text-foreground underline underline-offset-2\">choose a file</span>\n            </span>\n            <span className=\"mt-3 text-[11.5px] text-muted-foreground\">Up to {count(maxRows)} rows. Comma, semicolon or tab separated.</span>\n          </label>\n          <Fold open={Boolean(dropError)} reduced={reduced}>\n            <p role=\"alert\" className=\"pt-2.5 text-[12px] text-red-600 dark:text-red-400\">\n              {dropError}\n            </p>\n          </Fold>\n          {templateHref && (\n            <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}`}>\n              <Download className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n              Download a template\n            </a>\n          )}\n        </div>\n      );\n\n    if (s === \"map\")\n      return (\n        <div className=\"px-4 pb-4 @md:px-5 @md:pb-5\">\n          {header}\n          <div className=\"mt-4 mb-2.5 flex items-baseline justify-between gap-3\">\n            <p className=\"text-[12.5px] font-medium\">Match your columns</p>\n            <p className=\"text-[11.5px] text-muted-foreground tabular-nums\">\n              <NumberRoll value={matched} /> of {fields.length} fields matched\n            </p>\n          </div>\n          <CsvMapper headers={headers} samples={samples} fields={fields} mapping={mapping} onChange={setMapping} reduced={reduced} />\n          <div className=\"mt-4 flex flex-wrap items-center justify-end gap-x-3 gap-y-2\">\n            <p className={`mr-auto min-w-0 text-[11.5px] ${missing.length ? \"text-amber-700 dark:text-amber-400\" : \"text-muted-foreground\"}`}>\n              {missing.length ? `Match ${missing.map((f) => f.label).join(\" and \")} to continue.` : \"Carry a column onto a field, or pick it from the list.\"}\n            </p>\n            <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}`}>\n              Change file\n            </button>\n            <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\" : \"\"} />\n          </div>\n        </div>\n      );\n\n    if (s === \"review\") {\n      const blocked = !skipBad && bad.length > 0;\n      return (\n        <div className=\"px-4 pb-4 @md:px-5 @md:pb-5\">\n          {header}\n          <div className=\"mt-4 flex flex-wrap gap-2 text-[12px] tabular-nums\">\n            <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\">\n              <span className=\"size-1.5 rounded-full bg-emerald-500\" aria-hidden=\"true\" />\n              <NumberRoll value={ready} />\n              &nbsp;ready\n            </span>\n            <span\n              className=\"inline-grid\"\n              style={{ gridTemplateColumns: bad.length ? \"1fr\" : \"0fr\", opacity: bad.length ? 1 : 0, transition: reduced ? \"none\" : `grid-template-columns 420ms ${EASE}, opacity 260ms ${EASE}` }}\n            >\n              <span className=\"min-w-0 overflow-clip\">\n                <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\">\n                  <span className=\"size-1.5 rounded-full bg-amber-500\" aria-hidden=\"true\" />\n                  <NumberRoll value={bad.length || 1} />\n                  &nbsp;need a look\n                </span>\n              </span>\n            </span>\n          </div>\n\n          <Fold open={listed.length > 0} reduced={reduced}>\n            <div className=\"mt-3 max-h-[19rem] overflow-y-auto overscroll-contain pr-1\">\n              {listed.map((i) => (\n                <ProblemRow key={i} index={i} row={rowAt(i)} problems={checks[i] ?? {}} fields={fields} fixed={!Object.keys(checks[i] ?? {}).length} reduced={reduced} onEdit={edit} />\n              ))}\n              {bad.length > SHOWN && <p className=\"py-1 text-center text-[11.5px] text-muted-foreground\">and {count(bad.length - SHOWN)} more like these</p>}\n            </div>\n            <div className=\"mt-2\">\n              <Switch on={skipBad} onToggle={() => setSkipBad((v) => !v)} label=\"Skip the rows that still need a look\" reduced={reduced} />\n            </div>\n          </Fold>\n          <Fold open={!bad.length && !fixing.length} reduced={reduced}>\n            <div className=\"mt-4 flex items-center gap-3 rounded-[12px] px-3 py-3 shadow-[inset_0_0_0_1px_var(--border)]\">\n              <Drawn reduced={reduced} className=\"size-7\" />\n              <p className=\"text-[12.5px]\">Every row looks right.</p>\n            </div>\n          </Fold>\n\n          <div className=\"mt-4 flex flex-wrap items-center justify-end gap-x-3 gap-y-2\">\n            {blocked && <p className=\"mr-auto text-[11.5px] text-amber-700 dark:text-amber-400\">Fix the rows above, or skip them.</p>}\n            <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}`}>\n              Back\n            </button>\n            <StatusButton\n              size=\"sm\"\n              labels={{ idle: `Import ${count(ready)} ${ready === 1 ? noun.one : many}` }}\n              onClick={() => !blocked && ready > 0 && run()}\n              aria-disabled={blocked || !ready || undefined}\n              className={blocked || !ready ? \"pointer-events-none opacity-50\" : \"\"}\n            />\n          </div>\n        </div>\n      );\n    }\n\n    if (s === \"import\") {\n      const share = progress.total ? progress.done / progress.total : 0;\n      return (\n        <div className=\"px-4 pb-5 @md:px-5\">\n          {header}\n          <div\n            className=\"mt-5 h-1 overflow-hidden rounded-full bg-muted\"\n            role=\"progressbar\"\n            aria-label={`Importing ${fileName}`}\n            aria-valuemin={0}\n            aria-valuemax={progress.total}\n            aria-valuenow={progress.done}\n          >\n            <span className=\"block h-full origin-left rounded-full bg-primary\" style={{ transform: `scaleX(${share})`, transition: reduced ? \"none\" : `transform 360ms ${EASE}` }} />\n          </div>\n          <div className=\"mt-3 flex items-center justify-between gap-3 text-[12.5px]\">\n            <p className=\"inline-flex items-baseline text-muted-foreground tabular-nums\">\n              <span className=\"font-medium text-foreground\">\n                <NumberRoll value={progress.done} />\n              </span>\n              &nbsp;of {count(progress.total)} {many}\n            </p>\n            <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}`}>\n              Cancel\n            </button>\n          </div>\n        </div>\n      );\n    }\n\n    const r = result;\n    return (\n      <div className=\"flex flex-col items-center px-4 pt-2 pb-5 text-center @md:px-5\">\n        <Drawn reduced={reduced} tone={r?.imported ? \"text-emerald-600 dark:text-emerald-400\" : \"text-amber-600 dark:text-amber-400\"} />\n        <p className=\"mt-3 text-[15px] font-medium tracking-[-0.01em] tabular-nums\">\n          {count(r?.imported ?? 0)} {r?.imported === 1 ? noun.one : many} imported\n        </p>\n        <p className=\"mt-1 text-[12px] text-muted-foreground\">\n          {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.`}\n        </p>\n        <div className=\"mt-4 flex flex-wrap items-center justify-center gap-2\">\n          {skippedHref && (\n            <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}`}>\n              <Download className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n              Skipped rows\n            </a>\n          )}\n          <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}`}>\n            Import another file\n          </button>\n        </div>\n      </div>\n    );\n  };\n\n  return (\n    <div className={`@container w-full rounded-[22px] border border-border bg-background text-foreground ${className}`} {...props}>\n      <div className=\"px-4 pt-4 pb-3.5 @md:px-5 @md:pt-5\">\n        <p className=\"text-[14px] font-medium tracking-[-0.01em]\">{title}</p>\n        <p className=\"mt-0.5 text-[12px] text-muted-foreground\">{description}</p>\n      </div>\n      <Slides step={step} dir={dir} reduced={reduced} render={render} />\n      <span className=\"sr-only\" role=\"status\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n"},{"path":"registry/csv-mapper.tsx","type":"registry:component","content":"// Hairline UI · csv-import · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { PillSelect, type PillOption } from \"./pill-select\";\n\n/* ─────────────────────────────────────────────────────────\n * CSV MAPPER: your file's columns against your fields\n *\n *   threads   on a wide card each match is a hairline thread\n *             from the file's column to your field, drawn in\n *             when it's made\n *   drag      carry a column (or its dot) onto a field to match\n *             them; the thread follows the pointer. Or click a\n *             column, then a field\n *   select    every field also has a plain select, the whole\n *             mapping on a narrow card and for the keyboard\n * ───────────────────────────────────────────────────────── */\n\nexport interface MapperField {\n  key: string;\n  label: string;\n  required?: boolean;\n}\n\nexport interface CsvMapperProps {\n  headers: string[];\n  /** A few values from each column. */\n  samples: string[][];\n  fields: MapperField[];\n  mapping: Record<string, number | null>;\n  onChange: (mapping: Record<string, number | null>) => void;\n  reduced: boolean;\n}\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\nconst curve = (x1: number, y1: number, x2: number, y2: number) => {\n  const dx = Math.max(24, (x2 - x1) / 2);\n  return `M${x1} ${y1} C${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;\n};\n\nfunction Thread({ d, reduced, lit }: { d: string; reduced: boolean; lit: boolean }) {\n  const ref = React.useRef<SVGPathElement>(null);\n  React.useLayoutEffect(() => {\n    if (!reduced) ref.current?.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 520, easing: EASE, fill: \"backwards\" });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, []);\n  return (\n    <path\n      ref={ref}\n      d={d}\n      fill=\"none\"\n      stroke=\"var(--primary)\"\n      strokeWidth={lit ? 1.5 : 1}\n      strokeOpacity={lit ? 0.9 : 0.5}\n      pathLength={1}\n      strokeDasharray={1}\n      strokeDashoffset={0}\n      style={{ transition: reduced ? \"none\" : \"stroke-opacity 200ms, stroke-width 200ms\" }}\n    />\n  );\n}\n\nexport function CsvMapper({ headers, samples, fields, mapping, onChange, reduced }: CsvMapperProps) {\n  const id = React.useId();\n  const boxRef = React.useRef<HTMLDivElement>(null);\n  const tempRef = React.useRef<SVGPathElement>(null);\n  const [wide, setWide] = React.useState(false);\n  const [lines, setLines] = React.useState<{ key: string; col: number; d: string }[]>([]);\n  const [picked, setPicked] = React.useState<number | null>(null);\n  const [carrying, setCarrying] = React.useState<number | null>(null);\n  const [hover, setHover] = React.useState<string | null>(null);\n  const [size, setSize] = React.useState({ w: 0, h: 0 });\n\n  const assign = (key: string, col: number | null) => {\n    const next: Record<string, number | null> = { ...mapping };\n    if (col !== null) for (const k of Object.keys(next)) if (next[k] === col) next[k] = null;\n    next[key] = col;\n    onChange(next);\n  };\n  const owner = (col: number) => fields.find((f) => mapping[f.key] === col);\n\n  // Where each thread runs: from a column's dot to its field's dot.\n  const measure = React.useCallback(() => {\n    const box = boxRef.current;\n    if (!box) return;\n    const w = box.offsetWidth;\n    const isWide = w >= 520;\n    setWide((v) => (v === isWide ? v : isWide));\n    setSize((s) => (s.w === w && s.h === box.offsetHeight ? s : { w, h: box.offsetHeight }));\n    if (!isWide) return setLines((l) => (l.length ? [] : l));\n    const r = box.getBoundingClientRect();\n    const next: { key: string; col: number; d: string }[] = [];\n    for (const f of fields) {\n      const col = mapping[f.key];\n      if (col === null || col === undefined) continue;\n      const a = box.querySelector<HTMLElement>(`[data-col-dot=\"${col}\"]`)?.getBoundingClientRect();\n      const b = box.querySelector<HTMLElement>(`[data-field-dot=\"${CSS.escape(f.key)}\"]`)?.getBoundingClientRect();\n      if (!a || !b) continue;\n      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) });\n    }\n    setLines((l) => (l.length === next.length && l.every((x, i) => x.d === next[i].d && x.key === next[i].key) ? l : next));\n  }, [fields, mapping]);\n\n  React.useLayoutEffect(() => {\n    measure();\n    const box = boxRef.current;\n    if (!box) return;\n    const observer = new ResizeObserver(measure);\n    observer.observe(box);\n    return () => observer.disconnect();\n  }, [measure]);\n\n  // Carrying a column: a loose thread follows the pointer, written straight to the path.\n  const carry = (col: number, e: React.PointerEvent) => {\n    if (!wide || e.button !== 0) return;\n    const box = boxRef.current;\n    const dot = box?.querySelector<HTMLElement>(`[data-col-dot=\"${col}\"]`);\n    if (!box || !dot) return;\n    const sx = e.clientX;\n    const sy = e.clientY;\n    let started = false;\n    const move = (ev: PointerEvent) => {\n      if (!started && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return;\n      if (!started) {\n        started = true;\n        setCarrying(col);\n      }\n      const r = box.getBoundingClientRect();\n      const a = dot.getBoundingClientRect();\n      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));\n      const over = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest<HTMLElement>(\"[data-field]\");\n      setHover((h) => (h === (over?.dataset.field ?? null) ? h : (over?.dataset.field ?? null)));\n    };\n    const up = (ev: PointerEvent) => {\n      window.removeEventListener(\"pointermove\", move);\n      window.removeEventListener(\"pointerup\", up);\n      window.removeEventListener(\"pointercancel\", up);\n      if (!started) return;\n      const over = (document.elementFromPoint(ev.clientX, ev.clientY) as HTMLElement | null)?.closest<HTMLElement>(\"[data-field]\");\n      if (over?.dataset.field) assign(over.dataset.field, col);\n      setCarrying(null);\n      setHover(null);\n      tempRef.current?.setAttribute(\"d\", \"\");\n      // The click that ends a carry shouldn't also pick the column.\n      window.addEventListener(\"click\", (c) => c.stopPropagation(), { capture: true, once: true });\n    };\n    window.addEventListener(\"pointermove\", move);\n    window.addEventListener(\"pointerup\", up);\n    window.addEventListener(\"pointercancel\", up);\n  };\n\n  React.useEffect(() => {\n    if (picked === null) return;\n    const onKey = (e: KeyboardEvent) => e.key === \"Escape\" && setPicked(null);\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, [picked]);\n\n  const lit = hover ?? (picked !== null ? owner(picked)?.key ?? null : null);\n  const options = React.useMemo<PillOption[]>(\n    () => [\n      { value: \"\", label: \"Don’t import\" },\n      ...headers.map((h, i) => ({ value: String(i), label: h || `Column ${i + 1}`, description: samples[i]?.filter(Boolean).slice(0, 2).join(\" · \") || \"Empty\" })),\n    ],\n    [headers, samples],\n  );\n\n  return (\n    <div ref={boxRef} className={`relative ${wide ? \"grid grid-cols-[minmax(0,1fr)_48px_minmax(0,1.1fr)]\" : \"\"}`}>\n      {wide && (\n        <>\n          <ol aria-label=\"Columns in your file\" className=\"space-y-1.5\">\n            {headers.map((h, i) => {\n              const used = owner(i);\n              const on = picked === i || carrying === i;\n              return (\n                <li key={i}>\n                  <button\n                    type=\"button\"\n                    data-col={i}\n                    aria-pressed={picked === i}\n                    onPointerDown={(e) => carry(i, e)}\n                    onClick={() => setPicked((p) => (p === i ? null : i))}\n                    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} ${\n                      on ? \"bg-accent shadow-[inset_0_0_0_1px_var(--ring)]\" : \"shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent/60\"\n                    }`}\n                  >\n                    <span className=\"min-w-0 flex-1\">\n                      <span className={`block truncate font-mono text-[12px] ${used ? \"text-foreground\" : \"text-muted-foreground\"}`}>{h || `Column ${i + 1}`}</span>\n                      <span className=\"block truncate text-[11px] text-muted-foreground\">{samples[i]?.filter(Boolean).slice(0, 2).join(\" · \") || \"Empty\"}</span>\n                    </span>\n                    <span\n                      data-col-dot={i}\n                      aria-hidden=\"true\"\n                      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)]\"}`}\n                    />\n                  </button>\n                </li>\n              );\n            })}\n          </ol>\n          <span aria-hidden=\"true\" />\n        </>\n      )}\n\n      <ol aria-label=\"Your fields\" className=\"space-y-1.5\">\n        {fields.map((f) => {\n          const col = mapping[f.key];\n          const target = picked !== null || carrying !== null;\n          const isLit = lit === f.key;\n          return (\n            <li\n              key={f.key}\n              data-field={f.key}\n              onClick={(e) => {\n                if (picked === null || (e.target as HTMLElement).closest('[aria-haspopup=\"listbox\"], [role=\"listbox\"]')) return;\n                assign(f.key, picked);\n                setPicked(null);\n              }}\n              className={`relative flex items-center gap-2 rounded-[10px] py-1.5 pr-1.5 pl-3 transition-[background-color,box-shadow] duration-200 ${\n                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)]\"\n              }`}\n            >\n              {wide && (\n                <span\n                  data-field-dot={f.key}\n                  aria-hidden=\"true\"\n                  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)]\"}`}\n                />\n              )}\n              <span className=\"min-w-0 flex-1\">\n                <span id={`${id}-${f.key}`} className=\"block truncate text-[12.5px] font-medium\">\n                  {f.label}\n                  {f.required && <span className=\"text-muted-foreground\"> *</span>}\n                </span>\n                {!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>}\n              </span>\n              <span className=\"flex w-[45%] max-w-[11rem] shrink-0\">\n                <PillSelect\n                  block\n                  mono\n                  align=\"end\"\n                  width={250}\n                  label={`Column for ${f.label}`}\n                  options={options}\n                  value={col === null || col === undefined ? \"\" : String(col)}\n                  onValueChange={(v) => assign(f.key, v === \"\" ? null : Number(v))}\n                  muted={col === null || col === undefined}\n                />\n              </span>\n            </li>\n          );\n        })}\n      </ol>\n\n      {wide && (\n        <svg aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 overflow-visible\" width={size.w} height={size.h}>\n          {lines.map((l) => (\n            <Thread key={`${l.key}:${l.col}`} d={l.d} reduced={reduced} lit={lit === l.key} />\n          ))}\n          <path ref={tempRef} d=\"\" fill=\"none\" stroke=\"var(--primary)\" strokeWidth={1.5} strokeDasharray=\"3 3\" style={{ opacity: carrying !== null ? 0.9 : 0 }} />\n        </svg>\n      )}\n      <p className=\"sr-only\" aria-live=\"polite\">\n        {picked !== null ? `${headers[picked] || `Column ${picked + 1}`} picked. Choose the field it fills.` : \"\"}\n      </p>\n    </div>\n  );\n}\n"},{"path":"registry/csv-parse.ts","type":"registry:component","content":"// Hairline UI · csv-import · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n/* ─────────────────────────────────────────────────────────\n * CSV PARSE: reading, matching and writing CSV for csv-import\n *\n *   parse     RFC 4180: quoted fields, doubled quotes, commas\n *             and line breaks inside quotes, CRLF or LF, a\n *             byte-order mark. The separator (comma, semicolon,\n *             tab) is taken from the first line. Long files are\n *             read in slices with a pause between, so the page\n *             keeps drawing\n *   match     a column matches a field when their names agree\n *             once case, spaces and punctuation are ignored\n *             (\"E-mail address\" → email), or one contains the\n *             other\n *   write     the rows you skipped, back out as CSV\n * ───────────────────────────────────────────────────────── */\n\nexport interface CsvMatchable {\n  key: string;\n  label: string;\n  /** Other names the column might have, e.g. [\"e-mail\", \"email address\"]. */\n  match?: string[];\n}\n\nconst SLICE = 200_000;\nconst pause = () => new Promise<void>((r) => setTimeout(r, 0));\n\n/** The separator the first line uses most, outside quotes. */\nexport function detectDelimiter(text: string): string {\n  const counts: Record<string, number> = { \",\": 0, \";\": 0, \"\\t\": 0 };\n  let quoted = false;\n  for (let i = 0; i < text.length && i < 10_000; i++) {\n    const c = text[i];\n    if (c === '\"') quoted = !quoted;\n    else if (!quoted && (c === \"\\n\" || c === \"\\r\")) break;\n    else if (!quoted && c in counts) counts[c]++;\n  }\n  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] : \",\";\n}\n\n/** Every row as an array of fields; empty lines are dropped. */\nexport async function parseCsv(input: string, options: { delimiter?: string; onProgress?: (fraction: number) => void; signal?: AbortSignal } = {}): Promise<string[][]> {\n  const text = input.charCodeAt(0) === 0xfeff ? input.slice(1) : input;\n  const d = options.delimiter ?? detectDelimiter(text);\n  const rows: string[][] = [];\n  let row: string[] = [];\n  let field = \"\";\n  let quoted = false;\n  let start = 0;\n  const n = text.length;\n  for (let i = 0; i < n; i++) {\n    const c = text.charCodeAt(i);\n    if (quoted) {\n      if (c === 34) {\n        if (text.charCodeAt(i + 1) === 34) {\n          field += text.slice(start, i + 1);\n          i++;\n          start = i + 1;\n        } else {\n          field += text.slice(start, i);\n          quoted = false;\n          start = i + 1;\n        }\n      }\n    } else if (c === 34 && field === \"\" && start === i) {\n      quoted = true;\n      start = i + 1;\n    } else if (text[i] === d) {\n      row.push(field + text.slice(start, i));\n      field = \"\";\n      start = i + 1;\n    } else if (c === 10 || c === 13) {\n      row.push(field + text.slice(start, i));\n      if (row.length > 1 || row[0] !== \"\") rows.push(row);\n      row = [];\n      field = \"\";\n      if (c === 13 && text.charCodeAt(i + 1) === 10) i++;\n      start = i + 1;\n    }\n    if (i % SLICE === 0 && i) {\n      if (options.signal?.aborted) throw new DOMException(\"Aborted\", \"AbortError\");\n      options.onProgress?.(i / n);\n      await pause();\n    }\n  }\n  if (start < n || field || row.length) {\n    row.push(field + text.slice(start, quoted ? n : n));\n    if (row.length > 1 || row[0] !== \"\") rows.push(row);\n  }\n  options.onProgress?.(1);\n  return rows;\n}\n\nconst norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, \"\");\n\n/** Which column (by index) each field should take, or null. Each column is used once. */\nexport function autoMatch(headers: string[], fields: CsvMatchable[]): Record<string, number | null> {\n  const used = new Set<number>();\n  const out: Record<string, number | null> = {};\n  const heads = headers.map(norm);\n  for (const f of fields) {\n    const names = [f.key, f.label, ...(f.match ?? [])].map(norm).filter(Boolean);\n    let hit = heads.findIndex((h, i) => !used.has(i) && names.includes(h));\n    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))));\n    out[f.key] = hit === -1 ? null : hit;\n    if (hit !== -1) used.add(hit);\n  }\n  return out;\n}\n\n/** Rows back out as CSV text, quoting what needs it. */\nexport function toCsv(rows: string[][]): string {\n  const esc = (v: string) => (/[\",\\r\\n]/.test(v) ? `\"${v.replace(/\"/g, '\"\"')}\"` : v);\n  return rows.map((r) => r.map(esc).join(\",\")).join(\"\\r\\n\");\n}\n"},{"path":"registry/pill-select.tsx","type":"registry:component","content":"// Hairline UI · csv-import · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { TextMorph } from \"./text-morph\";\n\n/* ─────────────────────────────────────────────────────────\n * PILL SELECT: a select that's the same surface as its pill\n *\n *   open      the pill grows into its list (size and corners\n *             ease) from the edge it's lined up with, while its\n *             label fades into the options; it opens upward when\n *             there's no room below, and long lists scroll\n *   pick      the list folds back into the pill and the label\n *             morphs to the new choice; the current option\n *             carries a drawn check\n *   keys      arrows move a highlight that glides between rows,\n *             Home and End jump, typing a few letters finds an\n *             option, Enter picks, Escape folds it back\n *\n * The list lives in a portal on the page's body, placed over\n * the pill, so a clipping toolbar, fold or scroller never cuts\n * it off; it carries the theme's colours with it. Scrolling\n * the page closes it.\n *\n * Used wherever a component needs a choice from a list; never\n * the browser's own dropdown, which can't follow the theme.\n * ───────────────────────────────────────────────────────── */\n\nexport interface PillOption {\n  value: string;\n  label: string;\n  /** A second line in the list, e.g. a sample value. */\n  description?: string;\n}\n\nexport interface PillSelectProps {\n  options: PillOption[];\n  value: string | null | undefined;\n  onValueChange: (value: string) => void;\n  /** Accessible name, e.g. \"Column for Email\". */\n  label: string;\n  /** Shown while nothing is chosen. */\n  placeholder?: string;\n  /** What the pill shows for a choice; the option's label by default. */\n  display?: (option: PillOption | undefined) => string;\n  /** The pill's contents, when it needs more than text (an icon that turns). */\n  renderValue?: (option: PillOption | undefined, open: boolean) => React.ReactNode;\n  /** Monospace labels, for code-like values (event names, column headers). */\n  mono?: boolean;\n  /** Which edge of the pill the list lines up with. */\n  align?: \"start\" | \"end\";\n  /** The list's width; never narrower than the pill. */\n  width?: number;\n  /** Fill the width it's given. */\n  block?: boolean;\n  /** Dims the pill's text, e.g. for \"Don't import\". */\n  muted?: boolean;\n  disabled?: boolean;\n  className?: string;\n}\n\ntype Phase = \"closed\" | \"opening\" | \"open\" | \"closing\";\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst DURATION = 420;\nconst MAX_LIST = 280;\n// The theme travels with the list, so it matches the pill wherever the pill's colours were set.\nconst TOKENS = [\"--background\", \"--foreground\", \"--popover\", \"--popover-foreground\", \"--border\", \"--accent\", \"--accent-foreground\", \"--primary\", \"--primary-foreground\", \"--muted\", \"--muted-foreground\", \"--ring\"];\nconst FOCUS = \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\";\n\nconst reducedQuery = \"(prefers-reduced-motion: reduce)\";\nconst subscribeReduced = (onChange: () => void) => {\n  const query = window.matchMedia(reducedQuery);\n  query.addEventListener(\"change\", onChange);\n  return () => query.removeEventListener(\"change\", onChange);\n};\nconst useReducedMotion = () =>\n  React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);\n\nfunction Chevron({ up, reduced }: { up: boolean; reduced: boolean }) {\n  return (\n    <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}` }}>\n      <path d=\"M4.5 6.25 8 9.75l3.5-3.5\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n    </svg>\n  );\n}\n\nexport function PillSelect({\n  options,\n  value,\n  onValueChange,\n  label,\n  placeholder = \"Choose\",\n  display,\n  renderValue,\n  mono = false,\n  align = \"start\",\n  width = 240,\n  block = false,\n  muted = false,\n  disabled = false,\n  className = \"\",\n}: PillSelectProps) {\n  const id = React.useId();\n  const reduced = useReducedMotion();\n  const [phase, setPhase] = React.useState<Phase>(\"closed\");\n  const [active, setActive] = React.useState(0);\n  const [pill, setPill] = React.useState({ width: 0, height: 28 });\n  const [panelHeight, setPanelHeight] = React.useState(0);\n  const [fit, setFit] = React.useState({ up: false, width, shift: 0, x: 0, y: 0, theme: {} as Record<string, string> });\n  const [highlight, setHighlight] = React.useState({ top: 0, height: 0, ready: false });\n  const [quiet, setQuiet] = React.useState(false);\n  const wrapRef = React.useRef<HTMLDivElement>(null);\n  const triggerRef = React.useRef<HTMLButtonElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const surfaceRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLDivElement>(null);\n  const rowRefs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const timer = React.useRef(0);\n  const typed = React.useRef({ text: \"\", at: 0 });\n  const selected = options.find((o) => o.value === value);\n  const expanded = phase === \"open\";\n  const mounted = phase !== \"closed\";\n  const text = display ? display(selected) : (selected?.label ?? placeholder);\n\n  const measureFit = () => {\n    const wrap = wrapRef.current;\n    if (!wrap) return;\n    const rect = wrap.getBoundingClientRect();\n    const vw = document.documentElement.clientWidth;\n    const w = Math.min(Math.max(width, rect.width), vw - 24);\n    // Lined up with the pill's edge, nudged back on screen.\n    const shift = align === \"start\" ? Math.min(0, vw - 12 - (rect.left + w)) : Math.max(0, 12 - (rect.right - w));\n    const estimate = Math.min(MAX_LIST, options.length * (options.some((o) => o.description) ? 46 : 32)) + 12;\n    const vh = window.innerHeight;\n    const below = vh - 8 - rect.top;\n    const above = rect.bottom - 8;\n    const up = below < estimate + 8 && above > below;\n    // Fixed coordinates of the pill's corner the list grows from.\n    const x = align === \"start\" ? rect.left : vw - rect.right;\n    const y = up ? vh - rect.bottom : rect.top;\n    const cs = getComputedStyle(wrap);\n    const theme: Record<string, string> = { colorScheme: cs.colorScheme, fontFamily: cs.fontFamily };\n    for (const t of TOKENS) {\n      const v = cs.getPropertyValue(t);\n      if (v) theme[t] = v;\n    }\n    setFit({ up, width: w, shift, x, y, theme });\n  };\n\n  const open = () => {\n    if (disabled || phase === \"open\" || phase === \"opening\") return;\n    window.clearTimeout(timer.current);\n    measureFit();\n    setActive(Math.max(0, options.findIndex((o) => o.value === value)));\n    setPhase(reduced ? \"open\" : \"opening\");\n  };\n\n  const close = React.useCallback(\n    (refocus = true) => {\n      window.clearTimeout(timer.current);\n      if (refocus) triggerRef.current?.focus({ preventScroll: true });\n      if (reduced) return setPhase(\"closed\");\n      setPhase(\"closing\");\n      timer.current = window.setTimeout(() => setPhase(\"closed\"), DURATION);\n    },\n    [reduced],\n  );\n\n  React.useEffect(() => {\n    if (phase !== \"opening\") return;\n    let inner = 0;\n    const outer = requestAnimationFrame(() => (inner = requestAnimationFrame(() => setPhase(\"open\"))));\n    return () => {\n      cancelAnimationFrame(outer);\n      cancelAnimationFrame(inner);\n    };\n  }, [phase]);\n  React.useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  React.useLayoutEffect(() => {\n    const trigger = triggerRef.current;\n    if (!trigger) return;\n    const measure = () => setPill((p) => (p.width === trigger.offsetWidth && p.height === trigger.offsetHeight ? p : { width: trigger.offsetWidth, height: trigger.offsetHeight }));\n    measure();\n    const observer = new ResizeObserver(measure);\n    observer.observe(trigger);\n    return () => observer.disconnect();\n  }, []);\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current;\n    if (panel) setPanelHeight(panel.offsetHeight);\n  }, [mounted, options]);\n\n  // The highlight glides to the active row, and the list scrolls to keep it in view.\n  React.useLayoutEffect(() => {\n    const row = rowRefs.current[active];\n    const list = listRef.current;\n    if (!row || !list) return;\n    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 }));\n    const scroller = list.parentElement;\n    if (scroller && scroller.scrollHeight > scroller.clientHeight) {\n      if (row.offsetTop < scroller.scrollTop) scroller.scrollTop = row.offsetTop - 4;\n      else if (row.offsetTop + row.offsetHeight > scroller.scrollTop + scroller.clientHeight) scroller.scrollTop = row.offsetTop + row.offsetHeight - scroller.clientHeight + 4;\n    }\n  }, [active, expanded, mounted]);\n\n  React.useEffect(() => {\n    if (!mounted) setHighlight({ top: 0, height: 0, ready: false });\n  }, [mounted]);\n\n  React.useEffect(() => {\n    if (expanded) listRef.current?.focus({ preventScroll: true });\n  }, [expanded]);\n\n  React.useEffect(() => {\n    if (!expanded) return;\n    const inside = (n: Node) => Boolean(wrapRef.current?.contains(n) || surfaceRef.current?.contains(n));\n    const onDown = (e: PointerEvent) => {\n      if (!inside(e.target as Node)) close(false);\n    };\n    // The list sits where the pill was; if the page moves under it, it goes at once rather than fold in the wrong place.\n    const gone = () => {\n      window.clearTimeout(timer.current);\n      setPhase(\"closed\");\n    };\n    const onScroll = (e: Event) => {\n      if (!surfaceRef.current?.contains(e.target as Node)) gone();\n    };\n    const onResize = gone;\n    document.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"scroll\", onScroll, true);\n    window.addEventListener(\"resize\", onResize);\n    return () => {\n      document.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"scroll\", onScroll, true);\n      window.removeEventListener(\"resize\", onResize);\n    };\n  }, [expanded, close]);\n\n  const choose = (o: PillOption) => {\n    close();\n    if (o.value !== value) onValueChange(o.value);\n  };\n\n  const onListKey = (e: React.KeyboardEvent) => {\n    const last = options.length - 1;\n    const move = (to: number) => {\n      e.preventDefault();\n      setActive(Math.max(0, Math.min(last, to)));\n    };\n    if (e.key === \"ArrowDown\") move(active === last ? 0 : active + 1);\n    else if (e.key === \"ArrowUp\") move(active === 0 ? last : active - 1);\n    else if (e.key === \"Home\") move(0);\n    else if (e.key === \"End\") move(last);\n    else if (e.key === \"PageDown\") move(active + 6);\n    else if (e.key === \"PageUp\") move(active - 6);\n    else if (e.key === \"Enter\" || e.key === \" \") {\n      e.preventDefault();\n      if (options[active]) choose(options[active]);\n    } else if (e.key === \"Tab\") close(false);\n    else if (e.key.length === 1 && /\\S/.test(e.key)) {\n      // A few letters typed quickly find the option that starts with them.\n      const now = performance.now();\n      typed.current = { text: (now - typed.current.at < 700 ? typed.current.text : \"\") + e.key.toLowerCase(), at: now };\n      const hit = options.findIndex((o) => o.label.toLowerCase().startsWith(typed.current.text));\n      if (hit !== -1) setActive(hit);\n    }\n  };\n\n  const w = expanded ? fit.width : pill.width;\n  const h = expanded ? panelHeight : pill.height;\n  const listId = `${id}-list`;\n  const pillText = renderValue ? null : (\n    <>\n      <span className={`min-w-0 flex-1 truncate text-left ${mono ? \"font-mono text-[11.5px]\" : \"\"} ${muted || !selected ? \"text-muted-foreground\" : \"text-foreground\"}`}>\n        {block ? text : <TextMorph>{text}</TextMorph>}\n      </span>\n      <Chevron up={expanded} reduced={reduced} />\n    </>\n  );\n\n  return (\n    <div\n      ref={wrapRef}\n      className={`relative ${block ? \"flex w-full min-w-0\" : \"inline-flex\"} ${className}`}\n      onKeyDown={(e) => {\n        setQuiet(false);\n        if (e.key === \"Escape\" && expanded) {\n          e.preventDefault();\n          e.stopPropagation();\n          close();\n        }\n      }}\n      onBlur={(e) => {\n        const to = e.relatedTarget as Node | null;\n        if (expanded && document.hasFocus() && !(to && (wrapRef.current?.contains(to) || surfaceRef.current?.contains(to)))) close(false);\n      }}\n    >\n      <button\n        ref={triggerRef}\n        type=\"button\"\n        aria-haspopup=\"listbox\"\n        aria-expanded={expanded}\n        aria-controls={mounted ? listId : undefined}\n        aria-label={`${label}: ${selected?.label ?? placeholder}`}\n        disabled={disabled}\n        onClick={() => (expanded ? close() : open())}\n        onKeyDown={(e) => {\n          if (e.key === \"ArrowDown\" || e.key === \"ArrowUp\") {\n            e.preventDefault();\n            open();\n          }\n        }}\n        onPointerDown={() => setQuiet(true)}\n        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}`}\n      >\n        {renderValue ? renderValue(selected, expanded) : pillText}\n      </button>\n\n      {mounted &&\n        createPortal(\n        <div\n          ref={surfaceRef}\n          onKeyDown={(e) => {\n            if (e.key === \"Escape\") {\n              e.preventDefault();\n              e.stopPropagation();\n              close();\n            }\n          }}\n          onPointerDown={(e) => {\n            if (!(e.target as HTMLElement).closest(\"[data-scroll]\")) e.preventDefault();\n            setQuiet(true);\n          }}\n          className=\"fixed z-[60] overflow-hidden bg-popover text-popover-foreground\"\n          style={{\n            ...fit.theme,\n            [fit.up ? \"bottom\" : \"top\"]: fit.y,\n            [align === \"start\" ? \"left\" : \"right\"]: fit.x + (expanded ? (align === \"start\" ? fit.shift : -fit.shift) : 0),\n            width: w,\n            height: h,\n            borderRadius: expanded ? 16 : pill.height / 2,\n            // The pill's own hairline, the whole way: it is the same surface.\n            boxShadow: \"inset 0 0 0 1px var(--border)\",\n            transition: reduced ? \"none\" : `width ${DURATION}ms ${EASE}, height ${DURATION}ms ${EASE}, left ${DURATION}ms ${EASE}, right ${DURATION}ms ${EASE}, border-radius ${DURATION}ms ${EASE}`,\n          }}\n        >\n          {/* The pill's label, fading out as the surface grows and back in as it folds. */}\n          <span\n            aria-hidden=\"true\"\n            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\"}`}\n            style={{ width: pill.width, opacity: expanded ? 0 : 1, transition: reduced ? \"none\" : `opacity ${expanded ? 120 : 260}ms ${EASE} ${expanded ? 0 : 120}ms` }}\n          >\n            {renderValue ? renderValue(selected, expanded) : pillText}\n          </span>\n          <div\n            ref={panelRef}\n            className={`absolute p-1 ${fit.up ? \"bottom-0\" : \"top-0\"} ${align === \"start\" ? \"left-0\" : \"right-0\"}`}\n            style={{ width: fit.width, opacity: expanded ? 1 : 0, transition: reduced ? \"none\" : `opacity ${expanded ? 260 : 120}ms ${EASE} ${expanded ? 90 : 0}ms` }}\n          >\n            <div data-scroll=\"\" className=\"overflow-y-auto overscroll-contain\" style={{ maxHeight: MAX_LIST }}>\n              <div\n                ref={listRef}\n                id={listId}\n                role=\"listbox\"\n                tabIndex={-1}\n                aria-label={label}\n                aria-activedescendant={`${id}-o${active}`}\n                onKeyDown={onListKey}\n                className=\"relative outline-none\"\n              >\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-x-0 top-0 rounded-[12px] bg-accent\"\n                  style={{\n                    height: highlight.height,\n                    transform: `translateY(${highlight.top}px)`,\n                    opacity: highlight.ready ? 1 : 0,\n                    transition: highlight.ready && !reduced ? `transform 260ms ${EASE}, height 260ms ${EASE}, opacity 200ms` : \"none\",\n                  }}\n                />\n                {options.map((o, i) => {\n                  const on = o.value === value;\n                  return (\n                    <div\n                      key={o.value}\n                      ref={(el) => {\n                        rowRefs.current[i] = el;\n                      }}\n                      id={`${id}-o${i}`}\n                      role=\"option\"\n                      aria-selected={on}\n                      onPointerMove={() => setActive(i)}\n                      onClick={() => choose(o)}\n                      className=\"relative flex cursor-pointer items-start gap-2 rounded-[12px] px-2.5 py-1.5\"\n                    >\n                      <span className=\"min-w-0 flex-1\">\n                        <span className={`block truncate ${mono ? \"font-mono text-[12px]\" : \"text-[12.5px] font-medium\"}`}>{o.label}</span>\n                        {o.description && <span className=\"mt-0.5 block truncate text-[11.5px] text-muted-foreground\">{o.description}</span>}\n                      </span>\n                      <svg viewBox=\"0 0 16 16\" fill=\"none\" aria-hidden=\"true\" className=\"mt-0.5 size-3.5 shrink-0\">\n                        <path\n                          d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                          stroke=\"currentColor\"\n                          strokeWidth=\"1.8\"\n                          strokeLinecap=\"round\"\n                          strokeLinejoin=\"round\"\n                          pathLength={1}\n                          strokeDasharray={1}\n                          style={{ strokeDashoffset: on && expanded ? 0 : 1, transition: reduced ? \"none\" : `stroke-dashoffset 380ms ${EASE} 160ms` }}\n                        />\n                      </svg>\n                    </div>\n                  );\n                })}\n              </div>\n            </div>\n          </div>\n        </div>,\n          document.body,\n        )}\n    </div>\n  );\n}\n"}],"css":{},"docs":"Docs and live preview: https://hairlineui.com/ui/csv-import. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}