docs(docs): fix cursor handling and hover tip refactor in docs UI components

This commit is contained in:
Prad Nukala committed 2026-10-05 11:23:57 -04:00
1 parent 0d066b8104
commit 7ead021ee2
5 files changed
+21 -15

No files matched your search

+5 -1
View File
@@ -193,6 +193,8 @@ export default function DependencySpine() {
};
const [routes, setRoutes] = useState<Record<string, string> | null>(null);
const drag = useRef<{ x: number; y: number; moved: boolean } | null>(null);
// The cursor reads this, not `drag`: a ref read during render never re-renders.
const [panning, setPanning] = useState(false);
useEffect(() => {
let live = true;
@@ -244,6 +246,7 @@ export default function DependencySpine() {
// Capture only once it is a drag: capturing on pointerdown would retarget
// pointerup (and so the click) to the <svg>, and a node's link would never fire.
d.moved = true;
setPanning(true);
e.currentTarget.setPointerCapture(e.pointerId);
}
d.x = e.clientX;
@@ -254,6 +257,7 @@ export default function DependencySpine() {
};
const onPointerUp = (e: PointerEvent<SVGSVGElement>) => {
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
setPanning(false);
// Cleared on the next tick so a node's click handler can still see `moved`.
setTimeout(() => { drag.current = null; }, 0);
};
@@ -293,7 +297,7 @@ export default function DependencySpine() {
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-labelledby="dependency-spine-title dependency-spine-desc"
style={{ display: "block", width: "100%", height: "auto", touchAction: "none", cursor: drag.current?.moved ? "grabbing" : zoom > 1.001 ? "grab" : "default", userSelect: "none" }}
style={{ display: "block", width: "100%", height: "auto", touchAction: "none", cursor: panning ? "grabbing" : zoom > 1.001 ? "grab" : "default", userSelect: "none" }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
+5 -5
View File
@@ -57,14 +57,14 @@ export default function SolvedRing() {
const done = rows.reduce((n, r) => n + r.done, 0);
const span = SWEEP - GAP * (rows.length - 1);
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
let cursor = START;
const segments = rows.map((r) => {
for (const r of rows) {
const size = total > 0 ? (span * r.total) / total : 0;
const from = cursor;
cursor -= size + GAP;
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
return { ...r, from, to: from - size, solvedTo: from - solved };
});
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
cursor -= size + GAP;
}
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
+4 -4
View File
@@ -76,7 +76,7 @@ function layout(year: Day[]): { blocks: Block[]; width: number } {
export default function SubmissionHeatmap() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
if (load.phase !== "ready") {
return (
@@ -116,7 +116,7 @@ export default function SubmissionHeatmap() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 items-center">
<div ref={hover.ref} className="relative w-full" onPointerLeave={hover.hide}>
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full"
@@ -135,7 +135,7 @@ export default function SubmissionHeatmap() {
rx={2}
fill={HEAT[Math.min(cell.attempts, 4)]}
onPointerMove={(event) =>
hover.show(
show(
event,
<>
<div className="font-medium">{DAY.format(cell.date)}</div>
@@ -157,7 +157,7 @@ export default function SubmissionHeatmap() {
</g>
))}
</svg>
{hover.node}
{node}
</div>
</CardContent>
</Card>
+4 -4
View File
@@ -35,7 +35,7 @@ function lines(name: string): string[] {
export default function TopicBubbles() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
const clip = useId().replace(/:/g, "");
if (load.phase !== "ready") {
@@ -70,7 +70,7 @@ export default function TopicBubbles() {
</CardDescription>
</CardHeader>
<CardContent>
<div ref={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.hide}>
<div ref={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hide}>
<svg
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
className="h-auto w-full"
@@ -89,7 +89,7 @@ export default function TopicBubbles() {
key={c.issue}
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
onPointerMove={(event) =>
hover.show(
show(
event,
<>
<div className="font-medium">{c.name}</div>
@@ -131,7 +131,7 @@ export default function TopicBubbles() {
);
})}
</svg>
{hover.node}
{node}
</div>
</CardContent>
</Card>
+3 -1
View File
@@ -66,10 +66,12 @@ let request: Promise<Stats | null> | undefined;
export function useStats(): Load {
const [load, setLoad] = useState<Load>({ phase: "loading" });
useEffect(() => {
request ??= fetch(`${API_BASE}/api/stats`)
if (!request) {
request = fetch(`${API_BASE}/api/stats`)
.then((res) => (res.ok ? res.json() : null))
.then((body: unknown) => (isStats(body) ? body : null))
.catch(() => null);
}
let live = true;
request.then((stats) => {
if (live) setLoad(stats ? { phase: "ready", stats } : { phase: "error" });