{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "import-1",
  "title": "Import 1: CSV import with column matching",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A data import in four steps: choose a CSV, match its columns to your fields (found for you where names are alike), review which rows are ready and which have problems and why, then import with progress and a summary.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/progress.json",
    "https://ui.ballmac.com/r/select.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/state-view.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/blocks/import-1/import-1.tsx",
      "content": "// Ballmac UI: Import 1. https://ui.ballmac.com/blocks/import-1\n\"use client\"\n\nimport * as React from \"react\"\nimport { ArrowLeft, ArrowRight, Check, CircleAlert, CircleCheck, FileSpreadsheet, Loader2, UploadCloud } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Progress } from \"@/components/ballmac/progress\"\nimport { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from \"@/components/ballmac/select\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { frameProps, StateView, type StateProps } from \"@/components/ballmac/state-view\"\nimport { cn } from \"@/lib/utils\"\n\ntype Import1Field = {\n  key: string\n  label: string\n  required?: boolean\n  /** Return a short reason when a value is not acceptable, or null when it is. Empty values are handled by `required`. */\n  validate?: (value: string) => string | null\n  /** Other names a column might have, used when matching automatically. */\n  aliases?: string[]\n}\n\ntype Import1Row = Record<string, string>\n\ntype Import1Props = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSubmit\"> &\n  StateProps & {\n    title?: string\n    description?: string\n    fields?: Import1Field[]\n    /** CSV text used by the \"Try a sample file\" button. */\n    sampleCsv?: string\n    /** Called with the rows that passed. Return a promise; reject to show a message. Without it, progress is simulated for demos. */\n    onImport?: (rows: Import1Row[], progress: (done: number) => void) => void | Promise<void>\n    /** Start on this step with the sample file already read (0 to 3). */\n    defaultStep?: 0 | 1 | 2 | 3\n  }\n\nconst defaultFields: Import1Field[] = [\n  { key: \"name\", label: \"Name\", required: true, aliases: [\"full name\", \"contact\", \"customer\"] },\n  { key: \"email\", label: \"Email\", required: true, aliases: [\"e-mail\", \"email address\", \"mail\"], validate: (v) => (/^\\S+@\\S+\\.\\S+$/.test(v) ? null : \"Not an email address\") },\n  { key: \"company\", label: \"Company\", aliases: [\"organisation\", \"organization\", \"employer\"] },\n  { key: \"phone\", label: \"Phone\", aliases: [\"mobile\", \"telephone\", \"tel\"], validate: (v) => (v.replace(/\\D/g, \"\").length >= 7 ? null : \"Too short for a phone number\") },\n  { key: \"plan\", label: \"Plan\", aliases: [\"tier\", \"subscription\"] },\n]\n\nconst sample = `Full Name,E-mail,Organisation,Mobile,Tier\nHannah Brooks,hannah@brooks.studio,Brooks Studio,+44 20 7946 0958,Team\nMarcus Webb,marcus@webbagency.com,Webb Agency,+1 212 555 0148,Business\nElif Demir,elif@demir.co,Demir & Co,+90 212 555 0177,Team\nTom Alvarez,tom.alvarez@@ops,Alvarez Ops,+34 91 555 0123,Starter\n,aisha@rahman.io,Rahman Labs,+44 20 7946 0000,Team\nJonas Keller,jonas@keller.dev,Keller Software,12,Business\nPriya Nair,priya@nair.me,,+91 22 5550 0188,Starter\nLucas Moreau,lucas@moreau.fr,Moreau Marketing,+33 1 55 50 01 77,Team\nHannah Brooks,hannah@brooks.studio,Brooks Studio,+44 20 7946 0958,Team\nSofia Rossi,sofia@rossi.it,Rossi Design,+39 02 5550 0144,Business`\n\n// A small CSV reader: quoted fields, doubled quotes, commas and line breaks inside quotes.\nfunction parseCsv(text: string): string[][] {\n  const rows: string[][] = []\n  let row: string[] = []\n  let cell = \"\"\n  let q = false\n  for (let i = 0; i < text.length; i++) {\n    const c = text[i]!\n    if (q) {\n      if (c === '\"' && text[i + 1] === '\"') { cell += '\"'; i++ }\n      else if (c === '\"') q = false\n      else cell += c\n    } else if (c === '\"') q = true\n    else if (c === \",\") { row.push(cell); cell = \"\" }\n    else if (c === \"\\n\" || c === \"\\r\") {\n      if (c === \"\\r\" && text[i + 1] === \"\\n\") i++\n      row.push(cell); cell = \"\"\n      if (row.some((x) => x.trim())) rows.push(row)\n      row = []\n    } else cell += c\n  }\n  row.push(cell)\n  if (row.some((x) => x.trim())) rows.push(row)\n  return rows\n}\n\nconst norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, \"\")\nfunction autoMatch(headers: string[], fields: Import1Field[]) {\n  const used = new Set<number>()\n  const out: Record<string, number | null> = {}\n  for (const f of fields) {\n    const names = [f.key, f.label, ...(f.aliases ?? [])].map(norm)\n    const i = headers.findIndex((h, idx) => !used.has(idx) && names.includes(norm(h)))\n    out[f.key] = i >= 0 ? i : null\n    if (i >= 0) used.add(i)\n  }\n  return out\n}\n\nconst stepNames = [\"Upload\", \"Match columns\", \"Review\", \"Import\"]\n\nfunction Skel() {\n  return (\n    <div aria-hidden=\"true\" className=\"bg-card rounded-3xl border p-8\">\n      <Skeleton className=\"h-6 w-60\" />\n      <Skeleton className=\"mt-8 h-48 rounded-2xl\" />\n    </div>\n  )\n}\n\n/**\n * A data import in four steps: choose a CSV file, match its columns to your fields (found for you where the names are alike), review which rows\n * are ready and which have problems and why, then import with progress and a summary. Missing required values, bad emails or phones and duplicate\n * rows are all caught before anything is sent, and a failure keeps you on the review with the rows intact.\n */\nfunction Import1({\n  title = \"Import contacts\",\n  description = \"Bring in a spreadsheet saved as CSV. We will check it before anything is added.\",\n  fields = defaultFields,\n  sampleCsv = sample,\n  onImport,\n  defaultStep = 0,\n  state = \"ready\",\n  onRetry,\n  errorState,\n  className,\n  ...props\n}: Import1Props) {\n  const id = React.useId()\n  const [step, setStep] = React.useState<number>(defaultStep)\n  const [fileName, setFileName] = React.useState(defaultStep ? \"contacts.csv\" : \"\")\n  const [table, setTable] = React.useState<string[][]>(() => (defaultStep ? parseCsv(sampleCsv) : []))\n  const [map, setMap] = React.useState<Record<string, number | null>>(() => (defaultStep ? autoMatch(parseCsv(sampleCsv)[0] ?? [], fields) : {}))\n  const [onlyProblems, setOnlyProblems] = React.useState(false)\n  const [drag, setDrag] = React.useState(false)\n  const [readError, setReadError] = React.useState<string>()\n  const [done, setDone] = React.useState(0)\n  const [phase, setPhase] = React.useState<\"idle\" | \"running\" | \"done\" | \"error\">(\"idle\")\n  const [message, setMessage] = React.useState<string>()\n  const headingRef = React.useRef<HTMLHeadingElement>(null)\n  const first = React.useRef(true)\n  const input = React.useRef<HTMLInputElement>(null)\n\n  React.useEffect(() => {\n    if (first.current) first.current = false\n    else headingRef.current?.focus()\n  }, [step])\n\n  const headers = table[0] ?? []\n  const body = table.slice(1)\n  const missing = fields.filter((f) => f.required && map[f.key] == null)\n\n  const rows = React.useMemo(() => {\n    const seen = new Map<string, number>()\n    const emailKey = fields.find((f) => f.key === \"email\")?.key\n    return body.map((r, i) => {\n      const values: Import1Row = {}\n      const issues: string[] = []\n      for (const f of fields) {\n        const c = map[f.key]\n        const v = c == null ? \"\" : (r[c] ?? \"\").trim()\n        values[f.key] = v\n        if (!v && f.required) issues.push(`${f.label} is missing`)\n        else if (v && f.validate) {\n          const bad = f.validate(v)\n          if (bad) issues.push(`${f.label}: ${bad}`)\n        }\n      }\n      if (emailKey && values[emailKey]) {\n        const k = values[emailKey]!.toLowerCase()\n        if (seen.has(k)) issues.push(`Same email as row ${seen.get(k)! + 2}`)\n        else seen.set(k, i)\n      }\n      return { n: i + 2, values, issues }\n    })\n  }, [body, fields, map])\n  const good = rows.filter((r) => !r.issues.length)\n  const bad = rows.length - good.length\n\n  function read(text: string, name: string) {\n    const t = parseCsv(text)\n    if (t.length < 2) return setReadError(\"That file has no rows under the header. Check it is a CSV with a first row of column names.\")\n    setReadError(undefined)\n    setTable(t)\n    setFileName(name)\n    setMap(autoMatch(t[0]!, fields))\n    setStep(1)\n  }\n  async function file(f?: File | null) {\n    if (!f) return\n    if (!/\\.csv$|text\\/csv|text\\/plain/i.test(f.name + f.type)) return setReadError(\"Choose a CSV file. In your spreadsheet, use Save as and pick CSV.\")\n    read(typeof f.text === \"function\" ? await f.text() : await new Promise<string>((res) => { const r = new FileReader(); r.onload = () => res(String(r.result ?? \"\")); r.readAsText(f) }), f.name)\n  }\n\n  async function run() {\n    setPhase(\"running\")\n    setDone(0)\n    setMessage(undefined)\n    try {\n      if (onImport) await onImport(good.map((r) => r.values), (n) => setDone(n))\n      else {\n        for (let i = 1; i <= 10; i++) {\n          await new Promise((r) => setTimeout(r, 140))\n          setDone(Math.round((good.length * i) / 10))\n        }\n      }\n      setDone(good.length)\n      setPhase(\"done\")\n    } catch (e) {\n      setMessage(e instanceof Error ? e.message : undefined)\n      setPhase(\"error\")\n    }\n  }\n\n  const reset = () => {\n    setStep(0)\n    setTable([])\n    setFileName(\"\")\n    setPhase(\"idle\")\n    setDone(0)\n  }\n\n  const stepper = (\n    <ol aria-label=\"Steps\" className=\"mb-8 flex items-center gap-2\">\n      {stepNames.map((n, i) => (\n        <li key={n} aria-current={i === step ? \"step\" : undefined} className=\"flex flex-1 items-center gap-2 last:flex-none\">\n          <span className={cn(\"flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-semibold tabular-nums\", i < step && \"bg-foreground text-background border-foreground\", i === step && \"border-foreground\")}>\n            {i < step ? <Check aria-hidden=\"true\" className=\"size-4\" /> : i + 1}\n          </span>\n          <span className={cn(\"hidden text-sm sm:block\", i === step ? \"font-semibold\" : \"text-muted-foreground\")}>\n            {n}\n            <span className=\"sr-only\">{i < step ? \", done\" : i === step ? \", current step\" : \"\"}</span>\n          </span>\n          {i < stepNames.length - 1 ? <span aria-hidden=\"true\" className={cn(\"h-px flex-1\", i < step ? \"bg-foreground\" : \"bg-border\")} /> : null}\n        </li>\n      ))}\n    </ol>\n  )\n\n  const head = (text: string, sub?: string) => (\n    <>\n      <h2 ref={headingRef} tabIndex={-1} className=\"text-2xl font-semibold tracking-tight outline-none\">\n        {text}\n      </h2>\n      {sub ? <p className=\"text-muted-foreground mt-1.5 text-pretty\">{sub}</p> : null}\n    </>\n  )\n\n  const content = (\n    <div className=\"bg-card rounded-3xl border p-5 sm:p-8\">\n      {stepper}\n\n      {step === 0 ? (\n        <div>\n          {head(\"Choose a file\", \"A CSV with a first row of column names. Up to 10,000 rows.\")}\n          <div\n            onDragOver={(e) => { e.preventDefault(); setDrag(true) }}\n            onDragLeave={() => setDrag(false)}\n            onDrop={(e) => { e.preventDefault(); setDrag(false); void file(e.dataTransfer.files[0]) }}\n            className={cn(\"mt-6 flex flex-col items-center rounded-2xl border-2 border-dashed px-6 py-14 text-center transition-colors motion-reduce:transition-none\", drag ? \"border-foreground bg-accent/50\" : \"hover:bg-accent/30\")}\n          >\n            <span aria-hidden=\"true\" className=\"bg-muted flex size-14 items-center justify-center rounded-full\">\n              <UploadCloud className=\"size-6\" />\n            </span>\n            <p className=\"mt-4 font-medium\">Drag a CSV file here</p>\n            <p className=\"text-muted-foreground mt-1 text-sm\">or</p>\n            <div className=\"mt-3 flex flex-wrap justify-center gap-2.5\">\n              <Button onClick={() => input.current?.click()}>Choose a file</Button>\n              <Button variant=\"outline\" onClick={() => read(sampleCsv, \"contacts.csv\")}>\n                <FileSpreadsheet aria-hidden=\"true\" />\n                Try a sample file\n              </Button>\n            </div>\n            <input ref={input} type=\"file\" accept=\".csv,text/csv\" className=\"sr-only\" aria-label=\"CSV file\" tabIndex={-1} onChange={(e) => void file(e.target.files?.[0])} />\n          </div>\n          {readError ? (\n            <p role=\"alert\" className=\"text-destructive mt-4 flex items-start gap-2 text-sm\">\n              <CircleAlert aria-hidden=\"true\" className=\"mt-0.5 size-4 shrink-0\" />\n              {readError}\n            </p>\n          ) : null}\n        </div>\n      ) : null}\n\n      {step === 1 ? (\n        <div>\n          {head(\"Match your columns\", `${fileName} has ${body.length} rows. We matched what we could; check each one.`)}\n          <ul className=\"mt-6 divide-y rounded-2xl border\">\n            {fields.map((f) => {\n              const c = map[f.key]\n              const sampleVals = c == null ? [] : body.slice(0, 3).map((r) => r[c]).filter(Boolean)\n              return (\n                <li key={f.key} className=\"grid items-center gap-3 p-4 sm:grid-cols-[10rem_minmax(0,1fr)_minmax(0,1.2fr)]\">\n                  <p id={`${id}-${f.key}`} className=\"text-sm font-medium\">\n                    {f.label}\n                    {f.required ? <span className=\"text-destructive\" aria-hidden=\"true\"> *</span> : null}\n                    {f.required ? <span className=\"sr-only\"> (required)</span> : null}\n                  </p>\n                  <Select value={c == null ? \"none\" : String(c)} onValueChange={(v) => setMap((m) => ({ ...m, [f.key]: v === \"none\" ? null : Number(v) }))}>\n                    <SelectTrigger aria-labelledby={`${id}-${f.key}`} className=\"w-full\">\n                      <SelectValue />\n                    </SelectTrigger>\n                    <SelectContent>\n                      <SelectItem value=\"none\">Do not import</SelectItem>\n                      {headers.map((h, i) => (\n                        <SelectItem key={i} value={String(i)}>\n                          {h || `Column ${i + 1}`}\n                        </SelectItem>\n                      ))}\n                    </SelectContent>\n                  </Select>\n                  <p className=\"text-muted-foreground truncate text-sm\">{sampleVals.length ? sampleVals.join(\" · \") : <span>{f.required ? \"Choose a column to continue\" : \"Not imported\"}</span>}</p>\n                </li>\n              )\n            })}\n          </ul>\n          {missing.length ? (\n            <p role=\"alert\" className=\"text-destructive mt-4 text-sm\">\n              Match {missing.map((f) => f.label).join(\" and \")} to continue.\n            </p>\n          ) : null}\n        </div>\n      ) : null}\n\n      {step === 2 ? (\n        <div>\n          {head(\"Review before importing\")}\n          <div className=\"mt-5 grid gap-3 sm:grid-cols-3\">\n            <div className=\"rounded-2xl border p-4\">\n              <p className=\"text-3xl font-semibold tracking-tight tabular-nums\">{good.length}</p>\n              <p className=\"text-muted-foreground text-sm\">ready to import</p>\n            </div>\n            <div className={cn(\"rounded-2xl border p-4\", bad && \"border-destructive/40 bg-destructive/5\")}>\n              <p className=\"text-3xl font-semibold tracking-tight tabular-nums\">{bad}</p>\n              <p className=\"text-muted-foreground text-sm\">with problems, skipped</p>\n            </div>\n            <div className=\"rounded-2xl border p-4\">\n              <p className=\"text-3xl font-semibold tracking-tight tabular-nums\">{rows.length}</p>\n              <p className=\"text-muted-foreground text-sm\">rows in the file</p>\n            </div>\n          </div>\n          {bad ? (\n            <label className=\"mt-5 flex w-fit cursor-pointer items-center gap-2 text-sm\">\n              <input type=\"checkbox\" checked={onlyProblems} onChange={(e) => setOnlyProblems(e.target.checked)} className=\"accent-foreground size-4\" />\n              Show only the rows with problems\n            </label>\n          ) : null}\n          <div role=\"region\" tabIndex={0} aria-label=\"Rows from your file\" className=\"focus-visible:ring-ring/50 mt-4 max-h-96 overflow-auto rounded-2xl border outline-none focus-visible:ring-[3px]\">\n            <table className=\"w-full min-w-[44rem] text-sm\">\n              <caption className=\"sr-only\">Rows from your file and whether each can be imported</caption>\n              <thead className=\"bg-muted/60 sticky top-0 text-start\">\n                <tr>\n                  <th scope=\"col\" className=\"px-4 py-2.5 text-start font-medium\">Row</th>\n                  <th scope=\"col\" className=\"min-w-52 px-4 py-2.5 text-start font-medium\">Result</th>\n                  {fields.filter((f) => map[f.key] != null).map((f) => (\n                    <th key={f.key} scope=\"col\" className=\"px-4 py-2.5 text-start font-medium\">\n                      {f.label}\n                    </th>\n                  ))}\n                </tr>\n              </thead>\n              <tbody className=\"divide-y\">\n                {rows.filter((r) => !onlyProblems || r.issues.length).map((r) => (\n                  <tr key={r.n} className={cn(r.issues.length && \"bg-destructive/5\")}>\n                    <td className=\"text-muted-foreground px-4 py-2.5 tabular-nums\">{r.n}</td>\n                    <td className=\"px-4 py-2.5 align-top\">\n                      {r.issues.length ? (\n                        <span className=\"inline-flex items-start gap-1.5\">\n                          <CircleAlert aria-hidden=\"true\" className=\"text-destructive mt-0.5 size-4 shrink-0\" />\n                          <span>{r.issues.join(\". \")}</span>\n                        </span>\n                      ) : (\n                        <span className=\"inline-flex items-center gap-1.5\">\n                          <CircleCheck aria-hidden=\"true\" className=\"text-chart-2 size-4\" />\n                          Ready\n                        </span>\n                      )}\n                    </td>\n                    {fields.filter((f) => map[f.key] != null).map((f) => (\n                      <td key={f.key} className=\"max-w-44 truncate px-4 py-2.5 align-top\">\n                        {r.values[f.key] || <span className=\"text-muted-foreground\">Empty</span>}\n                      </td>\n                    ))}\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n          {!good.length ? (\n            <p role=\"alert\" className=\"text-destructive mt-4 text-sm\">\n              No row can be imported. Go back and check the column matches.\n            </p>\n          ) : null}\n        </div>\n      ) : null}\n\n      {step === 3 ? (\n        <div className=\"py-4 text-center\" aria-busy={phase === \"running\"}>\n          {phase === \"done\" ? (\n            <div role=\"status\">\n              <span aria-hidden=\"true\" className=\"bg-chart-2/15 mx-auto flex size-14 items-center justify-center rounded-full\">\n                <CircleCheck className=\"size-7\" />\n              </span>\n              <h2 ref={headingRef} tabIndex={-1} className=\"mt-5 text-2xl font-semibold tracking-tight outline-none\">\n                {good.length} {good.length === 1 ? \"contact\" : \"contacts\"} imported\n              </h2>\n              <p className=\"text-muted-foreground mx-auto mt-2 max-w-sm text-pretty\">{bad ? `${bad} ${bad === 1 ? \"row was\" : \"rows were\"} skipped because of problems.` : \"Every row came in.\"}</p>\n              <Button variant=\"outline\" className=\"mt-6\" onClick={reset}>\n                Import another file\n              </Button>\n            </div>\n          ) : (\n            <div>\n              <h2 ref={headingRef} tabIndex={-1} className=\"text-2xl font-semibold tracking-tight outline-none\">\n                {phase === \"running\" ? \"Importing…\" : phase === \"error\" ? \"The import stopped\" : `Import ${good.length} ${good.length === 1 ? \"row\" : \"rows\"}`}\n              </h2>\n              <p className=\"text-muted-foreground mx-auto mt-2 max-w-sm text-pretty\">{phase === \"running\" ? \"Keep this page open until it finishes.\" : phase === \"error\" ? (errorState?.description ?? message ?? \"Nothing was lost. Your rows are still here, so try again.\") : bad ? `${bad} ${bad === 1 ? \"row\" : \"rows\"} with problems will be skipped.` : \"Every row is ready.\"}</p>\n              {phase === \"running\" || phase === \"error\" ? (\n                <div className=\"mx-auto mt-6 max-w-md\">\n                  <Progress value={good.length ? (done / good.length) * 100 : 0} aria-label=\"Import progress\" />\n                  <p className=\"text-muted-foreground mt-2 text-sm tabular-nums\">\n                    {done} of {good.length}\n                  </p>\n                </div>\n              ) : null}\n              {phase === \"error\" ? (\n                <p role=\"alert\" className=\"text-destructive mt-4 text-sm\">\n                  {message ?? \"We could not finish the import.\"}\n                </p>\n              ) : null}\n            </div>\n          )}\n        </div>\n      ) : null}\n\n      {step > 0 && !(step === 3 && phase === \"done\") ? (\n        <div className=\"mt-8 flex items-center justify-between gap-3 border-t pt-5\">\n          <Button variant=\"ghost\" onClick={() => setStep(step - 1)} disabled={phase === \"running\"}>\n            <ArrowLeft aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n            Back\n          </Button>\n          {step < 3 ? (\n            <Button onClick={() => setStep(step + 1)} disabled={(step === 1 && missing.length > 0) || (step === 2 && !good.length)}>\n              {step === 2 ? `Continue with ${good.length} ${good.length === 1 ? \"row\" : \"rows\"}` : \"Continue\"}\n              <ArrowRight aria-hidden=\"true\" className=\"rtl:rotate-180\" />\n            </Button>\n          ) : (\n            <Button onClick={run} disabled={phase === \"running\" || !good.length}>\n              {phase === \"running\" ? <Loader2 aria-hidden=\"true\" className=\"animate-spin motion-reduce:animate-none\" /> : null}\n              {phase === \"running\" ? \"Importing\" : phase === \"error\" ? \"Try again\" : \"Start import\"}\n            </Button>\n          )}\n        </div>\n      ) : null}\n    </div>\n  )\n\n  return (\n    <section data-slot=\"import-1\" data-state={state} aria-labelledby={`${id}-title`} className={cn(\"mx-auto w-full max-w-4xl px-4 py-10 sm:px-6\", className)} {...frameProps(props)}>\n      <header className=\"mb-8\">\n        <h1 id={`${id}-title`} className=\"text-3xl font-semibold tracking-[-0.035em] text-balance\">\n          {title}\n        </h1>\n        <p className=\"text-muted-foreground mt-2 text-pretty\">{description}</p>\n      </header>\n      <StateView state={state === \"loading\" || state === \"error\" ? state : \"ready\"} onRetry={onRetry} label=\"Loading the importer\" loading={<Skel />} error={{ title: errorState?.title ?? \"The importer could not be loaded\", description: errorState?.description ?? \"Check your connection and try again.\", ...errorState }}>\n        {content}\n      </StateView>\n    </section>\n  )\n}\n\nexport { Import1, type Import1Props, type Import1Field, type Import1Row }\n",
      "type": "registry:component",
      "target": "@components/ballmac/blocks/import-1/import-1.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "import",
      "csv",
      "spreadsheet",
      "upload",
      "column mapping",
      "data migration",
      "validation",
      "bulk",
      "contacts",
      "wizard"
    ],
    "version": "1.0.0",
    "updated": "2026-10-18",
    "tests": true,
    "ai": {
      "summary": "A CSV importer. <Import1 fields={[{ key, label, required, aliases: ['e-mail'], validate: (value) => string | null }]} sampleCsv onImport={(rows, progress) => Promise} defaultStep />. Columns are matched by key, label and aliases; required fields must be matched; each row is checked for missing required values, validate() reasons and duplicate emails; only passing rows reach onImport, which can call progress(done). Reject onImport to show its message and keep the rows. Without onImport a run is simulated.",
      "whenToUse": [
        "Importing contacts, products or users from a spreadsheet",
        "Migrating data from another tool"
      ],
      "whenNotToUse": [
        "Uploading files of any kind (use upload-1)",
        "Editing rows in place (use a data table)"
      ],
      "composesWith": [
        "upload-1",
        "app-shell-1",
        "data-table"
      ],
      "a11y": [
        {
          "keys": "Steps",
          "action": "An ordered list that marks the current step and each finished one in words; focus moves to the new step's heading"
        },
        {
          "keys": "Matching",
          "action": "Each field's menu is labelled with the field name and says (required); a sample of the matched values shows beside it"
        },
        {
          "keys": "Review",
          "action": "A real table with a caption; every row says Ready or lists its problems in words beside an icon, not only by color"
        }
      ],
      "customization": [
        "fields decide what can be imported, how it is checked and what other column names match it",
        "A CSV reader handles quotes, commas and line breaks inside quotes",
        "Choosing a file works by drag, click or the sample button, and a wrong file type says how to export a CSV"
      ]
    },
    "states": [
      {
        "id": "loading",
        "title": "Loading",
        "example": "import-1-loading",
        "trigger": "state=\"loading\"",
        "note": "A skeleton card under the real heading, announced as loading."
      },
      {
        "id": "error",
        "title": "Could not load",
        "example": "import-1-error",
        "trigger": "state=\"error\"",
        "note": "An alert with Try again (onRetry). A failed import keeps you on the last step with your rows and a Try again button."
      }
    ],
    "examples": [
      "import-1-demo",
      "import-1-match",
      "import-1-review",
      "import-1-loading",
      "import-1-error"
    ],
    "url": "https://ui.ballmac.com/components/import-1"
  },
  "categories": [
    "blocks",
    "import"
  ],
  "type": "registry:block"
}