diff --git a/e2e/timeline.spec.ts b/e2e/timeline.spec.ts index ad5f3d0c7..d75c82a79 100644 --- a/e2e/timeline.spec.ts +++ b/e2e/timeline.spec.ts @@ -866,13 +866,18 @@ test("a lab marker is picked, drawn as its readings, read on its own and kept", '[data-slot="timeline-point-target"][data-series="lab:bm-ferritin"][data-t="fe-1"]', ); const tip = page.locator('[data-slot="timeline-point-tip"]'); - // A CI run focused the target and no tip opened. The cause is not pinned - // down (the tips layer unmounts while the chart has no layout, which is - // one candidate); focus again until the tip opens. - await expect(async () => { - await target.focus(); - await expect(tip).toHaveAttribute("data-t", "fe-1", { timeout: 1_000 }); - }).toPass({ timeout: 10_000 }); + // A pointer resting on another line's point, as the menu click can leave + // it: the focus takes the card, and that point losing the pointer (the + // focus scrolls the chart from under it) must not close the card. + await page + .locator('[data-slot="timeline-point-target"][data-series="WEIGHT"]') + .last() + .hover(); + await expect(tip).toHaveAttribute("data-series", "WEIGHT"); + await target.focus(); + await expect(tip).toHaveAttribute("data-t", "fe-1"); + await page.mouse.move(0, 0); + await expect(tip).toHaveAttribute("data-t", "fe-1"); await expect(tip).toContainText("22 ng/mL"); await expect(tip).toContainText("Laborbefund März"); diff --git a/src/components/timeline/__tests__/timeline-components.test.tsx b/src/components/timeline/__tests__/timeline-components.test.tsx index ab0196b6f..8598cefb9 100644 --- a/src/components/timeline/__tests__/timeline-components.test.tsx +++ b/src/components/timeline/__tests__/timeline-components.test.tsx @@ -25,7 +25,7 @@ import { SeriesNameLinks, TimelineChart, } from "../timeline-chart"; -import { SeriesPointTips, tipLeft } from "../series-point-tips"; +import { SeriesPointTips, releaseCard, tipLeft } from "../series-point-tips"; import { TimelineChronicle } from "../timeline-chronicle"; import { layoutTimeline } from "../timeline-geometry"; import { useSeriesValueFormat } from "../use-series-value-format"; @@ -693,6 +693,38 @@ describe("a value line's point, on its own", () => { expect(html).not.toContain('data-slot="timeline-point-tip"'); }); + it("lets a point that loses the pointer or the focus take only its own card", () => { + const lab = { key: "lab:bm-ferritin", t: "fe-1" }; + // The pointer rested on a weight point, then the keyboard took the lab + // reading (and its scroll moved the weight point away from the pointer): + // the weight point's leave must not close the reading's card. + expect( + releaseCard({ ...lab, pinned: false }, "WEIGHT", "2026-01-01", lab), + ).toEqual({ ...lab, pinned: false }); + // The pointer leaving its own point gives the card back to the focus. + expect( + releaseCard( + { key: "WEIGHT", t: "2026-01-01", pinned: false }, + "WEIGHT", + "2026-01-01", + lab, + ), + ).toEqual({ ...lab, pinned: false }); + // With nothing focused, it closes. + expect( + releaseCard( + { key: "WEIGHT", t: "2026-01-01", pinned: false }, + "WEIGHT", + "2026-01-01", + null, + ), + ).toBeNull(); + // A pinned card stays until a tap elsewhere. + const pinned = { key: "WEIGHT", t: "2026-01-01", pinned: true }; + expect(releaseCard(pinned, "WEIGHT", "2026-01-01", null)).toBe(pinned); + expect(releaseCard(null, "WEIGHT", "2026-01-01", lab)).toBeNull(); + }); + it("keeps the card inside the chart's width", () => { expect(tipLeft(450, 200, 900)).toBe(350); expect(tipLeft(20, 200, 900)).toBe(0); diff --git a/src/components/timeline/series-point-tips.tsx b/src/components/timeline/series-point-tips.tsx index e707103cf..eb467261a 100644 --- a/src/components/timeline/series-point-tips.tsx +++ b/src/components/timeline/series-point-tips.tsx @@ -36,6 +36,7 @@ import { useState, type KeyboardEvent, type ReactNode, + type RefObject, } from "react"; import Link from "next/link"; @@ -75,6 +76,24 @@ interface Active { pinned: boolean; } +/** + * The card once the point `key`/`t` loses the pointer or the focus. Only that + * point's own card goes, and then to the point the keyboard holds, if any: a + * pointer leaving one point (or a scroll moving it from under a resting + * pointer) never takes away the card of another point that has the focus. A + * card pinned by a tap stays until a tap elsewhere. + */ +export function releaseCard( + active: Active | null, + key: string, + t: string, + focused: { key: string; t: string } | null, +): Active | null { + if (!active || active.pinned) return active; + if (active.key !== key || active.t !== t) return active; + return focused ? { ...focused, pinned: false } : null; +} + /** One point's target and card, a value line's or a lab line's alike. */ interface TipPoint { /** The point's id within its line: a bucket start, or a reading's id. */ @@ -117,6 +136,8 @@ export function SeriesPointTips({ const cardRef = useRef(null); const [cardWidth, setCardWidth] = useState(200); const buttons = useRef(new Map()); + // The point the keyboard holds, the card a pointer leaving gives back to. + const focused = useRef<{ key: string; t: string } | null>(null); // The card's real width, once drawn, for the clamping below. useLayoutEffect(() => { @@ -209,13 +230,18 @@ export function SeriesPointTips({ }} onPointerLeave={(event) => { if (event.pointerType !== "mouse") return; - setActive((a) => (a?.pinned ? a : null)); + const held = focused.current; + setActive((a) => releaseCard(a, s.key, p.t, held)); }} onFocus={() => { + focused.current = { key: s.key, t: p.t }; setStops((st) => ({ ...st, [s.key]: p.t })); setActive({ key: s.key, t: p.t, pinned: false }); }} - onBlur={() => setActive((a) => (a?.pinned ? a : null))} + onBlur={() => { + focused.current = null; + setActive((a) => releaseCard(a, s.key, p.t, null)); + }} onKeyDown={(event) => { event.stopPropagation(); if (event.key === "Enter" || event.key === " ") { @@ -259,38 +285,67 @@ export function SeriesPointTips({ }), )} {line && point ? ( -
120 - ? { bottom: layout.height - point.y + POINT_TARGET / 2 } - : { top: point.y + POINT_TARGET / 2 }), - }} - > - - onOpenDay(point.day), - )} - /> -
+ ) : null} ); } +/** The card over one point, kept above it (below it at the chart's top). */ +function PointCard({ + cardRef, + seriesKey, + point, + pinned, + left, + layoutHeight, + onOpenDay, +}: { + cardRef: RefObject; + seriesKey: string; + point: TipPoint; + pinned: boolean; + left: number; + layoutHeight: number; + onOpenDay: (date: string) => void; +}) { + return ( +
120 + ? { bottom: layoutHeight - point.y + POINT_TARGET / 2 } + : { top: point.y + POINT_TARGET / 2 }), + }} + > + onOpenDay(point.day))} + /> +
+ ); +} + /** * Under a card's row: a reading's report (a link once the card is pinned, its * name while it only follows the pointer) and, pinned, the way into the day.