
Bidirectional drag handle and date picker event range adjuster.
components/tanstack-charts/line/editable-event-range.tsx
import { type ChartPoint, defineChart, rect, text } from "@tanstack/charts";
import { handleX } from "@tanstack/charts/interaction/handle";
import { controlledSignal } from "@tanstack/charts/interaction/signal";
import { motion } from "@tanstack/charts/motion";
import { tooltip } from "@tanstack/charts/tooltip";
import { Chart } from "@tanstack/react-charts/core";
import { scaleBand, scaleUtc } from "d3-scale";
import { useMemo, useState } from "react";
import { CalendarIcon, CalendarRange } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
interface EditableEvent {
id: string;
lane: string;
label: string;
start: Date;
end: Date;
}
const motionRenderer = motion({
initial: "always",
transition: { type: "spring", stiffness: 170, damping: 18, mass: 1 },
});
const lanes = ["Product", "Design", "Engineering"];
function generateInitialEvents(): EditableEvent[] {
const baseDate = new Date(Date.UTC(2024, 8, 1)).getTime();
const dayMs = 24 * 60 * 60 * 1000;
return [
{
id: "discovery",
lane: "Product",
label: "Product Discovery",
start: new Date(baseDate),
end: new Date(baseDate + 10 * dayMs),
},
{
id: "design",
lane: "Design",
label: "UI/UX Specs",
start: new Date(baseDate + 5 * dayMs),
end: new Date(baseDate + 18 * dayMs),
},
{
id: "release",
lane: "Engineering",
label: "Production Release",
start: new Date(baseDate + 12 * dayMs),
end: new Date(baseDate + 28 * dayMs),
},
];
}
function shadcnTooltipContent<TDatum>(points: readonly ChartPoint<TDatum>[]) {
if (points.length === 0) return { title: "", rows: [] };
const d = points[0].datum as EditableEvent;
if (!d) return { title: "", rows: [] };
const fmt = (dt: Date) => dt.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
return {
title: `${d.lane}: ${d.label}`,
rows: [{ label: "Duration", value: `${fmt(d.start)} - ${fmt(d.end)}`, color: "var(--chart-1)" }],
};
}
export function EditableEventRange() {
const [releaseEnd, setReleaseEnd] = useState<Date>(new Date(Date.UTC(2024, 8, 29)));
const data = useMemo(() => {
const events = generateInitialEvents();
const releaseEvt = events.find((e) => e.id === "release");
if (releaseEvt) releaseEvt.end = releaseEnd;
return events;
}, [releaseEnd]);
const fullDomain = useMemo(() => {
return [new Date(Date.UTC(2024, 8, 1)), new Date(Date.UTC(2024, 9, 15))];
}, []);
const eventEndValues = useMemo(() => {
const result: Date[] = [];
const start = new Date(Date.UTC(2024, 8, 13)).getTime();
const end = new Date(Date.UTC(2024, 9, 15)).getTime();
const dayMs = 24 * 60 * 60 * 1000;
for (let t = start; t <= end; t += dayMs) {
result.push(new Date(t));
}
return result;
}, []);
const activeReleaseEnd = useMemo(() => {
const releaseTime = releaseEnd.getTime();
return eventEndValues.find((d) => d.getTime() === releaseTime) || eventEndValues[eventEndValues.length - 1];
}, [releaseEnd, eventEndValues]);
const calendarSelected = useMemo(() => {
return new Date(releaseEnd.getUTCFullYear(), releaseEnd.getUTCMonth(), releaseEnd.getUTCDate());
}, [releaseEnd]);
const definition = useMemo(
() =>
defineChart(
{
renderer: motionRenderer,
marks: [
rect(data, {
id: "event-ranges",
x1: "start",
x2: "end",
y: "lane",
color: "id",
radius: 4,
stroke: "var(--background)",
strokeWidth: 1.5,
}),
text(data, {
id: "event-labels",
x: "start",
y: "lane",
text: "label",
anchor: "start",
dx: 8,
fill: "var(--foreground)",
fontSize: 11,
fontWeight: 600,
}),
],
scales: {
x: {
scale: scaleUtc().domain(fullDomain),
grid: true,
axis: {
ticks: {
format: (v: Date) =>
v.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" }),
},
},
},
y: {
scale: scaleBand<string>().domain(lanes).paddingInner(0.3),
grid: false,
axis: false,
},
},
color: {
domain: ["discovery", "design", "release"],
range: ["var(--chart-1)", "var(--chart-2)", "var(--chart-3)"],
},
controls: [
handleX({
id: "release-end",
value: controlledSignal<Date, any>(activeReleaseEnd, (next) =>
setReleaseEnd(next instanceof Date ? next : new Date(next)),
),
values: eventEndValues,
cross: { value: "Engineering" as any },
ruleStyle: { fill: "var(--chart-3)" },
handleStyle: {
fill: "var(--chart-3)",
stroke: "var(--background)",
strokeWidth: 2,
},
ariaLabel: "Release end date handle scrubber",
} as any),
],
margin: { top: 24, right: 24, bottom: 32, left: 90 },
theme: {
foreground: "var(--muted-foreground)",
grid: "var(--border)",
background: "transparent",
},
},
{
keyboard: true,
tooltip: {
use: tooltip,
className:
"font-sans shadow-none rounded-md bg-popover text-popover-foreground border p-2 text-xs",
anchor: "pointer",
placement: "auto",
content: (points) => shadcnTooltipContent(points),
},
},
),
[data, fullDomain, releaseEnd],
);
const fmtDate = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
return (
<Card className="w-full max-w-xl">
<CardHeader>
<CardTitle>Direct Editable Milestone Event Range</CardTitle>
<CardDescription>
Bidirectional drag handle and date picker input synchronizing milestone durations
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between gap-3">
<span className="text-muted-foreground font-mono text-xs">
Release End: <strong className="text-foreground">{fmtDate(releaseEnd)}</strong>
</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs font-medium">Edit Date:</span>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
size="sm"
className="h-8 font-mono text-xs font-normal">
<CalendarIcon className="mr-1.5 size-3.5 text-muted-foreground" />
{fmtDate(releaseEnd)}
</Button>
}
/>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={calendarSelected}
onSelect={(date) => {
if (date) {
setReleaseEnd(
new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())),
);
}
}}
defaultMonth={calendarSelected}
disabled={(date) => {
const utcTime = Date.UTC(date.getFullYear(), date.getMonth(), date.getDate());
return (
utcTime < Date.UTC(2024, 8, 13) ||
utcTime > Date.UTC(2024, 9, 15)
);
}}
autoFocus
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="h-[210px] w-full">
<Chart
definition={definition}
renderer={motionRenderer}
height={210}
ariaLabel="Direct Editable Milestone Event Range Chart"
/>
</div>
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex items-center gap-2 leading-none font-medium">
Drag the orange handle on Engineering lane to adjust release date{" "}
<CalendarRange className="h-4 w-4 text-emerald-500" />
</div>
<div className="text-muted-foreground leading-none">
`handleX` provides interactive endpoint manipulation synchronized with form controls
</div>
</CardFooter>
</Card>
);
}
export default EditableEventRange;