From 0652a21bc3d4f1466edf7773dfa26bf8e4e79500 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:10:22 +0400 Subject: [PATCH] fix: tip returns to its row --- src/components/ui/coach-mark.tsx | 21 ++++++++++++++++-- src/lib/coach-mark.test.mjs | 37 ++++++++++++++++++++++++++++++++ src/lib/coach-mark.ts | 7 +++++- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/components/ui/coach-mark.tsx b/src/components/ui/coach-mark.tsx index b67836bd..64c6a9f1 100644 --- a/src/components/ui/coach-mark.tsx +++ b/src/components/ui/coach-mark.tsx @@ -292,10 +292,21 @@ function watchMoves(element: Element, onMove: () => void): () => void { * that shows. The element followed last keeps the mark while it shows; when * it scrolls away or unmounts, the nearest one of the same id takes over. */ +/** + * What names an anchor element across remounts: its table row, when it sits + * in one (a virtualized row comes back as a new element), else the element. + */ +function anchorKey(item: HTMLElement): string | HTMLElement { + return ( + item.closest("[data-table-row]")?.getAttribute("data-table-row") ?? item + ); +} + function resolveAnchor( anchors: readonly CoachAnchorId[], lens: ReturnType, memory: Map, + homes: Map, ): AnchorCandidate | null { const candidates: AnchorCandidate[] = []; for (const id of anchors) { @@ -310,8 +321,12 @@ function resolveAnchor( candidates, (candidate) => lens.shows(candidate.item, candidate.box), (id) => memory.get(id), + (candidate) => homes.get(candidate.id) === anchorKey(candidate.item), ); - if (found) memory.set(found.id, { item: found.item, box: found.box }); + if (found) { + memory.set(found.id, { item: found.item, box: found.box }); + if (!homes.has(found.id)) homes.set(found.id, anchorKey(found.item)); + } return found; } @@ -489,6 +504,8 @@ export function CoachMark({ const memoryRef = useRef( new Map(), ); + /** The element each anchor id pointed at first, by its key. */ + const homesRef = useRef(new Map()); const cardX = useMotionValue(0); const cardY = useMotionValue(0); @@ -545,7 +562,7 @@ export function CoachMark({ const lens = createLens(); const found = blocked ? null - : resolveAnchor(anchors, lens, memoryRef.current); + : resolveAnchor(anchors, lens, memoryRef.current, homesRef.current); const element = found?.item ?? null; const hidden = blocked || (!found && !floatWithoutAnchor); const { viewport } = lens; diff --git a/src/lib/coach-mark.test.mjs b/src/lib/coach-mark.test.mjs index 569e3a3a..4053743b 100644 --- a/src/lib/coach-mark.test.mjs +++ b/src/lib/coach-mark.test.mjs @@ -262,3 +262,40 @@ test("anchor ids are unique and map to their attribute", () => { assert.ok(isRailAnchor("rail-settings")); assert.ok(!isRailAnchor("column-dns")); }); + +test("the element a mark started on takes it back when it shows again", () => { + const box = (top) => ({ x: 0, y: top, width: 20, height: 20 }); + const rows = [1, 2, 3].map((n) => ({ + id: "profile-info", + item: `row-${n}`, + box: box(n * 40), + })); + const shows = () => true; + const followed = { item: "row-3", box: box(120) }; + assert.equal( + pickAnchor(["profile-info"], rows, shows, () => followed).item, + "row-3", + "without a home the followed row keeps the mark", + ); + assert.equal( + pickAnchor( + ["profile-info"], + rows, + shows, + () => followed, + (candidate) => candidate.item === "row-1", + ).item, + "row-1", + ); + assert.equal( + pickAnchor( + ["profile-info"], + rows, + (candidate) => candidate.item !== "row-1", + () => followed, + (candidate) => candidate.item === "row-1", + ).item, + "row-3", + "a home out of view does not take the mark", + ); +}); diff --git a/src/lib/coach-mark.ts b/src/lib/coach-mark.ts index 71ae1b90..40c011a8 100644 --- a/src/lib/coach-mark.ts +++ b/src/lib/coach-mark.ts @@ -286,16 +286,21 @@ function distance(a: Box, b: Box): number { * every row), the one the mark followed last stays while it shows; when it * scrolls away or unmounts, the nearest one to where it was takes over, so * the mark moves a row and does not jump across the table. With nothing of - * that id followed before, the first element in page order wins. + * that id followed before, the first element in page order wins. The element + * the mark started on (`isHome`) takes it back whenever it shows again, so a + * scroll back to where the tip began puts the mark where it began. */ export function pickAnchor( anchors: readonly CoachAnchorId[], candidates: readonly AnchorCandidate[], shows: (candidate: AnchorCandidate) => boolean, recall: (id: CoachAnchorId) => { item: T; box: Box } | undefined, + isHome: (candidate: AnchorCandidate) => boolean = () => false, ): AnchorCandidate | null { for (const id of anchors) { const own = candidates.filter((candidate) => candidate.id === id); + const home = own.find((candidate) => isHome(candidate) && shows(candidate)); + if (home) return home; const last = recall(id); if (!last) { const first = own.find(shows);