fix: tip returns to its row

This commit is contained in:
zhom committed 2026-10-20 12:10:22 +04:00
1 parent f1bfe7d0e8
commit 0652a21bc3
3 files changed
+62 -3

No files matched your search

+19 -2
View File
@@ -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<typeof createLens>,
memory: Map<CoachAnchorId, { item: HTMLElement; box: Box }>,
homes: Map<CoachAnchorId, string | HTMLElement>,
): AnchorCandidate<HTMLElement> | null {
const candidates: AnchorCandidate<HTMLElement>[] = [];
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<CoachAnchorId, { item: HTMLElement; box: Box }>(),
);
/** The element each anchor id pointed at first, by its key. */
const homesRef = useRef(new Map<CoachAnchorId, string | HTMLElement>());
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;
+37
View File
@@ -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",
);
});
+6 -1
View File
@@ -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<T>(
anchors: readonly CoachAnchorId[],
candidates: readonly AnchorCandidate<T>[],
shows: (candidate: AnchorCandidate<T>) => boolean,
recall: (id: CoachAnchorId) => { item: T; box: Box } | undefined,
isHome: (candidate: AnchorCandidate<T>) => boolean = () => false,
): AnchorCandidate<T> | 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);