{"name":"paste-table","type":"registry:component","files":[{"path":"paste-table.tsx","type":"registry:component","target":"components/ui/paste-table/paste-table.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport {\n  LoaderCircle as LoaderCircleIcon,\n  TriangleAlert as TriangleAlertIcon,\n  X as CloseIcon,\n} from \"honestui/icons\"\n\nimport { cn } from \"@/lib/utils\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Field,\n  FieldControl,\n  FieldDescription,\n  FieldLabel,\n} from \"@/components/ui/field\"\nimport { Input } from \"@/components/ui/input\"\nimport {\n  Select,\n  SelectItem,\n  SelectPopup,\n  SelectTrigger,\n  SelectValue,\n} from \"@/components/ui/select\"\nimport { Switch } from \"@/components/ui/switch\"\nimport {\n  Table,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableHeader,\n  TableRow,\n} from \"@/components/ui/table\"\nimport { Textarea } from \"@/components/ui/textarea\"\n\nimport type {\n  PasteTableCellResult,\n  PasteTableColumn,\n  PasteTableLabels,\n  PasteTableMapping,\n  PasteTableRowResult,\n  PasteTableSourceRow,\n} from \"./paste-table-types\"\nimport {\n  defaultPasteTableLabels,\n  evaluateRows,\n  getMissingColumns,\n  mapColumnsByPosition,\n  matchColumns,\n  parseClipboardTable,\n} from \"./paste-table-utils\"\n\nexport type {\n  PasteTableCellResult,\n  PasteTableColumn,\n  PasteTableDateColumn,\n  PasteTableDateOrder,\n  PasteTableDecimalSeparator,\n  PasteTableLabels,\n  PasteTableMapping,\n  PasteTableNumberColumn,\n  PasteTableParseResult,\n  PasteTableRowResult,\n  PasteTableSourceRow,\n  PasteTableTextColumn,\n  PasteTableValidateContext,\n} from \"./paste-table-types\"\nexport {\n  defaultPasteTableLabels,\n  evaluateRows,\n  getMissingColumns,\n  matchColumns,\n  parseClipboardTable,\n  parseDateCell,\n  parseNumberCell,\n} from \"./paste-table-utils\"\n\nconst miniText =\n  \"[font-size:var(--hui-font-size-mini)] [letter-spacing:var(--hui-letter-spacing-mini)] [line-height:var(--hui-line-height-mini)]\"\nconst bodyText =\n  \"[font-size:var(--hui-font-size-small)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)]\"\n/** Table headings default to the tertiary text color, which is too faint for 12px text. */\nconst headText = \"text-[var(--hui-color-foreground-base-secondary)]\"\n/** The danger color is 3.8:1 on the base background: enough for an icon, too faint for small text. */\nconst dangerText = \"text-[var(--hui-color-foreground-danger-primary)]\"\n/** Filled buttons lose their fill in forced colors, so they get an edge instead. */\nconst forcedColorsBorder = \"forced-colors:border forced-colors:border-[ButtonText]\"\n\n/** Rows rendered at first, and added by each press of \"Show more rows\". */\nconst ROW_PAGE = 50\n\ntype PasteSession = {\n  /** Every pasted row, including the header row when there is one. */\n  rows: PasteTableSourceRow[]\n  hasHeaderRow: boolean\n  mapping: PasteTableMapping\n}\n\ntype CellAddress = { rowId: number; column: number }\n\nfunction cellKey({ rowId, column }: CellAddress) {\n  return `${rowId}:${column}`\n}\n\nfunction displayValue(cell: PasteTableCellResult): string {\n  if (cell.error) return cell.raw\n  // Show the value that will be imported, so \"3/4/2026\" is seen as the date it was read as.\n  return typeof cell.value === \"string\" || typeof cell.value === \"number\"\n    ? String(cell.value)\n    : cell.raw\n}\n\nexport type PasteTableProps = Omit<React.ComponentProps<\"section\">, \"onPaste\"> & {\n  /** The fields a pasted column can be mapped to. Define them outside the component. */\n  columns: PasteTableColumn[]\n  /**\n   * Receives the valid rows when Import is pressed. Return a promise to keep\n   * the rows on screen until it settles; a rejection leaves them in place.\n   */\n  onImport: (rows: Record<string, unknown>[]) => void | Promise<void>\n  /** Character between cells. Spreadsheets copy with tabs. */\n  delimiter?: string\n  /** A paste with more data rows than this is refused with a message. */\n  maxRows?: number\n  disabled?: boolean\n  labels?: Partial<PasteTableLabels> | undefined\n}\n\nfunction PasteTable({\n  columns,\n  onImport,\n  delimiter = \"\\t\",\n  maxRows = 1000,\n  disabled = false,\n  labels: labelsProp,\n  className,\n  ...props\n}: PasteTableProps) {\n  const labels = React.useMemo(() => ({ ...defaultPasteTableLabels, ...labelsProp }), [labelsProp])\n\n  const [session, setSession] = React.useState<PasteSession | null>(null)\n  const [draft, setDraft] = React.useState(\"\")\n  const [pasteError, setPasteError] = React.useState<string | null>(null)\n  const [importedCount, setImportedCount] = React.useState<number | null>(null)\n  const [visibleCount, setVisibleCount] = React.useState(ROW_PAGE)\n  const [editing, setEditing] = React.useState<CellAddress | null>(null)\n  const [importState, setImportState] = React.useState<\n    { status: \"idle\" | \"blocked\" | \"importing\" } | { status: \"failed\"; reason: string | undefined }\n  >({ status: \"idle\" })\n\n  const rootRef = React.useRef<HTMLElement>(null)\n  /** A `data-focus` target to move to once the render that produces it has committed. */\n  const [focusRequest, setFocusRequest] = React.useState<{ target: string } | null>(null)\n  const requestFocus = (target: string) => setFocusRequest({ target })\n  const lastProblem = React.useRef<string | null>(null)\n\n  React.useEffect(() => {\n    if (!focusRequest) return\n    rootRef.current\n      ?.querySelector<HTMLElement>(`[data-focus=\"${focusRequest.target}\"]`)\n      ?.focus()\n  }, [focusRequest])\n\n  const dataRows = React.useMemo(\n    () => (session ? session.rows.slice(session.hasHeaderRow ? 1 : 0) : []),\n    [session]\n  )\n  const results = React.useMemo(\n    () => (session ? evaluateRows(dataRows, session.mapping, columns, labels) : []),\n    [session, dataRows, columns, labels]\n  )\n  const missingColumns = React.useMemo(\n    () => (session ? getMissingColumns(session.mapping, columns) : []),\n    [session, columns]\n  )\n\n  const problemCells = React.useMemo(\n    () =>\n      results.flatMap((row) =>\n        row.cells.flatMap((cell, column) => (cell?.error ? [{ rowId: row.id, column }] : []))\n      ),\n    [results]\n  )\n  const problemRowCount = results.filter((row) => !row.isValid).length\n  const isImporting = importState.status === \"importing\"\n  const isLocked = disabled || isImporting\n\n  function readText(text: string) {\n    const table = parseClipboardTable(text, delimiter)\n    if (table.length === 0) {\n      setPasteError(labels.emptyPaste)\n      return\n    }\n\n    const headerMapping = matchColumns(table[0], columns)\n    const hasHeaderRow = headerMapping.some((key) => key !== null)\n    const rowCount = table.length - (hasHeaderRow ? 1 : 0)\n    if (rowCount > maxRows) {\n      setPasteError(labels.tooManyRows({ rows: rowCount, maxRows }))\n      return\n    }\n\n    setSession({\n      rows: table.map((cells, id) => ({ id, cells })),\n      hasHeaderRow,\n      mapping: hasHeaderRow ? headerMapping : mapColumnsByPosition(table[0].length, columns),\n    })\n    setDraft(\"\")\n    setPasteError(null)\n    setImportedCount(null)\n    setVisibleCount(ROW_PAGE)\n    setImportState({ status: \"idle\" })\n    // The paste field is about to unmount, so move focus to the result of the paste.\n    requestFocus(\"summary\")\n  }\n\n  function closeSession(imported: number | null) {\n    setSession(null)\n    setEditing(null)\n    setImportedCount(imported)\n    setImportState({ status: \"idle\" })\n    lastProblem.current = null\n    requestFocus(\"paste\")\n  }\n\n  function updateSession(update: (current: PasteSession) => PasteSession) {\n    setSession((current) => (current ? update(current) : current))\n    setImportState({ status: \"idle\" })\n  }\n\n  function setHeaderRow(hasHeaderRow: boolean) {\n    updateSession((current) => {\n      if (!hasHeaderRow) return { ...current, hasHeaderRow }\n      // Headers that match a field take it; other columns keep the choice already made.\n      const matched = matchColumns(current.rows[0]?.cells ?? [], columns)\n      const taken = new Set(matched)\n      return {\n        ...current,\n        hasHeaderRow,\n        mapping: matched.map((key, index) => {\n          const previous = current.mapping[index]\n          return key ?? (previous !== null && !taken.has(previous) ? previous : null)\n        }),\n      }\n    })\n  }\n\n  function mapColumn(column: number, key: string | null) {\n    updateSession((current) => ({\n      ...current,\n      // A field holds one column, so choosing it here releases it from any other column.\n      mapping: current.mapping.map((existing, index) =>\n        index === column ? key : existing === key ? null : existing\n      ),\n    }))\n  }\n\n  function commitCell(address: CellAddress, value: string) {\n    updateSession((current) => ({\n      ...current,\n      rows: current.rows.map((row) =>\n        row.id === address.rowId\n          ? { ...row, cells: row.cells.map((cell, index) => (index === address.column ? value : cell)) }\n          : row\n      ),\n    }))\n  }\n\n  function removeRows(ids: Set<number>) {\n    updateSession((current) => ({ ...current, rows: current.rows.filter((row) => !ids.has(row.id)) }))\n    requestFocus(\"summary\")\n  }\n\n  function focusNextProblem() {\n    if (missingColumns.length > 0) {\n      requestFocus(\"problems\")\n    } else if (problemCells.length > 0) {\n      const keys = problemCells.map(cellKey)\n      const next = keys[(keys.indexOf(lastProblem.current ?? \"\") + 1) % keys.length]\n      const rowIndex = results.findIndex((row) => row.id === Number(next.split(\":\")[0]))\n      lastProblem.current = next\n      setVisibleCount((count) => Math.max(count, rowIndex + 1))\n      requestFocus(next)\n    }\n  }\n\n  async function runImport() {\n    if (isLocked || results.length === 0) return\n    if (missingColumns.length > 0 || problemCells.length > 0) {\n      setImportState({ status: \"blocked\" })\n      focusNextProblem()\n      return\n    }\n\n    setImportState({ status: \"importing\" })\n    try {\n      await onImport(results.map((row) => row.values))\n      closeSession(results.length)\n    } catch (error) {\n      setImportState({\n        status: \"failed\",\n        reason: error instanceof Error && error.message ? error.message : undefined,\n      })\n      requestFocus(\"summary\")\n    }\n  }\n\n  return (\n    <section\n      ref={rootRef}\n      data-slot=\"paste-table-root\"\n      data-step={session ? \"review\" : \"paste\"}\n      className={cn(\"@container flex w-full min-w-0 flex-col gap-[var(--hui-space-3)]\", className)}\n      {...props}\n    >\n      {session ? (\n        <>\n          <ReviewHeader\n            labels={labels}\n            rowCount={results.length}\n            problemRowCount={problemRowCount}\n            problemCellCount={problemCells.length}\n            hasHeaderRow={session.hasHeaderRow}\n            onHeaderRowChange={setHeaderRow}\n            disabled={isLocked}\n          />\n\n          {(missingColumns.length > 0 || importState.status === \"blocked\" || importState.status === \"failed\") && (\n            <div\n              data-slot=\"paste-table-problems\"\n              data-focus=\"problems\"\n              role=\"alert\"\n              tabIndex={-1}\n              className={cn(\n                \"flex items-start gap-[var(--hui-space-2)] rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-danger-emphasis)] px-[var(--hui-space-3)] py-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)] outline-none focus-visible:[outline:var(--hui-focus-ring)]\",\n                bodyText\n              )}\n            >\n              <TriangleAlertIcon aria-hidden=\"true\" className={cn(\"mt-0.5 size-3.5 shrink-0\", dangerText)} />\n              <ul className=\"m-0 flex min-w-0 list-none flex-col gap-[var(--hui-space-1)] p-0\">\n                {importState.status === \"failed\" && <li>{labels.importFailed(importState.reason)}</li>}\n                {importState.status === \"blocked\" && missingColumns.length === 0 && (\n                  <li>{labels.importBlocked}</li>\n                )}\n                {missingColumns.map((label) => (\n                  <li key={label}>{labels.missingColumn(label)}</li>\n                ))}\n              </ul>\n            </div>\n          )}\n\n          <div\n            data-slot=\"paste-table-preview\"\n            className=\"w-full min-w-0 overflow-hidden rounded-[var(--hui-radius-2)] border-[0.5px] border-[var(--hui-color-border-base-secondary)]\"\n          >\n            {/* Table sets tabular figures, which also widens the spaces and points in names and emails. */}\n            <Table className=\"normal-nums\">\n              <TableHeader>\n                <TableRow>\n                  <TableHead scope=\"col\" className={cn(\"w-px whitespace-nowrap\", headText)}>\n                    {labels.rowHeading}\n                  </TableHead>\n                  {session.mapping.map((key, column) => {\n                    const pastedName = session.hasHeaderRow ? session.rows[0]?.cells[column] : \"\"\n                    const name = pastedName || labels.columnName(column + 1)\n                    return (\n                      <TableHead key={column} scope=\"col\" className={cn(\"min-w-28 align-top\", headText)}>\n                        <span className=\"flex flex-col gap-[var(--hui-space-2)]\">\n                          <span className=\"break-words\">{name}</span>\n                          <ColumnSelect\n                            label={labels.mapColumn(name)}\n                            skipLabel={labels.skipColumn}\n                            columns={columns}\n                            value={key}\n                            onValueChange={(next) => mapColumn(column, next)}\n                            disabled={isLocked}\n                          />\n                        </span>\n                      </TableHead>\n                    )\n                  })}\n                  <TableHead scope=\"col\" className=\"w-px\">\n                    <span className=\"sr-only\">{labels.actionsHeading}</span>\n                  </TableHead>\n                </TableRow>\n              </TableHeader>\n              <TableBody>\n                {results.length === 0 && (\n                  <TableRow>\n                    <TableCell colSpan={session.mapping.length + 2} className=\"text-center\">\n                      {labels.noRows}\n                    </TableCell>\n                  </TableRow>\n                )}\n                {results.slice(0, visibleCount).map((row, rowIndex) => (\n                  <PreviewRow\n                    key={row.id}\n                    row={row}\n                    rowNumber={rowIndex + 1}\n                    source={dataRows[rowIndex]}\n                    mapping={session.mapping}\n                    columns={columns}\n                    labels={labels}\n                    editing={editing?.rowId === row.id ? editing.column : null}\n                    disabled={isLocked}\n                    onEdit={(column) => setEditing({ rowId: row.id, column })}\n                    onCommit={(column, value, returnFocus) => {\n                      const address = { rowId: row.id, column }\n                      if (value !== null) commitCell(address, value)\n                      if (returnFocus) requestFocus(cellKey(address))\n                      setEditing(null)\n                    }}\n                    onRemove={() => removeRows(new Set([row.id]))}\n                  />\n                ))}\n              </TableBody>\n            </Table>\n          </div>\n\n          {results.length > visibleCount && (\n            <Button\n              variant=\"link\"\n              size=\"sm\"\n              className=\"self-start\"\n              onClick={() => setVisibleCount((count) => count + ROW_PAGE)}\n            >\n              {labels.showMoreRows(Math.min(ROW_PAGE, results.length - visibleCount))}\n            </Button>\n          )}\n\n          <div\n            data-slot=\"paste-table-actions\"\n            className=\"flex flex-wrap items-center justify-between gap-[var(--hui-space-2)]\"\n          >\n            <Button\n              variant=\"ghost\"\n              size=\"sm\"\n              disabled={isLocked}\n              onClick={() => closeSession(null)}\n            >\n              {labels.discard}\n            </Button>\n            <div className=\"flex flex-wrap items-center gap-[var(--hui-space-2)]\">\n              {problemCells.length > 0 && (\n                <>\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    className={forcedColorsBorder}\n                    disabled={isLocked}\n                    onClick={() =>\n                      removeRows(new Set(results.filter((row) => !row.isValid).map((row) => row.id)))\n                    }\n                  >\n                    {labels.removeProblemRows(problemRowCount)}\n                  </Button>\n                  <Button\n                    variant=\"secondary\"\n                    size=\"sm\"\n                    className={forcedColorsBorder}\n                    disabled={isLocked}\n                    onClick={focusNextProblem}\n                  >\n                    {labels.nextProblem}\n                  </Button>\n                </>\n              )}\n              <Button\n                size=\"sm\"\n                className={forcedColorsBorder}\n                disabled={isLocked || results.length === 0}\n                onClick={runImport}\n              >\n                {isImporting && (\n                  <LoaderCircleIcon aria-hidden=\"true\" className=\"size-3.5 motion-safe:animate-spin\" />\n                )}\n                {isImporting ? labels.importing : labels.import(results.length)}\n              </Button>\n            </div>\n          </div>\n        </>\n      ) : (\n        <>\n          <Field disabled={disabled}>\n            <FieldLabel>{labels.pasteLabel}</FieldLabel>\n            <FieldControl\n              value={draft}\n              onValueChange={(value) => {\n                setDraft(value)\n                setPasteError(null)\n              }}\n              onPaste={(event) => {\n                const text = event.clipboardData.getData(\"text/plain\")\n                if (text.trim() === \"\") return\n                event.preventDefault()\n                readText(text)\n              }}\n              render={(controlProps) => (\n                <Textarea\n                  rows={4}\n                  placeholder={labels.pastePlaceholder}\n                  data-focus=\"paste\"\n                  aria-invalid={pasteError ? true : undefined}\n                  {...controlProps}\n                />\n              )}\n            />\n            <FieldDescription>\n              {labels.pasteDescription(columns.map((column) => column.label))}\n            </FieldDescription>\n          </Field>\n          {pasteError && (\n            <p\n              role=\"alert\"\n              data-slot=\"paste-table-paste-error\"\n              className={cn(\n                \"m-0 flex items-start gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)]\",\n                bodyText\n              )}\n            >\n              <TriangleAlertIcon aria-hidden=\"true\" className={cn(\"mt-0.5 size-3.5 shrink-0\", dangerText)} />\n              {pasteError}\n            </p>\n          )}\n          {/* Text that arrived without a paste event, such as typed or dictated rows. */}\n          {draft.trim() !== \"\" && (\n            <Button\n              variant=\"secondary\"\n              size=\"sm\"\n              className={cn(\"self-start\", forcedColorsBorder)}\n              disabled={disabled}\n              onClick={() => readText(draft)}\n            >\n              {labels.readDraft}\n            </Button>\n          )}\n          <p\n            role=\"status\"\n            data-slot=\"paste-table-result\"\n            className={cn(\"m-0 text-[var(--hui-color-foreground-base-primary)] empty:hidden\", bodyText)}\n          >\n            {importedCount !== null && labels.imported(importedCount)}\n          </p>\n        </>\n      )}\n    </section>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Review header                                                              */\n/* -------------------------------------------------------------------------- */\n\nfunction ReviewHeader({\n  labels,\n  rowCount,\n  problemRowCount,\n  problemCellCount,\n  hasHeaderRow,\n  onHeaderRowChange,\n  disabled,\n}: {\n  labels: PasteTableLabels\n  rowCount: number\n  problemRowCount: number\n  problemCellCount: number\n  hasHeaderRow: boolean\n  onHeaderRowChange: (hasHeaderRow: boolean) => void\n  disabled: boolean\n}) {\n  const labelId = React.useId()\n\n  return (\n    <div\n      data-slot=\"paste-table-header\"\n      className=\"flex flex-wrap items-center justify-between gap-x-[var(--hui-space-4)] gap-y-[var(--hui-space-2)]\"\n    >\n      <p\n        role=\"status\"\n        tabIndex={-1}\n        data-slot=\"paste-table-summary\"\n        data-focus=\"summary\"\n        className={cn(\n          \"m-0 rounded-[var(--hui-radius-1)] text-[var(--hui-color-foreground-base-primary)] outline-none [font-weight:var(--hui-font-weight-medium)] focus-visible:[outline:var(--hui-focus-ring)]\",\n          bodyText\n        )}\n      >\n        {rowCount === 0\n          ? labels.noRows\n          : labels.summary({ rows: rowCount, problemRows: problemRowCount, problemCells: problemCellCount })}\n      </p>\n      <div\n        className={cn(\n          \"inline-flex min-h-6 items-center gap-[var(--hui-space-2)] text-[var(--hui-color-foreground-base-primary)]\",\n          bodyText\n        )}\n      >\n        {/* The switch is not a form control a <label> can name, so it is named by reference. */}\n        <Switch\n          aria-labelledby={labelId}\n          checked={hasHeaderRow}\n          onCheckedChange={onHeaderRowChange}\n          disabled={disabled}\n          // Forced colors removes the track and thumb fills, so draw both with system colors.\n          className={cn(forcedColorsBorder, \"forced-colors:box-content forced-colors:[&>span]:bg-[ButtonText]\")}\n        />\n        <span\n          id={labelId}\n          className={disabled ? undefined : \"cursor-pointer\"}\n          onClick={disabled ? undefined : () => onHeaderRowChange(!hasHeaderRow)}\n        >\n          {labels.headerRow}\n        </span>\n      </div>\n    </div>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Column mapping                                                             */\n/* -------------------------------------------------------------------------- */\n\nfunction ColumnSelect({\n  label,\n  skipLabel,\n  columns,\n  value,\n  onValueChange,\n  disabled,\n}: {\n  label: string\n  skipLabel: string\n  columns: PasteTableColumn[]\n  value: string | null\n  onValueChange: (key: string | null) => void\n  disabled: boolean\n}) {\n  const items = React.useMemo(\n    () => [\n      { label: skipLabel, value: null },\n      ...columns.map((column) => ({ label: column.label, value: column.key })),\n    ],\n    [skipLabel, columns]\n  )\n\n  return (\n    <Select items={items} value={value} onValueChange={onValueChange} disabled={disabled}>\n      <SelectTrigger size=\"sm\" aria-label={label} className=\"w-full\">\n        <SelectValue />\n      </SelectTrigger>\n      <SelectPopup>\n        {items.map((item) => (\n          <SelectItem key={item.value ?? \"\"} value={item.value}>\n            {item.label}\n          </SelectItem>\n        ))}\n      </SelectPopup>\n    </Select>\n  )\n}\n\n/* -------------------------------------------------------------------------- */\n/* Rows and cells                                                             */\n/* -------------------------------------------------------------------------- */\n\nfunction PreviewRow({\n  row,\n  rowNumber,\n  source,\n  mapping,\n  columns,\n  labels,\n  editing,\n  disabled,\n  onEdit,\n  onCommit,\n  onRemove,\n}: {\n  row: PasteTableRowResult\n  rowNumber: number\n  source: PasteTableSourceRow | undefined\n  mapping: PasteTableMapping\n  columns: PasteTableColumn[]\n  labels: PasteTableLabels\n  /** Index of the column being edited in this row. */\n  editing: number | null\n  disabled: boolean\n  onEdit: (column: number) => void\n  /** `value` is `null` when the edit was cancelled. */\n  onCommit: (column: number, value: string | null, returnFocus: boolean) => void\n  onRemove: () => void\n}) {\n  return (\n    <TableRow data-slot=\"paste-table-row\" data-invalid={row.isValid ? undefined : \"\"}>\n      <th\n        scope=\"row\"\n        className={cn(\n          \"border-b-[0.5px] border-[var(--hui-color-border-base-primary)] px-[var(--hui-space-3)] py-[var(--hui-space-2)] text-left align-top text-[var(--hui-color-foreground-base-secondary)] [font-weight:var(--hui-font-weight-regular)]\",\n          miniText\n        )}\n      >\n        {rowNumber}\n      </th>\n      {row.cells.map((cell, column) => {\n        if (!cell) {\n          return (\n            <TableCell\n              key={column}\n              data-slot=\"paste-table-skipped-cell\"\n              className={cn(\n                \"max-w-80 py-[var(--hui-space-2)] align-top break-words whitespace-normal text-[var(--hui-color-foreground-base-secondary)]\",\n                bodyText\n              )}\n            >\n              {source?.cells[column]}\n            </TableCell>\n          )\n        }\n\n        const columnLabel = columns.find((candidate) => candidate.key === mapping[column])?.label ?? \"\"\n        return (\n          <PreviewCell\n            key={column}\n            cell={cell}\n            focusKey={cellKey({ rowId: row.id, column })}\n            rowNumber={rowNumber}\n            columnLabel={columnLabel}\n            labels={labels}\n            isEditing={editing === column}\n            disabled={disabled}\n            onEdit={() => onEdit(column)}\n            onCommit={(value, returnFocus) => onCommit(column, value, returnFocus)}\n          />\n        )\n      })}\n      <TableCell className=\"py-[var(--hui-space-2)] align-top\">\n        <Button\n          variant=\"link\"\n          size=\"icon-sm\"\n          aria-label={labels.removeRow(rowNumber)}\n          disabled={disabled}\n          onClick={onRemove}\n        >\n          <CloseIcon aria-hidden=\"true\" className=\"size-3.5\" />\n        </Button>\n      </TableCell>\n    </TableRow>\n  )\n}\n\nfunction PreviewCell({\n  cell,\n  focusKey,\n  rowNumber,\n  columnLabel,\n  labels,\n  isEditing,\n  disabled,\n  onEdit,\n  onCommit,\n}: {\n  cell: PasteTableCellResult\n  focusKey: string\n  rowNumber: number\n  columnLabel: string\n  labels: PasteTableLabels\n  isEditing: boolean\n  disabled: boolean\n  onEdit: () => void\n  onCommit: (value: string | null, returnFocus: boolean) => void\n}) {\n  const errorId = React.useId()\n  /** Enter and Escape settle the edit themselves, so the blur they cause must not settle it again. */\n  const settled = React.useRef(false)\n  const editorRef = React.useRef<HTMLSpanElement>(null)\n  const shown = displayValue(cell)\n\n  React.useEffect(() => {\n    if (!isEditing) return\n    settled.current = false\n    const input = editorRef.current?.querySelector(\"input\")\n    input?.focus()\n    input?.select()\n  }, [isEditing])\n\n  return (\n    <TableCell\n      data-slot=\"paste-table-cell\"\n      data-invalid={cell.error ? \"\" : undefined}\n      className={cn(\n        \"max-w-80 py-[var(--hui-space-2)] align-top whitespace-normal text-[var(--hui-color-foreground-base-primary)]\",\n        bodyText\n      )}\n    >\n      {isEditing ? (\n        <span ref={editorRef} className=\"block\">\n          <Input\n            size=\"sm\"\n            defaultValue={cell.raw}\n            aria-label={labels.cellInput({ row: rowNumber, column: columnLabel })}\n            aria-invalid={cell.error ? true : undefined}\n            aria-describedby={cell.error ? errorId : undefined}\n            onBlur={(event) => {\n              if (!settled.current) onCommit(event.currentTarget.value.trim(), false)\n            }}\n            onKeyDown={(event) => {\n              if (event.key !== \"Enter\" && event.key !== \"Escape\") return\n              event.preventDefault()\n              settled.current = true\n              onCommit(event.key === \"Enter\" ? event.currentTarget.value.trim() : null, true)\n            }}\n          />\n        </span>\n      ) : (\n        <button\n          type=\"button\"\n          data-focus={focusKey}\n          disabled={disabled}\n          aria-label={labels.editCell({\n            row: rowNumber,\n            column: columnLabel,\n            value: shown || labels.emptyCell,\n          })}\n          aria-describedby={cell.error ? errorId : undefined}\n          onClick={onEdit}\n          className={cn(\n            \"block min-h-6 w-full cursor-text rounded-[var(--hui-radius-1)] border-0 bg-transparent p-0 text-left break-words text-inherit outline-none [font:inherit] hover:bg-[var(--hui-color-background-base-primary-hover)] focus-visible:[outline:var(--hui-focus-ring)] disabled:cursor-not-allowed\",\n            cell.error && \"underline decoration-[var(--hui-color-border-danger-emphasis)] decoration-wavy underline-offset-4\",\n            shown === \"\" && \"text-[var(--hui-color-foreground-base-secondary)]\"\n          )}\n        >\n          {shown || labels.emptyCell}\n        </button>\n      )}\n      {cell.error && (\n        <span\n          id={errorId}\n          data-slot=\"paste-table-cell-error\"\n          className={cn(\"mt-[var(--hui-space-1)] flex items-start gap-[var(--hui-space-1)]\", miniText)}\n        >\n          <TriangleAlertIcon aria-hidden=\"true\" className={cn(\"mt-px size-3 shrink-0\", dangerText)} />\n          {cell.error}\n        </span>\n      )}\n    </TableCell>\n  )\n}\n\nexport { PasteTable }\n"},{"path":"paste-table-types.ts","type":"registry:component","target":"components/ui/paste-table/paste-table-types.ts","content":"/** Which part of a date comes first in a pasted cell such as `03/04/2026`. */\nexport type PasteTableDateOrder = \"ymd\" | \"mdy\" | \"dmy\"\n\nexport type PasteTableDecimalSeparator = \".\" | \",\"\n\n/** What a custom `parse` returns: the value to import, or why the cell is invalid. */\nexport type PasteTableParseResult = { value: unknown } | { error: string }\n\nexport type PasteTableValidateContext = {\n  /** The cell's text as pasted or last edited. */\n  raw: string\n  /** Parsed values of the same pasted row, keyed by column. Invalid cells are absent. */\n  row: Record<string, unknown>\n}\n\ntype PasteTableColumnBase = {\n  /** Property name of this field in each imported row. */\n  key: string\n  label: string\n  /** Other header names that should map to this field, such as `E-mail`. */\n  aliases?: string[] | undefined\n  /** An empty cell is invalid, and a pasted column must be mapped to this field. */\n  required?: boolean | undefined\n  /** Replaces the built-in parsing for the column's type. Not called for empty cells. */\n  parse?: ((raw: string) => PasteTableParseResult) | undefined\n  /** Returns a message when a parsed value is not acceptable. Not called for empty cells. */\n  validate?: ((value: unknown, context: PasteTableValidateContext) => string | undefined) | undefined\n}\n\nexport type PasteTableTextColumn = PasteTableColumnBase & {\n  type?: \"text\" | undefined\n}\n\nexport type PasteTableNumberColumn = PasteTableColumnBase & {\n  type: \"number\"\n  /** The character before the fraction. The other of `.` and `,` groups thousands. */\n  decimalSeparator?: PasteTableDecimalSeparator | undefined\n}\n\nexport type PasteTableDateColumn = PasteTableColumnBase & {\n  type: \"date\"\n  /** Required, because `03/04/2026` is a different day in different countries. */\n  dateOrder: PasteTableDateOrder\n}\n\nexport type PasteTableColumn = PasteTableTextColumn | PasteTableNumberColumn | PasteTableDateColumn\n\n/** One pasted row. `id` stays the same while cells are edited and rows are removed. */\nexport type PasteTableSourceRow = {\n  id: number\n  cells: string[]\n}\n\n/** Field key for each pasted column, or `null` when the column is not imported. */\nexport type PasteTableMapping = (string | null)[]\n\nexport type PasteTableCellResult = {\n  raw: string\n  /** The value that would be imported. `null` for an empty cell. */\n  value: unknown\n  error?: string | undefined\n}\n\nexport type PasteTableRowResult = {\n  id: number\n  /** One entry per pasted column; `null` where the column is not imported. */\n  cells: (PasteTableCellResult | null)[]\n  /** Parsed values keyed by field. Only complete when `isValid` is true. */\n  values: Record<string, unknown>\n  isValid: boolean\n}\n\nexport type PasteTableLabels = {\n  pasteLabel: string\n  pasteDescription: (columns: string[]) => string\n  pastePlaceholder: string\n  /** Button that reads text which arrived without a paste event, such as typed text. */\n  readDraft: string\n  emptyPaste: string\n  tooManyRows: (counts: { rows: number; maxRows: number }) => string\n  imported: (rows: number) => string\n  headerRow: string\n  /** Column heading when the paste has no header row. */\n  columnName: (position: number) => string\n  rowHeading: string\n  actionsHeading: string\n  skipColumn: string\n  mapColumn: (columnName: string) => string\n  missingColumn: (fieldLabel: string) => string\n  summary: (counts: { rows: number; problemRows: number; problemCells: number }) => string\n  noRows: string\n  emptyCell: string\n  editCell: (cell: { row: number; column: string; value: string }) => string\n  cellInput: (cell: { row: number; column: string }) => string\n  removeRow: (row: number) => string\n  removeProblemRows: (rows: number) => string\n  nextProblem: string\n  showMoreRows: (rows: number) => string\n  discard: string\n  import: (rows: number) => string\n  importing: string\n  /** Shown when Import is pressed while cells or mappings still need attention. */\n  importBlocked: string\n  importFailed: (reason: string | undefined) => string\n  required: string\n  invalidNumber: (decimalSeparator: PasteTableDecimalSeparator) => string\n  invalidDate: (dateOrder: PasteTableDateOrder) => string\n}\n"},{"path":"paste-table-utils.ts","type":"registry:component","target":"components/ui/paste-table/paste-table-utils.ts","content":"import type {\n  PasteTableCellResult,\n  PasteTableColumn,\n  PasteTableDateOrder,\n  PasteTableDecimalSeparator,\n  PasteTableLabels,\n  PasteTableMapping,\n  PasteTableRowResult,\n  PasteTableSourceRow,\n} from \"./paste-table-types\"\n\nfunction trimCell(cell: string): string {\n  // Spreadsheets pad cells with no-break spaces, which String.trim also removes.\n  return cell.trim()\n}\n\n/**\n * Splits clipboard text into rows of trimmed cells. Excel and Google Sheets\n * separate cells with tabs and wrap a cell in double quotes when it contains\n * a tab, a line break, or a quote; a quote inside such a cell is doubled.\n * Blank rows are dropped and short rows are padded to the widest row.\n */\nexport function parseClipboardTable(text: string, delimiter = \"\\t\"): string[][] {\n  const rows: string[][] = []\n  let row: string[] = []\n  let cell = \"\"\n  let quoted = false\n  let atCellStart = true\n\n  const endCell = () => {\n    row.push(trimCell(cell))\n    cell = \"\"\n    atCellStart = true\n  }\n  const endRow = () => {\n    endCell()\n    if (row.some((value) => value !== \"\")) rows.push(row)\n    row = []\n  }\n\n  for (let index = 0; index < text.length; index += 1) {\n    const char = text[index]\n\n    if (quoted) {\n      if (char !== '\"') cell += char\n      else if (text[index + 1] === '\"') {\n        cell += '\"'\n        index += 1\n      } else quoted = false\n      continue\n    }\n\n    if (char === '\"' && atCellStart) {\n      quoted = true\n      atCellStart = false\n    } else if (char === delimiter) {\n      endCell()\n    } else if (char === \"\\n\" || char === \"\\r\") {\n      if (char === \"\\r\" && text[index + 1] === \"\\n\") index += 1\n      endRow()\n    } else {\n      cell += char\n      atCellStart = false\n    }\n  }\n  endRow()\n\n  const width = Math.max(0, ...rows.map((cells) => cells.length))\n  return rows.map((cells) =>\n    cells.length === width ? cells : [...cells, ...Array<string>(width - cells.length).fill(\"\")]\n  )\n}\n\n/**\n * Reads a number written with the given decimal separator. Accepts thousands\n * groups, a sign or accounting parentheses, one currency symbol, and Excel's\n * scientific notation. Returns `null` for anything else, including a number\n * written for the other separator, so `1,5` is never read as fifteen.\n */\nexport function parseNumberCell(\n  raw: string,\n  decimalSeparator: PasteTableDecimalSeparator = \".\"\n): number | null {\n  let text = raw.replace(/\\s/g, \"\")\n  let negative = false\n\n  const parenthesized = /^\\((.+)\\)$/.exec(text)\n  if (parenthesized) {\n    negative = true\n    text = parenthesized[1]\n  }\n\n  text = text.replace(/^([+\\-−]?)[$€£¥]/, \"$1\").replace(/[$€£¥]$/, \"\")\n\n  const sign = /^[+\\-−]/.exec(text)\n  if (sign) {\n    if (sign[0] !== \"+\") negative = !negative\n    text = text.slice(1)\n  }\n\n  const group = decimalSeparator === \".\" ? \",\" : \"\\\\.\"\n  const decimal = decimalSeparator === \".\" ? \"\\\\.\" : \",\"\n  const match = new RegExp(\n    `^(\\\\d{1,3}(?:${group}\\\\d{3})+|\\\\d+)?(?:${decimal}(\\\\d*))?(?:[eE]([+-]?\\\\d+))?$`\n  ).exec(text)\n  if (!match) return null\n\n  const [, whole = \"\", fraction = \"\", exponent] = match\n  if (whole === \"\" && fraction === \"\") return null\n\n  const value = Number(\n    `${whole.replace(/\\D/g, \"\") || \"0\"}.${fraction || \"0\"}${exponent ? `e${exponent}` : \"\"}`\n  )\n  if (!Number.isFinite(value)) return null\n  return negative ? -value : value\n}\n\n/**\n * Reads a calendar date and returns it as `YYYY-MM-DD`. A date that starts\n * with a four-digit year is accepted for every order. Two-digit years, times,\n * and days that do not exist return `null`.\n */\nexport function parseDateCell(raw: string, dateOrder: PasteTableDateOrder): string | null {\n  const text = raw.trim()\n  let year: number\n  let month: number\n  let day: number\n\n  const yearFirst = /^(\\d{4})[-/.](\\d{1,2})[-/.](\\d{1,2})$/.exec(text)\n  if (yearFirst) {\n    ;[year, month, day] = yearFirst.slice(1).map(Number)\n  } else {\n    const yearLast = dateOrder === \"ymd\" ? null : /^(\\d{1,2})[-/.](\\d{1,2})[-/.](\\d{4})$/.exec(text)\n    if (!yearLast) return null\n    const [first, second] = [Number(yearLast[1]), Number(yearLast[2])]\n    year = Number(yearLast[3])\n    month = dateOrder === \"mdy\" ? first : second\n    day = dateOrder === \"mdy\" ? second : first\n  }\n\n  const date = new Date(Date.UTC(year, month - 1, day))\n  const exists =\n    date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day\n  if (!exists) return null\n\n  const pad = (value: number, length: number) => String(value).padStart(length, \"0\")\n  return `${pad(year, 4)}-${pad(month, 2)}-${pad(day, 2)}`\n}\n\nfunction normalizeHeader(text: string): string {\n  return text.toLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \"\")\n}\n\n/**\n * Maps each pasted header to the field whose label, key, or alias it equals,\n * ignoring case, spaces, and punctuation. A field is used once; later\n * headers that match it stay unmapped.\n */\nexport function matchColumns(headers: string[], columns: PasteTableColumn[]): PasteTableMapping {\n  const used = new Set<string>()\n\n  return headers.map((header) => {\n    const name = normalizeHeader(header)\n    if (name === \"\") return null\n\n    const column = columns.find(\n      (candidate) =>\n        !used.has(candidate.key) &&\n        [candidate.label, candidate.key, ...(candidate.aliases ?? [])].some(\n          (option) => normalizeHeader(option) === name\n        )\n    )\n    if (!column) return null\n\n    used.add(column.key)\n    return column.key\n  })\n}\n\n/** Without a header row there are no names to match, so fields follow column order. */\nexport function mapColumnsByPosition(columnCount: number, columns: PasteTableColumn[]): PasteTableMapping {\n  return Array.from({ length: columnCount }, (_, index) => columns[index]?.key ?? null)\n}\n\n/** Labels of required fields that no pasted column is mapped to. */\nexport function getMissingColumns(mapping: PasteTableMapping, columns: PasteTableColumn[]): string[] {\n  return columns\n    .filter((column) => column.required && !mapping.includes(column.key))\n    .map((column) => column.label)\n}\n\nfunction parseCell(raw: string, column: PasteTableColumn, labels: PasteTableLabels): PasteTableCellResult {\n  if (raw === \"\") {\n    return column.required ? { raw, value: null, error: labels.required } : { raw, value: null }\n  }\n\n  if (column.parse) {\n    const result = column.parse(raw)\n    return \"error\" in result ? { raw, value: undefined, error: result.error } : { raw, value: result.value }\n  }\n\n  if (column.type === \"number\") {\n    const value = parseNumberCell(raw, column.decimalSeparator)\n    return value === null\n      ? { raw, value: undefined, error: labels.invalidNumber(column.decimalSeparator ?? \".\") }\n      : { raw, value }\n  }\n\n  if (column.type === \"date\") {\n    const value = parseDateCell(raw, column.dateOrder)\n    return value === null\n      ? { raw, value: undefined, error: labels.invalidDate(column.dateOrder) }\n      : { raw, value }\n  }\n\n  return { raw, value: raw }\n}\n\n/**\n * Parses and validates every mapped cell. Validation runs after the whole row\n * is parsed, so a `validate` callback can compare fields in the same row.\n */\nexport function evaluateRows(\n  rows: PasteTableSourceRow[],\n  mapping: PasteTableMapping,\n  columns: PasteTableColumn[],\n  labels: PasteTableLabels\n): PasteTableRowResult[] {\n  const mappedColumns = mapping.map((key) => columns.find((column) => column.key === key))\n\n  return rows.map((row) => {\n    const cells = mappedColumns.map((column, index) =>\n      column ? parseCell(row.cells[index] ?? \"\", column, labels) : null\n    )\n\n    const values: Record<string, unknown> = {}\n    cells.forEach((cell, index) => {\n      const column = mappedColumns[index]\n      if (column && cell && !cell.error) values[column.key] = cell.value\n    })\n\n    cells.forEach((cell, index) => {\n      const validate = mappedColumns[index]?.validate\n      if (!validate || !cell || cell.error || cell.value === null) return\n      const message = validate(cell.value, { raw: cell.raw, row: values })\n      if (message) cell.error = message\n    })\n\n    return { id: row.id, cells, values, isValid: cells.every((cell) => !cell?.error) }\n  })\n}\n\nfunction plural(count: number, singular: string, pluralForm: string) {\n  return `${count.toLocaleString(\"en\")} ${count === 1 ? singular : pluralForm}`\n}\n\nconst dateExamples: Record<PasteTableDateOrder, string> = {\n  ymd: \"YYYY-MM-DD\",\n  mdy: \"MM/DD/YYYY\",\n  dmy: \"DD/MM/YYYY\",\n}\n\nexport const defaultPasteTableLabels: PasteTableLabels = {\n  pasteLabel: \"Paste rows from a spreadsheet\",\n  pasteDescription: (columns) =>\n    `Copy cells in Excel or Google Sheets, then paste them here. Fields: ${columns.join(\", \")}.`,\n  pastePlaceholder: \"Paste cells here\",\n  readDraft: \"Preview rows\",\n  emptyPaste: \"There were no cells in that paste. Copy at least one row, then paste again.\",\n  tooManyRows: ({ rows, maxRows }) =>\n    `That paste has ${plural(rows, \"row\", \"rows\")}. Paste ${maxRows.toLocaleString(\"en\")} or fewer at a time.`,\n  imported: (rows) => `${plural(rows, \"row\", \"rows\")} imported.`,\n  headerRow: \"First row is a header\",\n  columnName: (position) => `Column ${position}`,\n  rowHeading: \"Row\",\n  actionsHeading: \"Remove\",\n  skipColumn: \"Do not import\",\n  mapColumn: (columnName) => `Field for ${columnName}`,\n  missingColumn: (fieldLabel) =>\n    `No column is mapped to ${fieldLabel}. Choose ${fieldLabel} for one of the columns.`,\n  summary: ({ rows, problemRows, problemCells }) =>\n    problemRows === 0\n      ? `${plural(rows, \"row\", \"rows\")} ready to import.`\n      : `${plural(rows, \"row\", \"rows\")}. ${plural(problemCells, \"cell\", \"cells\")} in ${plural(problemRows, \"row\", \"rows\")} ${problemCells === 1 ? \"needs\" : \"need\"} fixing.`,\n  noRows: \"There are no rows to import.\",\n  emptyCell: \"Empty\",\n  editCell: ({ row, column, value }) => `Edit ${column} in row ${row}: ${value}`,\n  cellInput: ({ row, column }) => `${column}, row ${row}`,\n  removeRow: (row) => `Remove row ${row}`,\n  removeProblemRows: (rows) => `Remove ${plural(rows, \"row\", \"rows\")} with problems`,\n  nextProblem: \"Next problem\",\n  showMoreRows: (rows) => `Show ${plural(rows, \"more row\", \"more rows\")}`,\n  discard: \"Discard paste\",\n  import: (rows) => `Import ${plural(rows, \"row\", \"rows\")}`,\n  importing: \"Importing…\",\n  importBlocked: \"Fix or remove the rows with problems before importing.\",\n  importFailed: (reason) =>\n    `The rows were not imported. They are still here, so you can try again.${reason ? ` Reason: ${reason}` : \"\"}`,\n  required: \"Required\",\n  invalidNumber: (decimalSeparator) =>\n    `Enter a number, such as ${decimalSeparator === \".\" ? \"1,234.5\" : \"1.234,5\"}`,\n  invalidDate: (dateOrder) => `Enter a date as ${dateExamples[dateOrder]}`,\n}\n"}],"dependencies":[",\n  mapColumn: (columnName) => `Field for ${columnName}`,\n  missingColumn: (fieldLabel) =>\n    `No column is mapped to ${fieldLabel}. Choose ${fieldLabel} for one of the columns.`,\n  summary: ({ rows, problemRows, problemCells }) =>\n    problemRows === 0\n      ? `${plural(rows, ","honestui"],"registryDependencies":["https://www.honestui.com/r/button.json","https://www.honestui.com/r/field.json","https://www.honestui.com/r/input.json","https://www.honestui.com/r/select.json","https://www.honestui.com/r/switch.json","https://www.honestui.com/r/table.json","https://www.honestui.com/r/textarea.json"]}