Maps CSV source columns to target schema fields; each link draws an animated SVG connector line via stroke-dashoffset and retracts when cleared.
npx shadcn@latest add @paragon/import-column-mapperAlso installs: dropdown-menu
"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>
);
}