"use client";
import * as React from "react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const noopSubscribe = () => () => {};
const localTodayISO = () => {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
};
export interface GanttTask {
id: string;
name: string;
/** First day, "YYYY-MM-DD". */
start: string;
/** Last day (inclusive), "YYYY-MM-DD". Milestones use the same day as `start`. */
end: string;
/** 0–100. */
progress?: number;
/** Render as a diamond on `start`. */
milestone?: boolean;
/** Ids of tasks that must finish before this one starts (finish-to-start). */
dependencies?: string[];
}
export type GanttZoom = "day" | "week" | "month";
export interface GanttChartProps extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "children"> {
tasks: GanttTask[];
/** Called with the updated list after a drag or keyboard edit. Without it the chart is read-only. */
onChange?: (tasks: GanttTask[], changed: GanttTask) => void;
/** Controlled zoom level. */
zoom?: GanttZoom;
defaultZoom?: GanttZoom;
onZoomChange?: (zoom: GanttZoom) => void;
/** "YYYY-MM-DD" for the today marker. When omitted it is computed in the browser after hydration. */
today?: string;
showToday?: boolean;
/** Draw finish-to-start dependency arrows. */
showDependencies?: boolean;
/** Fill bars by `progress`. */
showProgress?: boolean;
/** Shade Saturdays and Sundays (day and week zoom). */
showWeekends?: boolean;
/** Row height in px. */
rowHeight?: number;
/** Width of the sticky task-name column in px. */
labelWidth?: number;
/** Disable dragging and keyboard edits (navigation still works). */
readOnly?: boolean;
/** Bars, progress, today marker and focus rings. */
accent?: string;
/** Accessible name of the chart. */
label?: string;
}
const DAY_MS = 86_400_000;
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const PX_PER_DAY: Record<GanttZoom, number> = { day: 36, week: 12, month: 4.5 };
const HEADER_BAND = 24;
/** Dates are handled as whole UTC day numbers so time zones never shift a bar. */
const toDay = (iso: string) => {
const [y, m, d] = iso.split("-").map(Number);
return Math.round(Date.UTC(y, m - 1, d) / DAY_MS);
};
const toISO = (day: number) => new Date(day * DAY_MS).toISOString().slice(0, 10);
const dateOf = (day: number) => new Date(day * DAY_MS);
const fmt = (day: number) => `${dateOf(day).getUTCDate()} ${MONTHS[dateOf(day).getUTCMonth()]}`;
const r2 = (n: number) => Math.round(n * 100) / 100;
const firstOfMonth = (day: number) => {
const d = dateOf(day);
return Math.round(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1) / DAY_MS);
};
const nextMonth = (day: number) => {
const d = dateOf(day);
return Math.round(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1) / DAY_MS);
};
type DragMode = "move" | "start" | "end";
/** True when `task` starts before its predecessor `pred` has finished. */
const startsTooEarly = (task: GanttTask, pred: GanttTask) =>
pred.milestone ? toDay(task.start) < toDay(pred.start) : toDay(task.start) <= toDay(pred.end);
interface Draft {
id: string;
mode: DragMode;
delta: number;
}
function shift(task: GanttTask, mode: DragMode, delta: number): GanttTask {
const s = toDay(task.start);
const e = toDay(task.end);
if (mode === "move" || task.milestone) return { ...task, start: toISO(s + delta), end: toISO(e + delta) };
if (mode === "start") return { ...task, start: toISO(Math.min(e, s + delta)) };
return { ...task, end: toISO(Math.max(s, e + delta)) };
}
/**
* A project timeline: bars positioned by start / end dates, a today marker, dependency arrows,
* progress fills and milestones. Bars drag to move and their edges drag to resize, snapping to
* whole days; with the keyboard, arrows move a focused bar by a day and Shift + arrows resize it.
*/
export function GanttChart({
tasks,
onChange,
zoom: zoomProp,
defaultZoom = "week",
onZoomChange,
today: todayProp,
showToday = true,
showDependencies = true,
showProgress = true,
showWeekends = true,
rowHeight = 40,
labelWidth = 168,
readOnly: readOnlyProp = false,
accent = "#FF7448",
label = "Project timeline",
className = "",
style,
...props
}: GanttChartProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const readOnly = readOnlyProp || !onChange;
const browserToday = React.useSyncExternalStore(noopSubscribe, localTodayISO, () => null);
const todayISO = todayProp ?? browserToday;
const [innerZoom, setInnerZoom] = React.useState<GanttZoom>(defaultZoom);
const zoom = zoomProp ?? innerZoom;
const ppd = PX_PER_DAY[zoom];
const [draft, setDraft] = React.useState<Draft | null>(null);
const [focusIndex, setFocusIndex] = React.useState(0);
const [announcement, setAnnouncement] = React.useState("");
const scrollerRef = React.useRef<HTMLDivElement>(null);
const barRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const dragRef = React.useRef<{ id: string; mode: DragMode; startX: number; delta: number; moved: boolean } | null>(null);
const pendingCenter = React.useRef<number | null>(null);
// Tasks as drawn: the one being dragged shows its snapped draft position.
const shown = tasks.map((t) => (draft && t.id === draft.id ? shift(t, draft.mode, draft.delta) : t));
const index = new Map(shown.map((t, i) => [t.id, i]));
/* ------------------------------- range -------------------------------- */
const starts = shown.map((t) => toDay(t.start));
const ends = shown.map((t) => toDay(t.end));
const todayDay = todayISO ? toDay(todayISO) : null;
const minDay = Math.min(...starts, ...(todayDay !== null ? [todayDay] : []));
const maxDay = Math.max(...ends, ...(todayDay !== null ? [todayDay] : []));
let rangeStart: number;
let rangeEnd: number; // exclusive
if (zoom === "month") {
rangeStart = minDay - 4; // a few days of air so the first bar or milestone isn't clipped
rangeEnd = nextMonth(maxDay + 6);
} else if (zoom === "week") {
const back = (dateOf(minDay - 3).getUTCDay() + 6) % 7; // days since Monday
rangeStart = minDay - 3 - back;
const end = maxDay + 2;
rangeEnd = end + (7 - ((dateOf(end).getUTCDay() + 6) % 7));
} else {
rangeStart = minDay - 2;
rangeEnd = maxDay + 6;
}
const dayCount = rangeEnd - rangeStart;
const timelineWidth = r2(dayCount * ppd);
const x = (day: number) => r2((day - rangeStart) * ppd);
/* ------------------------------- scale -------------------------------- */
const top: { key: string; x: number; w: number; text: string }[] = [];
const bottom: { key: string; x: number; w: number; text: string; weekend?: boolean }[] = [];
if (zoom === "month") {
for (let d = rangeStart; d < rangeEnd; d = nextMonth(d)) {
const end = Math.min(nextMonth(d), rangeEnd);
const w = r2((end - d) * ppd);
bottom.push({ key: `m${d}`, x: x(d), w, text: w >= 28 ? MONTHS[dateOf(d).getUTCMonth()] : "" });
}
let y = rangeStart;
while (y < rangeEnd) {
const year = dateOf(y).getUTCFullYear();
const next = Math.min(rangeEnd, Math.round(Date.UTC(year + 1, 0, 1) / DAY_MS));
top.push({ key: `y${y}`, x: x(y), w: r2((next - y) * ppd), text: String(year) });
y = next;
}
} else {
for (let d = rangeStart; d < rangeEnd; d = nextMonth(d)) {
const from = d === rangeStart ? d : firstOfMonth(d);
const end = Math.min(nextMonth(from), rangeEnd);
const dt = dateOf(from);
top.push({ key: `m${from}`, x: x(from), w: r2((end - from) * ppd), text: `${MONTHS[dt.getUTCMonth()]} ${dt.getUTCFullYear()}` });
}
if (zoom === "day") {
for (let d = rangeStart; d < rangeEnd; d++) {
const wd = dateOf(d).getUTCDay();
bottom.push({ key: `d${d}`, x: x(d), w: ppd, text: String(dateOf(d).getUTCDate()), weekend: wd === 0 || wd === 6 });
}
} else {
for (let d = rangeStart; d < rangeEnd; d += 7) {
bottom.push({ key: `w${d}`, x: x(d), w: r2(7 * ppd), text: fmt(d) });
}
}
}
const weekendCols: number[] = [];
if (showWeekends && zoom !== "month") {
for (let d = rangeStart; d < rangeEnd; d++) {
const wd = dateOf(d).getUTCDay();
if (wd === 0 || wd === 6) weekendCols.push(d);
}
}
/* ------------------------------- zoom --------------------------------- */
const setZoom = (z: GanttZoom) => {
if (z === zoom) return;
const el = scrollerRef.current;
// Remember the date in the middle of the view so the new zoom can keep it centred.
if (el) pendingCenter.current = rangeStart + (el.scrollLeft + (el.clientWidth - labelWidth) / 2) / ppd;
if (zoomProp === undefined) setInnerZoom(z);
onZoomChange?.(z);
};
React.useLayoutEffect(() => {
const el = scrollerRef.current;
if (!el || pendingCenter.current === null) return;
el.scrollLeft = Math.max(0, (pendingCenter.current - rangeStart) * ppd - (el.clientWidth - labelWidth) / 2);
pendingCenter.current = null;
}, [zoom, ppd, rangeStart, labelWidth]);
const scrollToToday = () => {
const el = scrollerRef.current;
if (!el || todayDay === null) return;
el.scrollTo({ left: Math.max(0, x(todayDay) - (el.clientWidth - labelWidth) / 2), behavior: reduceMotion ? "auto" : "smooth" });
};
/* ------------------------------- edits -------------------------------- */
const describe = (t: GanttTask) =>
t.milestone
? `${t.name}, milestone, ${fmt(toDay(t.start))}`
: `${t.name}, ${fmt(toDay(t.start))} to ${fmt(toDay(t.end))}${showProgress ? `, ${Math.round(t.progress ?? 0)}% complete` : ""}`;
const commit = (id: string, mode: DragMode, delta: number) => {
if (!delta || !onChange) return;
const original = tasks.find((t) => t.id === id);
if (!original) return;
const changed = shift(original, mode, delta);
onChange(
tasks.map((t) => (t.id === id ? changed : t)),
changed,
);
setAnnouncement(
changed.milestone
? `${changed.name} moved to ${fmt(toDay(changed.start))}.`
: `${changed.name}: ${fmt(toDay(changed.start))} to ${fmt(toDay(changed.end))}.`,
);
};
const onBarPointerDown = (task: GanttTask, mode: DragMode, event: React.PointerEvent<HTMLElement>) => {
if (readOnly || event.button !== 0) return;
event.stopPropagation();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = { id: task.id, mode, startX: event.clientX, delta: 0, moved: false };
};
// Pointer moves only touch state when the snapped day offset changes.
const onBarPointerMove = (event: React.PointerEvent<HTMLElement>) => {
const d = dragRef.current;
if (!d) return;
event.stopPropagation(); // edge handles sit inside the bar; handle each move once
const dx = event.clientX - d.startX;
if (!d.moved && Math.abs(dx) < 3) return;
d.moved = true;
const delta = Math.round(dx / ppd);
if (delta !== d.delta) {
d.delta = delta;
setDraft({ id: d.id, mode: d.mode, delta });
} else if (!draft) setDraft({ id: d.id, mode: d.mode, delta });
};
const onBarPointerUp = (event: React.PointerEvent<HTMLElement>) => {
const d = dragRef.current;
if (!d) return;
dragRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
setDraft(null);
if (event.type === "pointerup") commit(d.id, d.mode, d.delta);
};
const focusBar = (i: number) => {
const next = Math.max(0, Math.min(shown.length - 1, i));
setFocusIndex(next);
barRefs.current[next]?.focus();
};
const onBarKeyDown = (i: number, task: GanttTask, event: React.KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "ArrowUp":
event.preventDefault();
return focusBar(i - 1);
case "ArrowDown":
event.preventDefault();
return focusBar(i + 1);
case "Home":
event.preventDefault();
return focusBar(0);
case "End":
event.preventDefault();
return focusBar(shown.length - 1);
case "ArrowLeft":
case "ArrowRight": {
if (readOnly) return;
event.preventDefault();
const delta = event.key === "ArrowLeft" ? -1 : 1;
// Shift resizes the end; Shift + Alt resizes the start; plain arrows move.
const mode: DragMode = event.shiftKey && !task.milestone ? (event.altKey ? "start" : "end") : "move";
// A bar never shrinks below one day.
const oneDay = toDay(task.start) >= toDay(task.end);
if (oneDay && ((mode === "end" && delta < 0) || (mode === "start" && delta > 0))) return;
return commit(task.id, mode, delta);
}
}
};
/* ---------------------------- dependencies ---------------------------- */
const bodyHeight = shown.length * rowHeight;
const barHeight = Math.max(14, Math.round(rowHeight * 0.56));
const arrows: { key: string; d: string; conflict: boolean }[] = [];
if (showDependencies) {
const half = r2(barHeight * 0.66 * Math.SQRT1_2); // half-diagonal of the milestone diamond
shown.forEach((t, row) => {
for (const depId of t.dependencies ?? []) {
const from = index.get(depId);
if (from === undefined) continue;
const p = shown[from];
const pEnd = p.milestone ? x(toDay(p.start)) + ppd / 2 + half : x(toDay(p.end) + 1);
const tStart = t.milestone ? x(toDay(t.start)) + ppd / 2 - half : x(toDay(t.start));
const y1 = from * rowHeight + rowHeight / 2;
const y2 = row * rowHeight + rowHeight / 2;
const conflict = startsTooEarly(t, p);
const gap = 8;
let d: string;
if (tStart - pEnd >= gap * 2) {
const mx = r2(pEnd + gap);
d = `M${r2(pEnd)} ${y1} H${mx} V${y2} H${r2(tStart - 1)}`;
} else {
// Not enough room: step out, drop to the row boundary, come back left, then in.
const yMid = r2(y2 > y1 ? row * rowHeight : (row + 1) * rowHeight);
d = `M${r2(pEnd)} ${y1} H${r2(pEnd + gap)} V${yMid} H${r2(tStart - gap)} V${y2} H${r2(tStart - 1)}`;
}
arrows.push({ key: `${depId}-${t.id}`, d, conflict });
}
});
}
/* ------------------------------- render ------------------------------- */
const tabStop = Math.min(focusIndex, shown.length - 1);
const focusRing =
"outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-[var(--gc-accent)]";
return (
<div
role="group"
aria-label={label}
className={`flex w-full min-w-0 select-none flex-col overflow-hidden rounded-xl border border-zinc-900/10 bg-white dark:border-white/10 dark:bg-zinc-950 ${className}`}
style={{ ["--gc-accent" as string]: accent, ...style }}
{...props}
>
<div className="flex min-h-11 flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-zinc-900/10 px-3 py-1.5 dark:border-white/10">
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{tasks.length} tasks · {fmt(Math.min(...tasks.map((t) => toDay(t.start))))} – {fmt(Math.max(...tasks.map((t) => toDay(t.end))))}
</span>
<div className="flex items-center gap-2">
{showToday && todayDay !== null && (
<button
type="button"
onClick={scrollToToday}
className={`h-7 rounded-md px-2 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-600 transition-colors hover:text-zinc-950 dark:text-zinc-300 dark:hover:text-white ${focusRing}`}
>
Today
</button>
)}
<div role="group" aria-label="Zoom" className="flex rounded-lg border border-zinc-900/10 p-0.5 dark:border-white/10">
{(["day", "week", "month"] as const).map((z) => (
<button
key={z}
type="button"
aria-pressed={zoom === z}
onClick={() => setZoom(z)}
className={`rounded-md px-2 py-1 font-mono text-[10px] uppercase tracking-[0.12em] transition-colors ${focusRing} ${
zoom === z ? "bg-zinc-900 text-white dark:bg-white dark:text-zinc-950" : "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
}`}
>
{z}
</button>
))}
</div>
</div>
</div>
<div ref={scrollerRef} className="relative min-w-0 overflow-x-auto overscroll-x-contain">
<div className="relative" style={{ width: labelWidth + timelineWidth, minWidth: "100%" }}>
{/* Header scale (decorative: every bar carries its dates in its accessible name). */}
<div aria-hidden="true" className="sticky top-0 z-20 flex border-b border-zinc-900/10 dark:border-white/10" style={{ height: HEADER_BAND * 2 }}>
<div
className="sticky left-0 z-30 flex shrink-0 items-end border-r border-zinc-900/10 bg-white px-3 pb-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:border-white/10 dark:bg-zinc-950 dark:text-zinc-500"
style={{ width: labelWidth }}
>
Task
</div>
<div className="relative flex-1 bg-zinc-50 dark:bg-zinc-900/60" style={{ minWidth: timelineWidth }}>
{top.map((u) => (
<div
key={u.key}
className="absolute top-0 truncate border-l border-zinc-900/10 px-2 font-mono text-[10px] uppercase leading-6 tracking-[0.12em] text-zinc-600 dark:border-white/10 dark:text-zinc-300"
style={{ left: u.x, width: u.w, height: HEADER_BAND }}
>
{/* Partial months at the edges are too narrow for a label. */}
{u.w >= 56 ? u.text : ""}
</div>
))}
{bottom.map((u) => (
<div
key={u.key}
className={`absolute truncate border-l border-t border-zinc-900/[0.07] font-mono text-[10px] leading-6 tabular-nums dark:border-white/[0.07] ${
zoom === "day" ? "text-center" : "px-1.5"
} ${u.weekend ? "text-zinc-400 dark:text-zinc-600" : "text-zinc-500 dark:text-zinc-400"}`}
style={{ left: u.x, top: HEADER_BAND, width: u.w, height: HEADER_BAND }}
>
{u.text}
</div>
))}
{showToday && todayDay !== null && todayDay >= rangeStart && todayDay < rangeEnd && (
<span
className="absolute top-1 z-10 -translate-x-1/2 rounded-sm bg-[var(--gc-accent)] px-1 font-mono text-[9px] uppercase leading-4 tracking-[0.1em] text-zinc-950"
style={{ left: r2(x(todayDay) + ppd / 2) }}
>
Today
</span>
)}
</div>
</div>
{/* Body */}
<div className="relative" style={{ height: bodyHeight }}>
{/* Grid, weekends and today line. */}
<div aria-hidden="true" className="pointer-events-none absolute inset-y-0" style={{ left: labelWidth, width: timelineWidth }}>
{weekendCols.map((d) => (
<div key={d} className="absolute inset-y-0 bg-zinc-900/[0.025] dark:bg-white/[0.025]" style={{ left: x(d), width: ppd }} />
))}
{bottom.map((u) => (
<div key={u.key} className="absolute inset-y-0 border-l border-zinc-900/[0.06] dark:border-white/[0.06]" style={{ left: u.x }} />
))}
{showToday && todayDay !== null && todayDay >= rangeStart && todayDay < rangeEnd && (
<div className="absolute inset-y-0 z-[6] w-px bg-[var(--gc-accent)]" style={{ left: r2(x(todayDay) + ppd / 2) }} />
)}
</div>
{/* Dependency arrows sit above the grid and below the bars. */}
{arrows.length > 0 && (
<svg
aria-hidden="true"
className="pointer-events-none absolute top-0 z-[5] overflow-visible"
style={{ left: labelWidth }}
width={timelineWidth}
height={bodyHeight}
>
<defs>
<marker id={`${uid}-arrow`} viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0 0.5 7.5 4 0 7.5z" className="fill-zinc-400 dark:fill-zinc-500" />
</marker>
<marker id={`${uid}-arrow-bad`} viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto">
<path d="M0 0.5 7.5 4 0 7.5z" className="fill-rose-500" />
</marker>
</defs>
{arrows.map((a) => (
<path
key={a.key}
d={a.d}
fill="none"
strokeWidth="1.25"
strokeLinejoin="round"
strokeDasharray={a.conflict ? "3 3" : undefined}
markerEnd={`url(#${a.conflict ? `${uid}-arrow-bad` : `${uid}-arrow`})`}
className={a.conflict ? "stroke-rose-500" : "stroke-zinc-400 dark:stroke-zinc-500"}
/>
))}
</svg>
)}
{shown.map((task, i) => {
const s = toDay(task.start);
const e = toDay(task.end);
const progress = Math.max(0, Math.min(100, task.progress ?? 0));
const left = x(s);
const width = r2((e - s + 1) * ppd);
const dragging = draft?.id === task.id;
const deps = (task.dependencies ?? []).map((id) => shown[index.get(id) ?? -1]?.name).filter(Boolean);
const conflict = (task.dependencies ?? []).some((id) => {
const p = shown[index.get(id) ?? -1];
return Boolean(p) && startsTooEarly(task, p);
});
const name = `${describe(task)}${deps.length ? `, after ${deps.join(" and ")}` : ""}${conflict ? ", starts before a dependency ends" : ""}`;
return (
<div key={task.id} className="group/row absolute inset-x-0 flex" style={{ top: i * rowHeight, height: rowHeight }}>
<div
aria-hidden="true"
className="sticky left-0 z-20 flex shrink-0 flex-col justify-center border-b border-r border-zinc-900/[0.07] bg-white px-3 group-hover/row:bg-zinc-50 dark:border-white/[0.07] dark:bg-zinc-950 dark:group-hover/row:bg-zinc-900"
style={{ width: labelWidth }}
>
<span className="truncate text-[13px] font-medium tracking-tight text-zinc-900 dark:text-zinc-100">{task.name}</span>
{rowHeight >= 40 && (
<span className="truncate font-mono text-[10px] tabular-nums text-zinc-400 dark:text-zinc-500">
{task.milestone ? fmt(s) : `${fmt(s)} – ${fmt(e)}`}
</span>
)}
</div>
<div className="relative flex-1 border-b border-zinc-900/[0.07] group-hover/row:bg-zinc-900/[0.02] dark:border-white/[0.07] dark:group-hover/row:bg-white/[0.02]" style={{ minWidth: timelineWidth }}>
<div
ref={(el) => {
barRefs.current[i] = el;
}}
role="button"
aria-roledescription={task.milestone ? "milestone" : "task bar"}
aria-label={name}
aria-describedby={`${uid}-help`}
aria-disabled={readOnly || undefined}
tabIndex={i === tabStop ? 0 : -1}
onFocus={() => setFocusIndex(i)}
onKeyDown={(ev) => onBarKeyDown(i, task, ev)}
onPointerDown={(ev) => onBarPointerDown(task, "move", ev)}
onPointerMove={onBarPointerMove}
onPointerUp={onBarPointerUp}
onPointerCancel={onBarPointerUp}
data-dragging={dragging || undefined}
className={`absolute top-1/2 z-10 -translate-y-1/2 ${readOnly ? "" : "cursor-grab touch-pan-y active:cursor-grabbing"} ${focusRing} ${
task.milestone ? "grid place-items-center" : "overflow-hidden rounded-md"
} ${dragging ? "shadow-[0_6px_16px_-6px_rgba(0,0,0,0.35)]" : ""}`}
style={
task.milestone
? { left: r2(left + ppd / 2 - barHeight / 2), width: barHeight, height: barHeight }
: { left, width, height: barHeight }
}
>
{task.milestone ? (
<span
aria-hidden="true"
className="block rotate-45 rounded-[2px] border border-zinc-950/20 bg-[var(--gc-accent)] dark:border-white/30"
style={{ width: r2(barHeight * 0.66), height: r2(barHeight * 0.66) }}
/>
) : (
<>
<span
aria-hidden="true"
className={`absolute inset-0 rounded-[inherit] border ${
conflict
? "border-rose-500/70 bg-rose-500/10"
: "border-[color-mix(in_srgb,var(--gc-accent)_55%,transparent)] bg-[color-mix(in_srgb,var(--gc-accent)_16%,transparent)]"
}`}
/>
{showProgress && progress > 0 && (
<span
aria-hidden="true"
className="absolute inset-y-0 left-0 bg-[color-mix(in_srgb,var(--gc-accent)_80%,transparent)]"
style={{ width: `${progress}%` }}
/>
)}
{width >= 56 && (
<span
aria-hidden="true"
className="relative flex h-full items-center gap-1.5 truncate px-2 text-[11px] font-medium leading-none text-zinc-950 dark:text-white"
>
<span className="truncate">{task.name}</span>
{showProgress && width >= 110 && <span className="font-mono text-[9px] opacity-60">{Math.round(progress)}%</span>}
</span>
)}
{!readOnly && (
<>
<span
aria-hidden="true"
onPointerDown={(ev) => onBarPointerDown(task, "start", ev)}
onPointerMove={onBarPointerMove}
onPointerUp={onBarPointerUp}
onPointerCancel={onBarPointerUp}
className="absolute inset-y-0 left-0 w-2 cursor-ew-resize after:absolute after:inset-y-1 after:left-0.5 after:w-[2px] after:rounded-full after:bg-zinc-950/30 after:opacity-0 after:transition-opacity hover:after:opacity-100 dark:after:bg-white/50"
/>
<span
aria-hidden="true"
onPointerDown={(ev) => onBarPointerDown(task, "end", ev)}
onPointerMove={onBarPointerMove}
onPointerUp={onBarPointerUp}
onPointerCancel={onBarPointerUp}
className="absolute inset-y-0 right-0 w-2 cursor-ew-resize after:absolute after:inset-y-1 after:right-0.5 after:w-[2px] after:rounded-full after:bg-zinc-950/30 after:opacity-0 after:transition-opacity hover:after:opacity-100 dark:after:bg-white/50"
/>
</>
)}
</>
)}
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
<p id={`${uid}-help`} hidden>
{readOnly
? "Up and Down arrows move between tasks."
: "Left and Right arrows move the task by a day. Shift with arrows changes the end date, Shift and Alt with arrows changes the start date. Up and Down arrows move between tasks."}
</p>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement}
</span>
</div>
);
}