Data Display

Import Column Mapper

Maps CSV source columns to target schema fields; each link draws an animated SVG connector line via stroke-dashoffset and retracts when cleared.

Install

npx shadcn@latest add @paragon/import-column-mapper

Also installs: dropdown-menu

import-column-mapper.tsx

"use client";

import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Check, ChevronDown } from "lucide-react";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { cn } from "@/lib/utils";

export interface TargetField {
  id: string;
  label: string;
  /** Marks the field required in the header. */
  required?: boolean;
}

export interface SourceColumn {
  id: string;
  /** CSV header name. */
  name: string;
  /** Sample cell value shown under the header. */
  sample?: string;
}

export interface ImportColumnMapperProps
  extends Omit<React.ComponentProps<"div">, "onChange"> {
  sources?: SourceColumn[];
  targets?: TargetField[];
  /** Initial source→target mapping, keyed by source id. */
  defaultMapping?: Record<string, string>;
  onChange?: (mapping: Record<string, string>) => void;
}

const DEFAULT_SOURCES: SourceColumn[] = [
  { id: "s1", name: "Email Address", sample: "ada@acme.io" },
  { id: "s2", name: "Full Name", sample: "Ada Lovelace" },
  { id: "s3", name: "Company", sample: "Acme Inc." },
  { id: "s4", name: "Signup Date", sample: "2026-03-14" },
];

const DEFAULT_TARGETS: TargetField[] = [
  { id: "email", label: "email", required: true },
  { id: "name", label: "name", required: true },
  { id: "organization", label: "organization" },
  { id: "created_at", label: "created_at" },
];

const DEFAULT_MAPPING: Record<string, string> = {
  s1: "email",
  s2: "name",
  s3: "organization",
};

/**
 * A CSV column mapper. Source headers on the left connect to target schema
 * fields on the right; each mapping draws an SVG connector whose path animates
 * in via stroke-dashoffset when the link is made (and retracts when cleared).
 * Because it's a transition — not a keyframe — remapping mid-draw retargets
 * cleanly. Endpoints are measured from the DOM after layout, so the curves
 * stay correct across widths. Reduced motion shows the finished line with no
 * draw.
 */
export function ImportColumnMapper({
  sources = DEFAULT_SOURCES,
  targets = DEFAULT_TARGETS,
  defaultMapping = DEFAULT_MAPPING,
  onChange,
  className,
  ...props
}: ImportColumnMapperProps) {
  const reduced = useReducedMotion();
  const [mapping, setMapping] =
    React.useState<Record<string, string>>(defaultMapping);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const sourceRefs = React.useRef<Record<string, HTMLElement | null>>({});
  const targetRefs = React.useRef<Record<string, HTMLElement | null>>({});
  const [paths, setPaths] = React.useState<
    Array<{ id: string; d: string }>
  >([]);
  const [box, setBox] = React.useState({ w: 0, h: 0 });
  // Ids whose draw transition has been armed (flipped to offset 0 post-mount).
  const [drawn, setDrawn] = React.useState<Set<string>>(() => new Set());

  const measure = React.useCallback(() => {
    const root = rootRef.current;
    if (!root) return;
    const rb = root.getBoundingClientRect();
    setBox({ w: rb.width, h: rb.height });
    const next: Array<{ id: string; d: string }> = [];
    for (const [sid, tid] of Object.entries(mapping)) {
      const s = sourceRefs.current[sid];
      const t = targetRefs.current[tid];
      if (!s || !t) continue;
      const sb = s.getBoundingClientRect();
      const tb = t.getBoundingClientRect();
      const x1 = sb.right - rb.left;
      const y1 = sb.top + sb.height / 2 - rb.top;
      const x2 = tb.left - rb.left;
      const y2 = tb.top + tb.height / 2 - rb.top;
      const mid = (x1 + x2) / 2;
      next.push({ id: `${sid}-${tid}`, d: `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}` });
    }
    setPaths(next);
  }, [mapping]);

  React.useLayoutEffect(() => {
    measure();
  }, [measure]);

  // Arm the draw on the next frame after a path first appears, so the
  // stroke-dashoffset transition (1 → 0) actually runs instead of snapping.
  React.useEffect(() => {
    const ids = paths.map((p) => p.id);
    const fresh = ids.filter((id) => !drawn.has(id));
    if (fresh.length === 0) {
      // Drop ids that no longer have a path so re-linking re-draws.
      setDrawn((prev) => {
        const kept = new Set([...prev].filter((id) => ids.includes(id)));
        return kept.size === prev.size ? prev : kept;
      });
      return;
    }
    const raf = requestAnimationFrame(() =>
      setDrawn((prev) => new Set([...prev, ...ids])),
    );
    return () => cancelAnimationFrame(raf);
  }, [paths, drawn]);

  React.useEffect(() => {
    if (typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => measure());
    if (rootRef.current) ro.observe(rootRef.current);
    window.addEventListener("resize", measure);
    return () => {
      ro.disconnect();
      window.removeEventListener("resize", measure);
    };
  }, [measure]);

  const setTarget = (sid: string, tid: string | null) => {
    setMapping((prev) => {
      const next = { ...prev };
      if (tid === null) {
        delete next[sid];
      } else {
        // A target maps to at most one source — steal it from any prior owner.
        for (const key of Object.keys(next)) {
          if (next[key] === tid) delete next[key];
        }
        next[sid] = tid;
      }
      onChange?.(next);
      return next;
    });
  };

  const usedTargets = new Set(Object.values(mapping));

  return (
    <div
      ref={rootRef}
      data-slot="import-column-mapper"
      className={cn(
        "relative grid grid-cols-[1fr_auto_1fr] gap-x-10 rounded-xl bg-card p-5 text-card-foreground shadow-border",
        className,
      )}
      {...props}
    >
      <style href="paragon-import-column-mapper" precedence="paragon">{`
        [data-mapper-path] {
          stroke-dasharray: 1;
          stroke-dashoffset: 1;
          transition: stroke-dashoffset var(--duration-base) var(--ease-out);
        }
        [data-mapper-path][data-drawn="true"] { stroke-dashoffset: 0; }
        @media (prefers-reduced-motion: reduce) {
          [data-mapper-path] { transition: none; stroke-dashoffset: 0; }
        }
      `}</style>

      {/* Connector layer. */}
      <svg
        aria-hidden
        className="pointer-events-none absolute inset-0"
        width={box.w}
        height={box.h}
        viewBox={`0 0 ${box.w || 1} ${box.h || 1}`}
        fill="none"
      >
        {paths.map((p) => (
          <path
            key={p.id}
            data-mapper-path
            data-drawn={reduced || drawn.has(p.id)}
            d={p.d}
            pathLength={1}
            stroke="var(--color-primary)"
            strokeWidth={1.5}
            strokeLinecap="round"
          />
        ))}
      </svg>

      {/* Source columns. */}
      <div className="space-y-2">
        <p className="mb-1 text-xs font-medium text-muted-foreground">
          CSV columns
        </p>
        {sources.map((src) => (
          <div
            key={src.id}
            ref={(el) => {
              sourceRefs.current[src.id] = el;
            }}
            className={cn(
              "relative rounded-lg border bg-background px-3 py-2 transition-colors duration-(--duration-fast)",
              mapping[src.id] ? "border-primary/40" : "border-border",
            )}
          >
            <p className="truncate text-[13px] font-medium">{src.name}</p>
            {src.sample && (
              <p className="truncate font-mono text-[11px] text-muted-foreground">
                {src.sample}
              </p>
            )}
          </div>
        ))}
      </div>

      {/* Spacer column keeps the connector gutter. */}
      <div aria-hidden className="w-0" />

      {/* Target fields. */}
      <div className="space-y-2">
        <p className="mb-1 text-xs font-medium text-muted-foreground">
          Maps to field
        </p>
        {sources.map((src) => {
          const current = targets.find((t) => t.id === mapping[src.id]);
          return (
            <div
              key={src.id}
              ref={(el) => {
                if (current) targetRefs.current[current.id] = el;
              }}
            >
              <DropdownMenu>
                <DropdownMenuTrigger
                  className={cn(
                    "flex w-full items-center justify-between gap-2 rounded-lg border px-3 py-2 text-left text-[13px] outline-none",
                    "transition-colors duration-(--duration-fast) hover:bg-accent/50",
                    "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
                    current
                      ? "border-primary/40 font-mono"
                      : "border-dashed text-muted-foreground",
                  )}
                >
                  <span className="truncate">
                    {current ? current.label : "Select field…"}
                  </span>
                  <ChevronDown className="size-4 shrink-0 text-muted-foreground" />
                </DropdownMenuTrigger>
                <DropdownMenuContent
                  align="end"
                  className="w-(--radix-dropdown-menu-trigger-width)"
                >
                  <DropdownMenuLabel>Schema fields</DropdownMenuLabel>
                  <DropdownMenuSeparator />
                  {targets.map((t) => {
                    const taken = usedTargets.has(t.id) && mapping[src.id] !== t.id;
                    return (
                      <DropdownMenuItem
                        key={t.id}
                        disabled={taken}
                        onSelect={() => setTarget(src.id, t.id)}
                        className="justify-between gap-2 font-mono"
                      >
                        <span>
                          {t.label}
                          {t.required && (
                            <span className="text-destructive"> *</span>
                          )}
                        </span>
                        {mapping[src.id] === t.id && (
                          <Check className="size-4 !text-foreground" />
                        )}
                      </DropdownMenuItem>
                    );
                  })}
                  {mapping[src.id] && (
                    <>
                      <DropdownMenuSeparator />
                      <DropdownMenuItem
                        variant="destructive"
                        onSelect={() => setTarget(src.id, null)}
                      >
                        Clear mapping
                      </DropdownMenuItem>
                    </>
                  )}
                </DropdownMenuContent>
              </DropdownMenu>
            </div>
          );
        })}
      </div>
    </div>
  );
}