{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "csv-column-mapper",
  "title": "CSV Column Mapper",
  "description": "The screen between \"a CSV arrived\" and \"rows went into the database\": it reads the head of the file, shows what it found, and lets the person correct every guess it made — the separator, whether the first row is names, and which column feeds which field. Reach for it wherever a file somebody else exported has to become records here: the import step of a B2B onboarding, a bulk upload of customers, contacts, subscribers, members or leads, an inventory, product or price-list load, an expense or transactions import, a CRM or help-desk migration, a \"import from spreadsheet\" button in an admin panel, a payroll or timesheet upload, a student or roster list, and the receiving half of whatever another tool's Export button produced. Common asks it answers: \"csv column mapper react\", \"csv import mapping ui\", \"map csv columns to fields react\", \"shadcn csv import\", \"csv importer component react\", \"react-papaparse alternative\", \"papaparse parse csv react\", \"flatfile alternative open source\", \"csv upload wizard react\", \"column matching ui\", \"import csv to database frontend\", \"csv preview table react\", \"detect csv delimiter javascript\", \"guess csv separator\", \"csv semicolon vs comma\", \"excel csv semicolon delimiter\", \"csv parse quoted comma\", \"csv newline inside quotes\", \"csv split breaks rows\", \"rfc 4180 parser javascript\", \"csv bom first column name\", \"csv utf-8 bom header mismatch\", \"read large csv in browser\", \"stream csv file javascript\", \"file.slice textdecoder stream\", \"csv header detection\", \"does csv have header row\", \"validate csv column types\", \"csv date column validation\", \"csv インポート 列 マッピング\", \"csv 取り込み 画面\", \"csv 区切り文字 判定\", \"csv 見出し行 判定\", \"csv 文字化け BOM\". **Guessing the separator by counting characters is the bug this is built around, and the fix is not the obvious one.** Counting occurrences in the first line is wrong in both directions: it overcounts a separator sitting inside quoted data — `id,note` over `1,\"a;b;c;d;e\"` has five semicolons and one comma per record, so counting picks the semicolon and the file reads as five ragged columns — and it undercounts whenever the header is the one line with no quoting in it. So each candidate is used to actually read the file and the result is scored, but *how* it is scored decided this component: the first version here ranked by columns times agreement, which picked the semicolon on that very file (five columns at half agreement beats two at full) and lost to naive counting on the case it was written for. Agreement has to lead and width only break ties, because a wrong separator does not produce a narrower table, it produces a ragged one. With agreement leading, all ten measured sample files resolve to the separator they were written with, including both quoted-data traps and a European export whose cells hold decimal commas. A one-column result is never returned as a confident guess, because that is exactly the shape a wrong separator makes — reading a semicolon file with a comma yields one column on every row, no exception and no error — and that silence is what the chooser on screen exists to break. **Records are found by parsing, never by splitting on newlines.** `id,note\\n1,\"line one\\nline two\"\\n2,ok` is three records and `text.split(\"\\n\")` returns four: the quoted newline becomes a record boundary and lands every following row one column over, which is a file that opens without complaint and is wrong from that row down. Quoted separators stay in their cell, doubled quotes become one, CRLF and lone CR both end a record, a blank line in the middle is kept while trailing ones are dropped, and text that ends inside a quote is reported rather than silently closed. The reader is resumable — the quote flag, the half-built cell and the half-built row live across calls — because a chunk boundary lands wherever the byte offset says it does, including the middle of a quoted cell, and a parser restarted per chunk reads the second half as a different file. Every split point of a quoted test file is asserted to give identical records. **The preview is a window on the file, not the file.** Only a prefix is read (64 KiB by default) and the decoder is kept across chunks with `{ stream: true }`, which is not optional: slicing `名前,住所` at byte 8, the second byte of `住`, and decoding that slice alone yields a U+FFFD replacement character with no exception thrown, while a streaming decoder holds the partial bytes back and the halves rejoin exactly. A record the window cut in half is dropped rather than shown as a short row, the BOM is stripped because `\"\\ufeffid\" === \"id\"` is false and a surviving one silently unmatches the first header against its field, and every check on screen says it covers the records read so far and not the whole file. **Whether the first row is a header cannot be decided from the data, so there is a toggle and an honest reason beside it.** `Tokyo,Osaka,Kyoto` is a credible header and a credible first row; a monthly sheet whose columns are `2026-01-01,2026-02-01` has a header made entirely of values. Only one piece of evidence actually settles anything, and it runs one way: a first record whose every cell is a number is not a header. Everything else is a lean — a repeated name, or a column that holds numbers below a first cell that does not — and `looksLikeHeader` returns `certain: false` on every input, so the uncertainty is in the return type rather than in a comment. **Type mismatches are reported per row, because that is how they arrive.** A column of a thousand dates where three read `n/a` is not a text column and not a date column; it is a date column with three rows to fix, and a check that answers only \"is this column dates?\" says no and hides the three rows that are the entire problem. So `checkColumn` names the failing row numbers and counts blanks separately, blank being a different thing to decide about. The predicates are narrower than the platform's on purpose, each for a measured reason: `Number(\"\")` and `Number(\" \")` are both 0, so a blank cell in a quantity column would import as the quantity zero — a value nobody entered and the most expensive item on this list; `Number(\"0x10\")` is 16, `Number(\"0b101\")` is 5 and `Number(\"1e3\")` is 1000, none of them something a person typed into a spreadsheet; `Date.parse(\"42\")` is a day in 2041 and `Date.parse(\"1699\")` is in 1699, so a column of quantities or short ids reads as a column of dates; and `Date.parse(\"2026-02-30\")` does not fail but rolls forward to the 2nd of March, so \"parses\" is not the question \"is a day\". Dates are therefore matched as ISO and then verified by rebuilding them, and `1,50`, `11/08/2026` and `08.11.2026` are all reported as text rather than guessed at — `1,50` is one and a half in a European export and one thousand and fifty elsewhere, and a date guessed wrong is wrong by months. Nothing is converted on the way out: `applyMapping` hands back strings and the caller converts knowing their own locale. **The correspondence is not one-to-one in either direction, and both halves are reported at once.** A file can carry columns nobody asked for — an internal id, a legacy note, a column of empties — while required fields sit empty, and both are true at the same time; a screen that shows one half is the ordinary way an import loses a column quietly. Columns left out are listed by name rather than warned about, since skipping one is a normal decision the reader only needs to be told was taken, while a required field with no column blocks the confirm button and marks its own control invalid. One column feeding two fields is reported and not undone, because `name` and `display_name` off one column is a real thing to want and `email` and `phone` off one column is a mistake, and nothing here can tell them apart. A stale column index left over from a different separator counts as unmapped instead of passing. Matching runs exact normalised equality across every field before any looser rule gets a turn, so `name` cannot swallow `company_name` by being declared first, and a column is claimed once; two columns of the same name — which merged exports really do carry — are told apart in the chooser while matching still runs on the written names. A choice the reader makes sticks, and every field they have not touched keeps following the suggestion, so correcting the separator rebuilds the suggestions instead of freezing them. Official shadcn/ui has nothing of the kind and the measurement is not close: fetching every entry in its registry today — 65 listed, 62 fetchable, `code-block`, `questionnaire` and `trace` 404 on both style tracks — and searching all 223,287 bytes of it, `csv`, `parse`, `delimiter`, `quote`, `mapping` and `column` are every one of them a zero hit, and the 15 hits on `header` are table and card headings with nothing to do with importing. Within pulld it is the receiving end: `csv-export-button` writes a file out, `file-dropzone` and `upload-list` take one in without reading it, `paste-area` catches what the clipboard carries, and `transfer-list` moves items between two lists — this is the one that interprets a table somebody else exported. `createCsvParser`, `parseCsv`, `sniffDelimiter`, `csvShape`, `readCsvPreview`, `looksLikeHeader`, `isPlainNumber`, `isPlainDate`, `checkColumn`, `autoMatchFields`, `mappingReport` and `applyMapping` are all exported as plain functions, so the route handler that receives the confirmed mapping reaches the same answers the screen showed. The controls are one labelled `role=\"group\"`, every select and checkbox separately labelled, the preview is a real table with a caption and `scope=\"col\"` headings, required fields are marked for a screen reader and not only with an asterisk, the one polite live region ships empty and is wired to the invalid controls with `aria-describedby`, and every message is replaceable for translation. No dependencies beyond React and your `cn`; every colour is a shadcn token, so it follows light and dark.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/ui/csv-column-mapper.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The separators worth guessing between, in the order they are tried.\n *\n * Four, not \"any single character\": every extra candidate is another chance to win a tie against\n * the right answer on a file that happens to contain it. These four are what spreadsheets and\n * database exports actually emit — comma, the semicolon Excel writes in locales where the comma is\n * the decimal mark, tab, and the pipe that shows up in hand-rolled exports.\n */\nexport const CSV_DELIMITERS = [\",\", \";\", \"\\t\", \"|\"] as const\n\nexport type CsvDelimiter = (typeof CSV_DELIMITERS)[number] | (string & {})\n\nexport interface CsvParseOptions {\n  delimiter?: string\n  /** The character that protects a cell. RFC 4180 says `\"`; some exports use `'`. */\n  quote?: string\n  /** Stop after this many records. The rest of the text is not examined. */\n  limit?: number\n}\n\nexport interface CsvParseResult {\n  rows: string[][]\n  /** True when `limit` cut the read short, so this is a sample and not the file. */\n  truncated: boolean\n  /** True when the text ended inside a quoted cell — a sign of a wrong quote character or a cut file. */\n  unterminatedQuote: boolean\n}\n\n/**\n * A resumable RFC 4180 reader.\n *\n * Resumable because of how a large file has to be read. A chunk boundary lands wherever the byte\n * offset says it does, which includes the middle of a quoted cell: slicing\n * `id,note\\n1,\"a,b\\nc\"\\n2,ok` in half gives a first half of `id,note\\n1,\"` whose quote never closes,\n * so a parser that starts fresh on each chunk reads the second half as a different file. The quote\n * flag, the half-built cell and the half-built row therefore live across calls rather than inside\n * one.\n *\n * Splitting on newlines first is the thing this exists to avoid. In that same text the record count\n * is 3, and `text.split(\"\\n\")` returns 4 — the quoted newline inside `note` becomes a record\n * boundary, which puts every following row one column over. It is a file that opens without\n * complaint and is wrong from that row down.\n */\nexport function createCsvParser(options: CsvParseOptions = {}) {\n  const delimiter = options.delimiter ?? \",\"\n  const quote = options.quote ?? '\"'\n\n  let cell = \"\"\n  let row: string[] = []\n  let inQuotes = false\n  /** True when the previous chunk ended on a CR, whose LF may be the first byte of this one. */\n  let pendingCr = false\n  /** True once a quote has opened this cell, so a quote after the closing one is literal text. */\n  let quoted = false\n\n  function endCell() {\n    row.push(cell)\n    cell = \"\"\n    quoted = false\n  }\n\n  function endRow(out: string[][]) {\n    endCell()\n    out.push(row)\n    row = []\n  }\n\n  return {\n    /** Feeds one chunk of text and returns whatever records completed inside it. */\n    push(chunk: string): string[][] {\n      const out: string[][] = []\n      for (let i = 0; i < chunk.length; i += 1) {\n        const char = chunk[i]\n\n        // A CRLF split across two chunks: the LF arriving first here is the tail of a line break\n        // already acted on, not an empty record.\n        if (pendingCr) {\n          pendingCr = false\n          if (char === \"\\n\") continue\n        }\n\n        if (inQuotes) {\n          if (char === quote) {\n            if (chunk[i + 1] === quote) {\n              cell += quote\n              i += 1\n            } else {\n              inQuotes = false\n            }\n          } else {\n            cell += char\n          }\n          continue\n        }\n\n        if (char === quote && cell === \"\") {\n          inQuotes = true\n          quoted = true\n        } else if (char === delimiter) {\n          endCell()\n        } else if (char === \"\\n\") {\n          endRow(out)\n        } else if (char === \"\\r\") {\n          pendingCr = true\n          endRow(out)\n        } else {\n          cell += char\n        }\n      }\n      return out\n    },\n\n    /** Closes the last record, if the text did not end on a line break. */\n    end(): string[][] {\n      const out: string[][] = []\n      if (cell !== \"\" || row.length > 0 || quoted) endRow(out)\n      return out\n    },\n\n    /** True while the reader is inside a quoted cell. */\n    get open() {\n      return inQuotes\n    },\n  }\n}\n\n/** Reads `text` into records. Blank trailing lines are dropped; a blank line in the middle is not. */\nexport function parseCsv(text: string, options: CsvParseOptions = {}): CsvParseResult {\n  const limit = options.limit ?? Infinity\n  const parser = createCsvParser(options)\n  const rows: string[][] = []\n  let truncated = false\n\n  // Fed in slices rather than whole, so this function exercises the same boundary-crossing path the\n  // chunked reader does. A parser only tested on one call is a parser only tested on easy input.\n  const step = 4096\n  for (let at = 0; at < text.length; at += step) {\n    for (const record of parser.push(text.slice(at, at + step))) {\n      if (rows.length >= limit) {\n        truncated = true\n        break\n      }\n      rows.push(record)\n    }\n    if (truncated) break\n  }\n  if (!truncated) {\n    for (const record of parser.end()) {\n      if (rows.length >= limit) {\n        truncated = true\n        break\n      }\n      rows.push(record)\n    }\n  }\n\n  while (rows.length > 0) {\n    const last = rows[rows.length - 1]\n    if (last.length === 1 && last[0] === \"\") rows.pop()\n    else break\n  }\n\n  return { rows, truncated, unterminatedQuote: parser.open }\n}\n\nexport interface CsvShape {\n  /** The most common record width. */\n  columns: number\n  /** The share of records that are that wide, 0 to 1. */\n  agreement: number\n  records: number\n}\n\n/** How rectangular `text` looks when read with `delimiter`. */\nexport function csvShape(\n  text: string,\n  delimiter: string,\n  options: { quote?: string; sample?: number } = {}\n): CsvShape {\n  const { rows } = parseCsv(text, {\n    delimiter,\n    quote: options.quote,\n    limit: options.sample ?? 20,\n  })\n  const real = rows.filter((row) => !(row.length === 1 && row[0].trim() === \"\"))\n  if (real.length === 0) return { columns: 0, agreement: 0, records: 0 }\n\n  const tally = new Map<number, number>()\n  for (const row of real) tally.set(row.length, (tally.get(row.length) ?? 0) + 1)\n\n  let columns = 0\n  let hits = 0\n  for (const [width, count] of tally) {\n    if (count > hits || (count === hits && width > columns)) {\n      columns = width\n      hits = count\n    }\n  }\n  return { columns, agreement: hits / real.length, records: real.length }\n}\n\nexport interface DelimiterGuess {\n  delimiter: string\n  columns: number\n  agreement: number\n  /** False when the guess rests on one record, or on a file that came out one column wide. */\n  confident: boolean\n}\n\n/**\n * Guesses the separator by reading the file with each candidate and keeping the one that comes out\n * rectangular.\n *\n * Counting occurrences in the first line is the obvious version and it is wrong in both directions.\n * It overcounts a separator that appears inside quoted data — `id,note` / `1,\"a;b;c;d;e\"` has five\n * semicolons and one comma per record, so counting picks the semicolon and the file reads as five\n * ragged columns — and it undercounts whenever the header is the one line with no quoting in it.\n *\n * Reading the file properly and scoring the result is therefore the method, but *how* it is scored\n * decided this: the first version here ranked by `columns * agreement`, which picked the semicolon\n * on that same file (5 columns at 0.5 agreement beats 2 at 1.0) and lost to naive counting on the\n * very case it was written for. Agreement has to come first and width only break ties, because a\n * wrong separator does not produce a narrower table — it produces a ragged one. With agreement\n * leading, all ten of the sample files measured against this, including both quoted-data traps and\n * a European file whose cells hold decimal commas, resolve to the separator they were written with.\n *\n * A one-column result is never returned as a guess, because that is the shape a wrong separator\n * makes: reading a semicolon file with a comma yields one column on every row and no error at all.\n * The file is not rejected — a single-column CSV is a real thing — but `confident` is false, which\n * is what puts the chooser in front of the reader instead of a silent decision.\n */\nexport function sniffDelimiter(\n  text: string,\n  options: { candidates?: readonly string[]; quote?: string; sample?: number } = {}\n): DelimiterGuess {\n  const candidates = options.candidates ?? CSV_DELIMITERS\n  const fallback = candidates[0] ?? \",\"\n  let best: DelimiterGuess | null = null\n\n  for (const delimiter of candidates) {\n    const shape = csvShape(text, delimiter, options)\n    if (shape.columns < 2) continue\n    const guess: DelimiterGuess = {\n      delimiter,\n      columns: shape.columns,\n      agreement: shape.agreement,\n      confident: shape.agreement >= 0.9 && shape.records >= 2,\n    }\n    if (\n      best === null ||\n      guess.agreement > best.agreement + 1e-9 ||\n      (Math.abs(guess.agreement - best.agreement) < 1e-9 && guess.columns > best.columns)\n    ) {\n      best = guess\n    }\n  }\n\n  if (best) return best\n  const flat = csvShape(text, fallback, options)\n  return {\n    delimiter: fallback,\n    columns: flat.columns,\n    agreement: flat.agreement,\n    confident: false,\n  }\n}\n\n/**\n * Reads a prefix of `blob` as text, without pulling the whole file into memory.\n *\n * The decoder is kept across chunks and fed with `{ stream: true }`, which is not optional. A byte\n * slice cuts wherever it cuts, and a cut inside a multi-byte character is silent: measured, slicing\n * `名前,住所` at byte 8 — the second byte of `住` — and decoding that slice on its own yields\n * `\"名前,�\"`, a replacement character, with no exception thrown. A streaming decoder holds the\n * partial bytes back and the two halves rejoin to exactly the original text.\n *\n * The BOM is handled by not fighting the platform: `new TextDecoder(\"utf-8\")` strips a leading\n * U+FEFF by default (`ignoreBOM` defaults to false), and that matters because `\"﻿id\" === \"id\"`\n * is false — a surviving BOM silently unmatches the first header against the field it belongs to.\n * It is stripped again below anyway, for the callers who hand over text they decoded themselves.\n */\nexport async function readCsvPreview(\n  blob: Blob,\n  options: {\n    bytes?: number\n    records?: number\n    delimiter?: string\n    quote?: string\n    chunkBytes?: number\n  } = {}\n): Promise<{\n  rows: string[][]\n  truncated: boolean\n  unterminatedQuote: boolean\n  bytesRead: number\n  delimiter: string\n}> {\n  const bytes = Math.max(1, options.bytes ?? 64 * 1024)\n  const wanted = Math.max(1, options.records ?? 11)\n  const chunkBytes = Math.max(1, options.chunkBytes ?? 16 * 1024)\n\n  const head = blob.slice(0, Math.min(bytes, blob.size))\n  const buffer = await head.arrayBuffer()\n  const view = new Uint8Array(buffer)\n  const decoder = new TextDecoder(\"utf-8\")\n\n  let text = \"\"\n  for (let at = 0; at < view.length; at += chunkBytes) {\n    text += decoder.decode(view.subarray(at, Math.min(at + chunkBytes, view.length)), {\n      stream: true,\n    })\n  }\n  text += decoder.decode()\n  text = stripBom(text)\n\n  const delimiter = options.delimiter ?? sniffDelimiter(text, { quote: options.quote }).delimiter\n  const parsed = parseCsv(text, { delimiter, quote: options.quote, limit: wanted })\n\n  // A prefix that ran out mid-record would otherwise contribute a short last row that looks like a\n  // ragged file rather than like the edge of the window.\n  const cutMidRecord = head.size < blob.size && !parsed.truncated\n  const rows = cutMidRecord && parsed.rows.length > 1 ? parsed.rows.slice(0, -1) : parsed.rows\n\n  return {\n    rows,\n    truncated: parsed.truncated || head.size < blob.size,\n    unterminatedQuote: parsed.unterminatedQuote,\n    bytesRead: head.size,\n    delimiter,\n  }\n}\n\n/** Removes a leading U+FEFF, which is not part of the first header's name. */\nexport function stripBom(text: string): string {\n  return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text\n}\n\nexport interface HeaderGuess {\n  header: boolean\n  /**\n   * Always false. Kept as a field so the uncertainty is in the return type rather than in a comment\n   * somebody has to find.\n   */\n  certain: false\n  reason: string\n}\n\n/**\n * Guesses whether the first record is a header, and says so as a guess.\n *\n * This cannot be decided from the data, and the component shows a toggle because of it rather than\n * in spite of it. `Tokyo,Osaka,Kyoto` is a credible header and a credible first row of data, and\n * a sheet of monthly figures whose columns are `2026-01-01,2026-02-01,2026-03-01` has a header made\n * entirely of values. The only evidence that actually settles it runs one way: a first record whose\n * every cell reads as a number is not a header, because nobody names a column `42`. Everything else\n * is a lean, and the toggle is where the reader overrules it.\n */\nexport function looksLikeHeader(rows: readonly string[][]): HeaderGuess {\n  const first = rows[0]\n  if (!first || first.length === 0) {\n    return { header: false, certain: false, reason: \"There is no first record to read.\" }\n  }\n\n  const cells = first.map((cell) => cell.trim())\n  if (cells.every((cell) => cell === \"\")) {\n    return { header: false, certain: false, reason: \"The first record is empty.\" }\n  }\n  if (cells.every((cell) => cell !== \"\" && isPlainNumber(cell))) {\n    return {\n      header: false,\n      certain: false,\n      reason: \"Every cell in the first record is a number, so it reads as data.\",\n    }\n  }\n\n  const named = cells.filter((cell) => cell !== \"\").length\n  const unique = new Set(cells.filter((cell) => cell !== \"\")).size\n  if (unique < named) {\n    return {\n      header: true,\n      certain: false,\n      reason: \"The first record repeats a name, which happens in headers more often than in data.\",\n    }\n  }\n\n  const rest = rows.slice(1)\n  if (rest.length > 0) {\n    // A column that is numeric below and not numeric on top is the ordinary shape of a header.\n    let differs = 0\n    for (let column = 0; column < first.length; column += 1) {\n      const below = rest.map((row) => (row[column] ?? \"\").trim()).filter((cell) => cell !== \"\")\n      if (below.length === 0) continue\n      const belowNumeric = below.every((cell) => isPlainNumber(cell))\n      const topNumeric = isPlainNumber(cells[column] ?? \"\")\n      if (belowNumeric && !topNumeric) differs += 1\n    }\n    if (differs > 0) {\n      return {\n        header: true,\n        certain: false,\n        reason: `${differs} column${differs === 1 ? \"\" : \"s\"} hold numbers below a first cell that is not one.`,\n      }\n    }\n  }\n\n  return {\n    header: true,\n    certain: false,\n    reason: \"Assumed, because most exports carry a header. Change it if this file does not.\",\n  }\n}\n\nexport type CsvValueKind = \"text\" | \"number\" | \"integer\" | \"date\" | \"boolean\"\n\n/**\n * True for text that is a number somebody typed, which is narrower than `Number` accepts.\n *\n * Measured, `Number(\"\")` and `Number(\" \")` are both 0 — an empty cell in a quantity column becomes\n * the quantity zero, which is a value nobody entered and the single most expensive thing on this\n * list. `Number(\"0x10\")` is 16, `Number(\"0b101\")` is 5 and `Number(\"1e3\")` is 1000; none of those is\n * something a person put in a spreadsheet, and all three would import as a number no one can trace.\n * `Infinity` and `NaN` parse too.\n *\n * `1,50` is refused on purpose rather than read as one and a half. In a semicolon-separated export\n * from a European locale that is a decimal comma, and in a comma-separated one it is a thousands\n * separator — the same four characters meaning two different numbers, decidable only by knowing\n * where the file came from. Guessing picks wrong for half the world, so the cell is reported as not\n * a number and the caller is left to say what its files mean.\n */\nexport function isPlainNumber(text: string): boolean {\n  const trimmed = text.trim()\n  if (trimmed === \"\") return false\n  if (!/^[+-]?(\\d+(\\.\\d+)?|\\.\\d+)$/.test(trimmed)) return false\n  return Number.isFinite(Number(trimmed))\n}\n\nexport function isPlainInteger(text: string): boolean {\n  const trimmed = text.trim()\n  return /^[+-]?\\d+$/.test(trimmed) && Number.isFinite(Number(trimmed))\n}\n\n/**\n * True only for an unambiguous ISO calendar date that is also a real day.\n *\n * `Date.parse` is the wrong instrument here three times over, each measured:\n *\n *   - `Date.parse(\"42\")` is a valid date — the last day of 2041 in this timezone — and\n *     `Date.parse(\"1699\")` is the first of January 1699. A column of quantities or short ids\n *     therefore reads as a column of dates, which is how a numeric field arrives as a timestamp.\n *   - `Date.parse(\"2026-02-30\")` does not fail. It returns the 2nd of March: the overflow rolls\n *     forward silently, so \"parses\" is not the same question as \"is a day\".\n *   - `Date.parse(\"2026-11-08\")` is midnight UTC while `Date.parse(\"2026/11/08\")` is midnight local\n *     — nine hours apart where this was measured — so two spellings of one day land on two\n *     different instants, and `11/08` versus `08/11` cannot be told apart at all without knowing\n *     the file's convention.\n *\n * So the shape is matched first and then verified by rebuilding the date and checking the parts came\n * back unchanged, which is what rejects the 30th of February. Anything else — slashes, dots, month\n * names, day-first — is reported as text, and a caller who knows their files' convention converts\n * it themselves. Refusing to guess is the feature: a date guessed wrong is wrong by months.\n */\nexport function isPlainDate(text: string): boolean {\n  const trimmed = text.trim()\n  const parts = /^(\\d{4})-(\\d{2})-(\\d{2})$/.exec(trimmed)\n  if (!parts) return false\n  const year = Number(parts[1])\n  const month = Number(parts[2])\n  const day = Number(parts[3])\n  if (month < 1 || month > 12 || day < 1 || day > 31) return false\n  const built = new Date(Date.UTC(year, month - 1, day))\n  return (\n    built.getUTCFullYear() === year &&\n    built.getUTCMonth() === month - 1 &&\n    built.getUTCDate() === day\n  )\n}\n\n/** The spellings of yes and no that an export is likely to contain. */\nconst TRUTHY = new Set([\"true\", \"yes\", \"y\", \"1\", \"t\"])\nconst FALSY = new Set([\"false\", \"no\", \"n\", \"0\", \"f\"])\n\nexport function isPlainBoolean(text: string): boolean {\n  const lower = text.trim().toLowerCase()\n  return TRUTHY.has(lower) || FALSY.has(lower)\n}\n\nexport function matchesKind(text: string, kind: CsvValueKind): boolean {\n  switch (kind) {\n    case \"number\":\n      return isPlainNumber(text)\n    case \"integer\":\n      return isPlainInteger(text)\n    case \"date\":\n      return isPlainDate(text)\n    case \"boolean\":\n      return isPlainBoolean(text)\n    default:\n      return true\n  }\n}\n\nexport interface CsvCellProblem {\n  /** The record's index in the rows that were checked, 0 being the first data record. */\n  row: number\n  value: string\n}\n\nexport interface ColumnCheck {\n  kind: CsvValueKind\n  checked: number\n  /** Cells that were blank. Blank is not a type error; it is a different thing to decide about. */\n  empty: number\n  problems: CsvCellProblem[]\n}\n\n/**\n * Checks one column's cells against a kind, and reports the rows that failed.\n *\n * Per row, not per column, because that is how the failure arrives. A column of a thousand dates\n * where three are `n/a` is not a text column and not a date column — it is a date column with three\n * rows to fix, and a check that answers only \"is this column dates?\" says no and hides the three\n * rows that are the entire problem. The row numbers are the output.\n *\n * `limit` caps how many are kept, since the point is to show the reader which rows to look at, and a\n * column that is wrong in ten thousand rows is wrong in a way the first few already demonstrate.\n */\nexport function checkColumn(\n  values: readonly string[],\n  kind: CsvValueKind,\n  options: { limit?: number } = {}\n): ColumnCheck {\n  const limit = options.limit ?? 5\n  const problems: CsvCellProblem[] = []\n  let empty = 0\n\n  values.forEach((value, row) => {\n    if (value.trim() === \"\") {\n      empty += 1\n      return\n    }\n    if (!matchesKind(value, kind) && problems.length < limit) problems.push({ row, value })\n  })\n\n  return { kind, checked: values.length, empty, problems }\n}\n\nexport interface ImportField {\n  /** The key this field is written out under. */\n  name: string\n  label?: string\n  required?: boolean\n  kind?: CsvValueKind\n  /** Extra header spellings that should match this field. */\n  synonyms?: readonly string[]\n}\n\n/** Lowercases and drops everything that is not a letter or digit, so `First Name` meets `first_name`. */\nexport function normalizeHeader(text: string): string {\n  return stripBom(text)\n    .toLowerCase()\n    .replace(/[^\\p{L}\\p{N}]+/gu, \"\")\n}\n\n/** A column index per field name, or null for \"no column feeds this field\". */\nexport type CsvMapping = Record<string, number | null>\n\n/**\n * Suggests a column for each field by name, and leaves the rest alone.\n *\n * Exact normalised equality first, across every field, before any looser rule gets a turn — a\n * contains-match run field by field lets an early field take a column that a later field names\n * outright, and `name` swallowing `company_name` is the version of this bug that reaches\n * production. A column is claimed once; the second field that wants it is left unassigned for the\n * reader to settle, which is the whole reason the chooser is on screen.\n */\nexport function autoMatchFields(\n  headers: readonly string[],\n  fields: readonly ImportField[]\n): CsvMapping {\n  const normalised = headers.map(normalizeHeader)\n  const taken = new Set<number>()\n  const mapping: CsvMapping = {}\n  for (const field of fields) mapping[field.name] = null\n\n  const names = (field: ImportField) =>\n    [field.name, field.label ?? \"\", ...(field.synonyms ?? [])]\n      .map(normalizeHeader)\n      .filter((name) => name !== \"\")\n\n  for (const field of fields) {\n    const wanted = names(field)\n    const at = normalised.findIndex((header, index) => !taken.has(index) && wanted.includes(header))\n    if (at !== -1) {\n      mapping[field.name] = at\n      taken.add(at)\n    }\n  }\n\n  for (const field of fields) {\n    if (mapping[field.name] !== null) continue\n    const wanted = names(field)\n    const at = normalised.findIndex(\n      (header, index) =>\n        !taken.has(index) &&\n        header !== \"\" &&\n        wanted.some((name) => header.includes(name) || name.includes(header))\n    )\n    if (at !== -1) {\n      mapping[field.name] = at\n      taken.add(at)\n    }\n  }\n\n  return mapping\n}\n\nexport interface MappingReport {\n  /** Required fields with no column behind them. */\n  missingRequired: string[]\n  /** Optional fields with no column behind them. */\n  missingOptional: string[]\n  /** Column indices no field is reading. */\n  ignoredColumns: number[]\n  /** Column indices more than one field is reading, with the fields that share them. */\n  reusedColumns: { column: number; fields: string[] }[]\n  complete: boolean\n}\n\n/**\n * What is unresolved about a mapping, from both sides.\n *\n * Both sides, because the correspondence is not one-to-one in either direction and a screen that\n * reports only one half is the usual way an import loses data quietly. A file can carry columns\n * nobody asked for — an internal id, a legacy note, a column of empties — while required fields sit\n * empty, and both are true at once. Columns left out are listed rather than warned about: skipping\n * a column is a normal decision, and the reader only needs to be told it was taken. Required fields\n * left empty are the ones that block.\n *\n * Reuse is reported and not forbidden. One column feeding both `name` and `display_name` is a real\n * thing to want; the same column landing in `email` and `phone` is a mistake. Nothing here can tell\n * the two apart, so both are shown and neither is undone.\n */\nexport function mappingReport(\n  mapping: CsvMapping,\n  fields: readonly ImportField[],\n  columnCount: number\n): MappingReport {\n  const missingRequired: string[] = []\n  const missingOptional: string[] = []\n  const byColumn = new Map<number, string[]>()\n\n  for (const field of fields) {\n    const column = mapping[field.name]\n    if (column === null || column === undefined || column < 0 || column >= columnCount) {\n      if (field.required) missingRequired.push(field.name)\n      else missingOptional.push(field.name)\n      continue\n    }\n    byColumn.set(column, [...(byColumn.get(column) ?? []), field.name])\n  }\n\n  const ignoredColumns: number[] = []\n  for (let column = 0; column < columnCount; column += 1) {\n    if (!byColumn.has(column)) ignoredColumns.push(column)\n  }\n\n  const reusedColumns = [...byColumn.entries()]\n    .filter(([, names]) => names.length > 1)\n    .map(([column, names]) => ({ column, fields: names }))\n    .sort((a, b) => a.column - b.column)\n\n  return {\n    missingRequired,\n    missingOptional,\n    ignoredColumns,\n    reusedColumns,\n    complete: missingRequired.length === 0,\n  }\n}\n\n/**\n * Turns records into objects using the mapping.\n *\n * Strings out, always. Converting here would mean deciding what `1,50` and `08/11/2026` mean, which\n * is exactly what this file refuses to guess at; `matchesKind` says whether a cell is what the field\n * wants and the caller converts knowing their own locale. A field whose column is short on a\n * particular row gets `\"\"` rather than `undefined`, so every object has every key and a `JSON.parse`\n * on the other end does not have to care which rows were ragged.\n */\nexport function applyMapping(\n  rows: readonly string[][],\n  mapping: CsvMapping,\n  fields: readonly ImportField[]\n): Record<string, string>[] {\n  return rows.map((row) => {\n    const out: Record<string, string> = {}\n    for (const field of fields) {\n      const column = mapping[field.name]\n      out[field.name] =\n        column === null || column === undefined ? \"\" : (row[column] ?? \"\")\n    }\n    return out\n  })\n}\n\nexport interface CsvColumnMapperMessages {\n  delimiterLabel: string\n  headerLabel: string\n  previewCaption: (records: number, truncated: boolean) => string\n  columnName: (index: number) => string\n  /** Tells two columns of the same name apart in the chooser. */\n  duplicateColumn: (name: string, index: number) => string\n  ignore: string\n  missingRequired: (fields: string[]) => string\n  ignoredColumns: (names: string[]) => string\n  reusedColumn: (name: string, fields: string[]) => string\n  typeProblems: (field: string, kind: CsvValueKind, rows: number[], checked: number) => string\n  emptyCells: (field: string, count: number, checked: number) => string\n  unterminatedQuote: string\n  oneColumn: string\n  sampleOnly: (records: number) => string\n  empty: string\n  confirm: string\n}\n\nconst DEFAULT_MESSAGES: CsvColumnMapperMessages = {\n  delimiterLabel: \"Separator\",\n  headerLabel: \"First row is a header\",\n  previewCaption: (records, truncated) =>\n    `${records} ${records === 1 ? \"record\" : \"records\"}${truncated ? \" from the start of the file\" : \"\"}`,\n  columnName: (index) => `Column ${index + 1}`,\n  duplicateColumn: (name, index) => `${name} (column ${index + 1})`,\n  ignore: \"— not imported —\",\n  missingRequired: (fields) =>\n    `${fields.length === 1 ? \"A required field has\" : `${fields.length} required fields have`} no column yet: ${fields.join(\", \")}.`,\n  ignoredColumns: (names) =>\n    `Not imported: ${names.join(\", \")}. That is fine if those columns are not wanted.`,\n  reusedColumn: (name, fields) => `${name} feeds ${fields.join(\" and \")}.`,\n  typeProblems: (field, kind, rows, checked) =>\n    `${field} expects ${kind === \"integer\" ? \"a whole number\" : `a ${kind}`}: ${rows.length} of the ${checked} rows shown ${rows.length === 1 ? \"does\" : \"do\"} not read that way (row ${rows.join(\", row \")}).`,\n  emptyCells: (field, count, checked) =>\n    `${field} is blank in ${count} of the ${checked} rows shown.`,\n  unterminatedQuote: \"A quoted cell is never closed — check the separator and quote character.\",\n  oneColumn: \"This read as a single column, which usually means the separator is wrong.\",\n  sampleOnly: (records) =>\n    `Checks cover the ${records} ${records === 1 ? \"record\" : \"records\"} read so far, not the whole file.`,\n  empty: \"Nothing to map yet.\",\n  confirm: \"Import\",\n}\n\nexport interface CsvColumnMapperProps {\n  /** The file to read. Only a prefix of it is read; the whole file is never held here. */\n  file?: Blob | null\n  /** CSV text, for callers who already have it. One of `file` or `text`. */\n  text?: string\n  fields: readonly ImportField[]\n  /** Controlled mapping of field name to column index. */\n  value?: CsvMapping\n  onValueChange?: (mapping: CsvMapping, report: MappingReport) => void\n  onConfirm?: (result: {\n    mapping: CsvMapping\n    delimiter: string\n    hasHeader: boolean\n    headers: string[]\n    report: MappingReport\n  }) => void\n  /** Forces the separator instead of guessing it. */\n  delimiter?: string\n  quote?: string\n  /** Forces the header decision instead of guessing it. */\n  hasHeader?: boolean\n  /** Data records to show and check (default 10). */\n  previewRecords?: number\n  /** How much of a `file` to read (default 64 KiB). */\n  sampleBytes?: number\n  messages?: Partial<CsvColumnMapperMessages>\n  disabled?: boolean\n  className?: string\n  id?: string\n}\n\n/**\n * The screen between \"a CSV arrived\" and \"rows went into the database\".\n *\n * It reads a prefix of the file, shows what it found, and lets the reader correct every guess it\n * made: the separator, whether the first row is names, and which column feeds which field. Nothing\n * it decides is hidden, because each of those guesses is wrong on some real file and a wrong one\n * that is not visible becomes a silent import of the wrong data.\n */\nexport function CsvColumnMapper({\n  file,\n  text,\n  fields,\n  value,\n  onValueChange,\n  onConfirm,\n  delimiter,\n  quote,\n  hasHeader,\n  previewRecords = 10,\n  sampleBytes = 64 * 1024,\n  messages,\n  disabled,\n  className,\n  id,\n}: CsvColumnMapperProps) {\n  const say = React.useMemo(() => ({ ...DEFAULT_MESSAGES, ...messages }), [messages])\n\n  const generatedId = React.useId()\n  const rootId = id ?? generatedId\n  const statusId = `${rootId}-status`\n\n  const [source, setSource] = React.useState<string>(() => stripBom(text ?? \"\"))\n  const [fileTruncated, setFileTruncated] = React.useState(false)\n  const [reading, setReading] = React.useState(false)\n\n  React.useEffect(() => {\n    if (text !== undefined) {\n      setSource(stripBom(text))\n      setFileTruncated(false)\n      return\n    }\n    if (!file) {\n      setSource(\"\")\n      setFileTruncated(false)\n      return\n    }\n    let live = true\n    setReading(true)\n    // Read for the sample window only. A fifty-megabyte export has no business in component state,\n    // and the mapping decision needs the first handful of records, not the file.\n    void (async () => {\n      try {\n        const head = file.slice(0, Math.min(sampleBytes, file.size))\n        const buffer = await head.arrayBuffer()\n        const decoder = new TextDecoder(\"utf-8\")\n        const view = new Uint8Array(buffer)\n        let read = \"\"\n        const step = 16 * 1024\n        for (let at = 0; at < view.length; at += step) {\n          read += decoder.decode(view.subarray(at, Math.min(at + step, view.length)), {\n            stream: true,\n          })\n        }\n        read += decoder.decode()\n        if (!live) return\n        setSource(stripBom(read))\n        setFileTruncated(head.size < file.size)\n      } catch {\n        if (!live) return\n        setSource(\"\")\n        setFileTruncated(false)\n      } finally {\n        if (live) setReading(false)\n      }\n    })()\n    return () => {\n      live = false\n    }\n  }, [file, text, sampleBytes])\n\n  const guess = React.useMemo(\n    () => sniffDelimiter(source, { quote }),\n    [source, quote]\n  )\n  const [chosenDelimiter, setChosenDelimiter] = React.useState<string | null>(null)\n  const activeDelimiter = delimiter ?? chosenDelimiter ?? guess.delimiter\n\n  const parsed = React.useMemo(\n    () =>\n      parseCsv(source, {\n        delimiter: activeDelimiter,\n        quote,\n        limit: previewRecords + 1,\n      }),\n    [source, activeDelimiter, quote, previewRecords]\n  )\n\n  const headerGuess = React.useMemo(() => looksLikeHeader(parsed.rows), [parsed.rows])\n  const [chosenHeader, setChosenHeader] = React.useState<boolean | null>(null)\n  const activeHeader = hasHeader ?? chosenHeader ?? headerGuess.header\n\n  const columnCount = React.useMemo(\n    () => parsed.rows.reduce((widest, row) => Math.max(widest, row.length), 0),\n    [parsed.rows]\n  )\n\n  const headers = React.useMemo(() => {\n    const names: string[] = []\n    for (let column = 0; column < columnCount; column += 1) {\n      const written = activeHeader ? (parsed.rows[0]?.[column] ?? \"\").trim() : \"\"\n      names.push(written === \"\" ? say.columnName(column) : written)\n    }\n    return names\n  }, [parsed.rows, columnCount, activeHeader, say])\n\n  /**\n   * Names for the chooser, with repeats told apart.\n   *\n   * A file with two columns both called `Email` is ordinary — it comes out of a merge, or out of a\n   * sheet where somebody copied a column — and two options reading `Email` are two options the\n   * reader cannot choose between, even though the values behind them differ. The suffix is added\n   * only to the duplicates, and only for display: matching still runs on the written names, since\n   * `Email (column 2)` normalises to something no field is called.\n   */\n  const optionLabels = React.useMemo(() => {\n    const seen = new Map<string, number>()\n    for (const name of headers) seen.set(name, (seen.get(name) ?? 0) + 1)\n    return headers.map((name, column) =>\n      (seen.get(name) ?? 0) > 1 ? say.duplicateColumn(name, column) : name\n    )\n  }, [headers, say])\n\n  const dataRows = React.useMemo(\n    () => (activeHeader ? parsed.rows.slice(1) : parsed.rows).slice(0, previewRecords),\n    [parsed.rows, activeHeader, previewRecords]\n  )\n\n  const suggested = React.useMemo(\n    () => autoMatchFields(headers, fields),\n    [headers, fields]\n  )\n  const [edited, setEdited] = React.useState<CsvMapping>({})\n\n  /**\n   * The suggestion, with the reader's own choices laid over it.\n   *\n   * Only the fields actually chosen are held, rather than a copy of the whole mapping taken at the\n   * first edit. The difference shows the moment the columns change underneath: picking a separator\n   * or flipping the header toggle rebuilds the suggestion, and a stored full mapping would freeze\n   * every untouched field at whatever the old columns suggested — so a file reread with the right\n   * separator would keep pointing fields at the wrong ones. A chosen field stays chosen; an\n   * untouched field keeps following the suggestion.\n   */\n  const mapping = React.useMemo<CsvMapping>(() => {\n    if (value) return value\n    const base: CsvMapping = { ...suggested }\n    for (const field of fields) {\n      const choice = edited[field.name]\n      if (choice !== undefined) base[field.name] = choice\n    }\n    return base\n  }, [suggested, edited, value, fields])\n\n  const report = React.useMemo(\n    () => mappingReport(mapping, fields, columnCount),\n    [mapping, fields, columnCount]\n  )\n\n  const checks = React.useMemo(() => {\n    const out: { field: ImportField; column: number; check: ColumnCheck }[] = []\n    for (const field of fields) {\n      const column = mapping[field.name]\n      if (column === null || column === undefined) continue\n      const kind = field.kind ?? \"text\"\n      const values = dataRows.map((row) => row[column] ?? \"\")\n      const check = checkColumn(values, kind)\n      if (check.problems.length > 0 || (kind !== \"text\" && check.empty > 0)) {\n        out.push({ field, column, check })\n      }\n    }\n    return out\n  }, [fields, mapping, dataRows])\n\n  function choose(fieldName: string, column: number | null) {\n    setEdited((previous) => ({ ...previous, [fieldName]: column }))\n    const next: CsvMapping = { ...mapping, [fieldName]: column }\n    onValueChange?.(next, mappingReport(next, fields, columnCount))\n  }\n\n  const nothing = source.trim() === \"\" || parsed.rows.length === 0\n\n  // Nothing is wrong yet when there is nothing to be wrong about. Every required field is unmapped\n  // before a file arrives, and saying so then is a complaint about a decision the reader has not\n  // been given the chance to make — the same reasoning that leaves an untouched empty input alone.\n  const problems: string[] = []\n  if (!nothing && parsed.unterminatedQuote) problems.push(say.unterminatedQuote)\n  if (!nothing && columnCount === 1) problems.push(say.oneColumn)\n  if (!nothing && report.missingRequired.length > 0) {\n    problems.push(\n      say.missingRequired(\n        report.missingRequired.map(\n          (name) => fields.find((field) => field.name === name)?.label ?? name\n        )\n      )\n    )\n  }\n\n  const notes: string[] = []\n  if (!nothing && report.ignoredColumns.length > 0) {\n    notes.push(say.ignoredColumns(report.ignoredColumns.map((column) => headers[column])))\n  }\n  for (const reuse of report.reusedColumns) {\n    notes.push(\n      say.reusedColumn(\n        headers[reuse.column],\n        reuse.fields.map((name) => fields.find((field) => field.name === name)?.label ?? name)\n      )\n    )\n  }\n  for (const { field, check } of checks) {\n    const label = field.label ?? field.name\n    if (check.problems.length > 0) {\n      notes.push(\n        say.typeProblems(\n          label,\n          check.kind,\n          check.problems.map((problem) => problem.row + 1),\n          check.checked\n        )\n      )\n    }\n    if (check.empty > 0) notes.push(say.emptyCells(label, check.empty, check.checked))\n  }\n  if (dataRows.length > 0 && (parsed.truncated || fileTruncated)) {\n    notes.push(say.sampleOnly(dataRows.length))\n  }\n\n  return (\n    <div\n      className={cn(\"flex w-full flex-col gap-4 text-sm\", className)}\n      role=\"group\"\n      aria-label=\"Match columns to fields\"\n      aria-busy={reading || undefined}\n    >\n      <div className=\"flex flex-wrap items-end gap-4\">\n        <label className=\"flex flex-col gap-1\">\n          <span className=\"text-xs font-medium text-muted-foreground\">\n            {say.delimiterLabel}\n          </span>\n          <select\n            value={activeDelimiter}\n            disabled={disabled || delimiter !== undefined || nothing}\n            onChange={(event) => setChosenDelimiter(event.target.value)}\n            className=\"h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n          >\n            {CSV_DELIMITERS.map((option) => (\n              <option key={option} value={option}>\n                {option === \"\\t\" ? \"Tab\" : option}\n              </option>\n            ))}\n            {(CSV_DELIMITERS as readonly string[]).includes(activeDelimiter) ? null : (\n              <option value={activeDelimiter}>{activeDelimiter}</option>\n            )}\n          </select>\n        </label>\n\n        <label className=\"flex items-center gap-2\">\n          <input\n            type=\"checkbox\"\n            checked={activeHeader}\n            disabled={disabled || hasHeader !== undefined || nothing}\n            onChange={(event) => setChosenHeader(event.target.checked)}\n            className=\"size-4 rounded border-input accent-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\"\n          />\n          <span className=\"text-xs font-medium text-muted-foreground\">{say.headerLabel}</span>\n        </label>\n\n        {!nothing && !headerGuess.certain ? (\n          <span className=\"text-xs text-muted-foreground\">{headerGuess.reason}</span>\n        ) : null}\n      </div>\n\n      {nothing ? (\n        <p className=\"text-sm text-muted-foreground\">{say.empty}</p>\n      ) : (\n        <>\n          <div className=\"overflow-x-auto rounded-md border border-border\">\n            <table className=\"w-full border-collapse text-left text-xs\">\n              <caption className=\"sr-only\">\n                {say.previewCaption(dataRows.length, parsed.truncated || fileTruncated)}\n              </caption>\n              <thead>\n                <tr className=\"border-b border-border bg-muted/50\">\n                  {headers.map((name, column) => (\n                    <th\n                      key={column}\n                      scope=\"col\"\n                      className={cn(\n                        \"whitespace-nowrap px-2 py-1.5 font-medium\",\n                        report.ignoredColumns.includes(column)\n                          ? \"text-muted-foreground/60\"\n                          : \"text-foreground\"\n                      )}\n                    >\n                      {name}\n                    </th>\n                  ))}\n                </tr>\n              </thead>\n              <tbody>\n                {dataRows.map((row, index) => (\n                  <tr key={index} className=\"border-b border-border last:border-0\">\n                    {headers.map((_, column) => (\n                      <td\n                        key={column}\n                        className=\"max-w-[16rem] truncate px-2 py-1.5 text-muted-foreground\"\n                      >\n                        {row[column] ?? \"\"}\n                      </td>\n                    ))}\n                  </tr>\n                ))}\n              </tbody>\n            </table>\n          </div>\n\n          <ul className=\"flex flex-col gap-2\">\n            {fields.map((field) => {\n              const column = mapping[field.name]\n              const selectId = `${rootId}-${normalizeHeader(field.name) || \"field\"}`\n              const missing = report.missingRequired.includes(field.name)\n              return (\n                <li key={field.name} className=\"flex flex-wrap items-center gap-2\">\n                  <label\n                    htmlFor={selectId}\n                    className=\"min-w-[8rem] text-xs font-medium text-foreground\"\n                  >\n                    {field.label ?? field.name}\n                    {field.required ? (\n                      <span aria-hidden=\"true\" className=\"text-destructive\">\n                        {\" *\"}\n                      </span>\n                    ) : null}\n                    {field.required ? <span className=\"sr-only\"> (required)</span> : null}\n                  </label>\n                  <select\n                    id={selectId}\n                    value={column === null || column === undefined ? \"\" : String(column)}\n                    disabled={disabled}\n                    aria-invalid={missing || undefined}\n                    aria-describedby={missing ? statusId : undefined}\n                    onChange={(event) =>\n                      choose(field.name, event.target.value === \"\" ? null : Number(event.target.value))\n                    }\n                    className={cn(\n                      \"h-9 min-w-[12rem] rounded-md border border-input bg-transparent px-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50\",\n                      missing && \"border-destructive focus-visible:ring-destructive\"\n                    )}\n                  >\n                    <option value=\"\">{say.ignore}</option>\n                    {optionLabels.map((name, index) => (\n                      <option key={index} value={String(index)}>\n                        {name}\n                      </option>\n                    ))}\n                  </select>\n                </li>\n              )\n            })}\n          </ul>\n\n          {onConfirm ? (\n            <div>\n              <button\n                type=\"button\"\n                disabled={disabled || !report.complete}\n                onClick={() =>\n                  onConfirm({\n                    mapping,\n                    delimiter: activeDelimiter,\n                    hasHeader: activeHeader,\n                    headers,\n                    report,\n                  })\n                }\n                className=\"inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n              >\n                {say.confirm}\n              </button>\n            </div>\n          ) : null}\n        </>\n      )}\n\n      {/*\n        Outside the branch above, and therefore mounted from the first render on.\n        A live region that arrives in the same commit as its first message is not announced — the\n        technology watches a region it is already observing for changes, and a region that did not\n        exist a moment ago has no change to report. A file read asynchronously would hit exactly\n        that: nothing on screen until the bytes land, then the whole subtree appearing at once with\n        its messages already inside it. So the region ships empty and stays put, and only its\n        contents change.\n      */}\n      <div\n        id={statusId}\n        aria-live=\"polite\"\n        className={cn(\n          \"flex flex-col gap-1\",\n          problems.length === 0 && notes.length === 0 && \"sr-only\"\n        )}\n      >\n        {problems.map((message, index) => (\n          <p key={`problem-${index}`} className=\"text-xs text-destructive\">\n            {message}\n          </p>\n        ))}\n        {notes.map((message, index) => (\n          <p key={`note-${index}`} className=\"text-xs text-muted-foreground\">\n            {message}\n          </p>\n        ))}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
