mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(timeline): keep a focused point's card when another point loses the pointer
A pointer leaving any point target closed whichever card was open, and a blur did the same. With the pointer resting on one line's point and the keyboard on another (the focus scrolls the chart, so the resting pointer leaves the first point), the focused point lost its card and focusing it again fired nothing. A point now releases only its own card, and hands it back to the focused point when there is one. The lab-marker journey drove into this when the menu click left the pointer over a value point. It now sets that up on purpose and asserts the card survives the pointer leaving, in place of the retry that could never reopen a card on an already focused target.
This commit is contained in:
1 parent
7b4c67b372
commit
0882ebd79f
3 files changed
+129
-37
No files matched your search
+12
-7
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [cardWidth, setCardWidth] = useState(200);
|
||||
const buttons = useRef(new Map<string, HTMLButtonElement>());
|
||||
// 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 ? (
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-slot="timeline-point-tip"
|
||||
data-series={line.key}
|
||||
data-t={point.t}
|
||||
className={
|
||||
active?.pinned
|
||||
? "absolute z-10 w-max max-w-60"
|
||||
: "pointer-events-none absolute z-10 w-max max-w-60"
|
||||
}
|
||||
style={{
|
||||
left: tipLeft(point.x, cardWidth, layout.width),
|
||||
// Above the point, below it when the line sits at the top.
|
||||
...(point.y > 120
|
||||
? { bottom: layout.height - point.y + POINT_TARGET / 2 }
|
||||
: { top: point.y + POINT_TARGET / 2 }),
|
||||
}}
|
||||
>
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={point.label}
|
||||
rows={[point.row]}
|
||||
action={tipAction(point, active?.pinned ?? false, () =>
|
||||
onOpenDay(point.day),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<PointCard
|
||||
cardRef={cardRef}
|
||||
seriesKey={line.key}
|
||||
point={point}
|
||||
pinned={active?.pinned ?? false}
|
||||
left={tipLeft(point.x, cardWidth, layout.width)}
|
||||
layoutHeight={layout.height}
|
||||
onOpenDay={onOpenDay}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<HTMLDivElement | null>;
|
||||
seriesKey: string;
|
||||
point: TipPoint;
|
||||
pinned: boolean;
|
||||
left: number;
|
||||
layoutHeight: number;
|
||||
onOpenDay: (date: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-slot="timeline-point-tip"
|
||||
data-series={seriesKey}
|
||||
data-t={point.t}
|
||||
className={
|
||||
pinned
|
||||
? "absolute z-10 w-max max-w-60"
|
||||
: "pointer-events-none absolute z-10 w-max max-w-60"
|
||||
}
|
||||
style={{
|
||||
left,
|
||||
// Above the point, below it when the line sits at the top.
|
||||
...(point.y > 120
|
||||
? { bottom: layoutHeight - point.y + POINT_TARGET / 2 }
|
||||
: { top: point.y + POINT_TARGET / 2 }),
|
||||
}}
|
||||
>
|
||||
<RichChartTooltip
|
||||
active
|
||||
label={point.label}
|
||||
rows={[point.row]}
|
||||
action={tipAction(point, pinned, () => onOpenDay(point.day))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
||||
Reference in new issue
Block a user