feat(plan): GanttBar component
Single horizontal bar — same component renders both card bars (full colour) and subtask bars (same colour, optionally strikethrough when done). Uses the same TimelineRange as the header so the bar and the month ticks above it can never disagree. Keyboard-focusable with a brand-coloured focus ring.
This commit is contained in:
parent
92c2501726
commit
644843ce0c
1 changed files with 75 additions and 0 deletions
75
src/components/roadmap/gantt-bar.tsx
Normal file
75
src/components/roadmap/gantt-bar.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { datePosition } from "@/lib/roadmap/timeline";
|
||||
import type { TimelineRange } from "@/lib/roadmap/timeline";
|
||||
|
||||
interface GanttBarProps {
|
||||
href: string;
|
||||
startDate: string;
|
||||
targetDate: string;
|
||||
range: TimelineRange;
|
||||
/** Hex/rgb colour from the client. */
|
||||
color: string;
|
||||
/** Indent from the left of the track — subtasks use a larger pad. */
|
||||
insetLeft: number;
|
||||
/** Bar height in px. Cards taller, subtasks shorter. */
|
||||
height: number;
|
||||
/** Tooltip text shown on hover (native title attribute). */
|
||||
title: string;
|
||||
/** Optional strikethrough for subtasks whose `done` is true. */
|
||||
strike?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single horizontal bar in the Gantt track. Width is derived from
|
||||
* the date range against the same `TimelineRange` the header uses, so
|
||||
* a card and the month ticks above it can never disagree. Falls back to
|
||||
* a small minimum width so a card with `start_date == target_date`
|
||||
* still gets a clickable target.
|
||||
*/
|
||||
export function GanttBar({
|
||||
href,
|
||||
startDate,
|
||||
targetDate,
|
||||
range,
|
||||
color,
|
||||
insetLeft,
|
||||
height,
|
||||
title,
|
||||
strike = false,
|
||||
}: GanttBarProps) {
|
||||
const MIN_WIDTH = 0.012;
|
||||
|
||||
const { left, width } = useMemo(() => {
|
||||
const anchor = startDate || targetDate;
|
||||
const startFraction = datePosition(anchor, range);
|
||||
const endFraction = Math.max(
|
||||
startFraction + MIN_WIDTH,
|
||||
datePosition(targetDate || anchor, range)
|
||||
);
|
||||
return { left: startFraction, width: endFraction - startFraction };
|
||||
}, [startDate, targetDate, range]);
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title={title}
|
||||
className={`absolute flex items-center overflow-hidden rounded-full focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--brand)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--surface)] ${
|
||||
strike ? "line-through" : ""
|
||||
}`}
|
||||
style={{
|
||||
left: `calc(${(left * 100).toFixed(3)}% + ${insetLeft}px)`,
|
||||
width: `calc(${(width * 100).toFixed(3)}% - ${insetLeft}px)`,
|
||||
top: 0,
|
||||
height,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
>
|
||||
<span className="sr-only">{title}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue