mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 14:51:49 +02:00
fix: tips follow scroll
This commit is contained in:
1 parent
553c7689d9
commit
cae6f9980e
4 files changed
+548
-87
No files matched your search
@@ -346,8 +346,14 @@ flow (what to show when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`,
|
||||
rail uses `right`). The coach mark follows its anchor through scrolling,
|
||||
resizing and re-renders, flips to stay on screen, hides while a modal
|
||||
surface is open, never takes the focus, describes the anchor with its text
|
||||
(`aria-describedby`), and closes with Escape. Placement is the pure
|
||||
`placeCoachMark` (tests in `src/lib/coach-mark.test.mjs`).
|
||||
(`aria-describedby`), and closes with Escape. An anchor counts only while
|
||||
most of it shows: not scrolled out of any container, under a `.scroll-fade`
|
||||
sticky header or fade band, or covered. The followed element keeps the mark
|
||||
while it shows; then the nearest element of the same id, the next anchor,
|
||||
or nothing (the mark hides until one comes back). Events schedule one
|
||||
measure per frame (`frame.read`). The card stays inside the anchor's scroll
|
||||
container when it fits there. Placement and choice are the pure
|
||||
`placeCoachMark` and `pickAnchor` (tests in `src/lib/coach-mark.test.mjs`).
|
||||
- One new tip a day, only on the Profiles page with at least one profile,
|
||||
after a settled launch (onboarding done, terms accepted, never in the
|
||||
first-run session), once the page has been quiet a few seconds with nothing
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import {
|
||||
animate,
|
||||
cancelFrame,
|
||||
frame,
|
||||
type MotionValue,
|
||||
motion,
|
||||
useMotionValue,
|
||||
@@ -21,19 +23,29 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import {
|
||||
type AnchorCandidate,
|
||||
anchorSelector,
|
||||
type Box,
|
||||
COACH_MARK_EXIT_MS,
|
||||
type CoachAnchorId,
|
||||
clipArea,
|
||||
type Insets,
|
||||
mostlyVisible,
|
||||
pickAnchor,
|
||||
placeCoachMark,
|
||||
type Side,
|
||||
visiblePart,
|
||||
} from "@/lib/coach-mark";
|
||||
import { MOTION_DURATION, MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** A modal surface is open; a coach mark waits behind it. */
|
||||
const MODAL_SELECTOR = '[role="dialog"], [role="alertdialog"]';
|
||||
/** The mark's own layers, and surfaces that pass over the page for a moment. */
|
||||
const PASSING_LAYERS =
|
||||
"[data-coach-layer], [data-radix-popper-content-wrapper], [data-sonner-toaster]";
|
||||
/** A new anchor closer than this to the ring is the same place: no travel. */
|
||||
const SAME_PLACE_PX = 2;
|
||||
/**
|
||||
* Observers report through the rendering loop, which a throttled webview
|
||||
* stops; this slow poll still places the card there. It runs only while the
|
||||
@@ -85,31 +97,145 @@ function toBox(rect: DOMRect): Box {
|
||||
return { x: rect.left, y: rect.top, width: rect.width, height: rect.height };
|
||||
}
|
||||
|
||||
/** Every box that clips `element`: its scrolling ancestors and the window. */
|
||||
function clipsOf(element: Element): Box[] {
|
||||
const clips: Box[] = [
|
||||
{ x: 0, y: 0, width: window.innerWidth, height: window.innerHeight },
|
||||
];
|
||||
const NO_INSETS: Insets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
function clipsContent(style: CSSStyleDeclaration): boolean {
|
||||
return style.overflowX !== "visible" || style.overflowY !== "visible";
|
||||
}
|
||||
|
||||
/**
|
||||
* The edges of a `.scroll-fade` container that hide what scrolls under them:
|
||||
* the sticky header it declares, and each fade band while it shows.
|
||||
*/
|
||||
function coveredEdges(node: HTMLElement, style: CSSStyleDeclaration): Insets {
|
||||
if (!node.classList.contains("scroll-fade")) return NO_INSETS;
|
||||
const length = (name: string) =>
|
||||
Number.parseFloat(style.getPropertyValue(name)) || 0;
|
||||
const band = length("--scroll-fade-size");
|
||||
return {
|
||||
top:
|
||||
length("--scroll-fade-top-offset") +
|
||||
(node.dataset.fadeTop === "true" ? band : 0),
|
||||
right: 0,
|
||||
bottom: node.dataset.fadeBottom === "true" ? band : 0,
|
||||
left: 0,
|
||||
};
|
||||
}
|
||||
|
||||
interface Ancestor {
|
||||
sticky: boolean;
|
||||
/**
|
||||
* When the element clips: what it shows of content that scrolls inside
|
||||
* it, and of sticky content, which its covered edges do not hide.
|
||||
*/
|
||||
clip: { scrolling: Box; sticky: Box } | null;
|
||||
}
|
||||
|
||||
/** Nothing of the page lies over the middle of what shows of the anchor. */
|
||||
function uncovered(element: Element, part: Box): boolean {
|
||||
const top = document
|
||||
.elementsFromPoint(part.x + part.width / 2, part.y + part.height / 2)
|
||||
.find((hit) => !hit.closest(PASSING_LAYERS));
|
||||
return (
|
||||
!top || top === element || element.contains(top) || top.contains(element)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads what shows on screen, for one placement. The buttons of every row
|
||||
* share their scroll containers, so each element is read once.
|
||||
*/
|
||||
function createLens() {
|
||||
const viewport: Box = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
};
|
||||
const read = new Map<Element, Ancestor>();
|
||||
const ancestor = (node: HTMLElement): Ancestor => {
|
||||
const known = read.get(node);
|
||||
if (known) return known;
|
||||
const style = getComputedStyle(node);
|
||||
const sticky = style.position === "sticky";
|
||||
let found: Ancestor = { sticky, clip: null };
|
||||
if (clipsContent(style)) {
|
||||
const border = toBox(node.getBoundingClientRect());
|
||||
const client = {
|
||||
left: node.clientLeft,
|
||||
top: node.clientTop,
|
||||
width: node.clientWidth,
|
||||
height: node.clientHeight,
|
||||
};
|
||||
const whole = clipArea(border, client, NO_INSETS);
|
||||
const covered = coveredEdges(node, style);
|
||||
found = {
|
||||
sticky,
|
||||
clip: {
|
||||
scrolling:
|
||||
covered === NO_INSETS ? whole : clipArea(border, client, covered),
|
||||
sticky: whole,
|
||||
},
|
||||
};
|
||||
}
|
||||
read.set(node, found);
|
||||
return found;
|
||||
};
|
||||
|
||||
/**
|
||||
* Every box that clips `element`: the window and its clipping ancestors. A
|
||||
* sticky header is not hidden by the edge it sticks to.
|
||||
*/
|
||||
const clipsOf = (element: HTMLElement): Box[] => {
|
||||
const clips = [viewport];
|
||||
let pinned = ancestor(element).sticky;
|
||||
for (
|
||||
let node = element.parentElement;
|
||||
node && node !== document.body;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const { sticky, clip } = ancestor(node);
|
||||
if (clip) {
|
||||
clips.push(pinned ? clip.sticky : clip.scrolling);
|
||||
pinned = false;
|
||||
}
|
||||
if (sticky) pinned = true;
|
||||
}
|
||||
return clips;
|
||||
};
|
||||
|
||||
/** Most of the element shows: not scrolled away, clipped or covered. */
|
||||
const shows = (element: HTMLElement, box: Box): boolean => {
|
||||
if (box.width < 1 || box.height < 1) return false;
|
||||
const clips = clipsOf(element);
|
||||
if (!mostlyVisible(box, clips)) return false;
|
||||
if (element.closest("[inert], [aria-hidden='true']")) return false;
|
||||
const style = getComputedStyle(element);
|
||||
if (style.visibility === "hidden" || style.display === "none") return false;
|
||||
const part = visiblePart(box, clips);
|
||||
return part !== null && uncovered(element, part);
|
||||
};
|
||||
|
||||
/** The part of the page the element's scroll containers show. */
|
||||
const areaOf = (element: HTMLElement): Box => {
|
||||
const [first, ...rest] = clipsOf(element);
|
||||
return visiblePart(first, rest) ?? viewport;
|
||||
};
|
||||
|
||||
return { viewport, shows, areaOf };
|
||||
}
|
||||
|
||||
/** The element's clipping ancestors, to watch them change size. */
|
||||
function clippingAncestors(element: Element): HTMLElement[] {
|
||||
const found: HTMLElement[] = [];
|
||||
for (
|
||||
let node = element.parentElement;
|
||||
node && node !== document.body;
|
||||
node = node.parentElement
|
||||
) {
|
||||
const style = getComputedStyle(node);
|
||||
if (style.overflowX !== "visible" || style.overflowY !== "visible") {
|
||||
clips.push(toBox(node.getBoundingClientRect()));
|
||||
}
|
||||
if (clipsContent(getComputedStyle(node))) found.push(node);
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
function onScreen(element: Element): boolean {
|
||||
if (element.closest("[inert], [aria-hidden='true']")) return false;
|
||||
const style = getComputedStyle(element);
|
||||
if (style.visibility === "hidden" || style.display === "none") return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width < 1 || rect.height < 1) return false;
|
||||
return mostlyVisible(toBox(rect), clipsOf(element));
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,18 +287,32 @@ function watchMoves(element: Element, onMove: () => void): () => void {
|
||||
return stop;
|
||||
}
|
||||
|
||||
/** The first anchor, by preference, with an element that shows. */
|
||||
/**
|
||||
* Where to point now: the first anchor id, by preference, with an element
|
||||
* 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.
|
||||
*/
|
||||
function resolveAnchor(
|
||||
anchors: readonly CoachAnchorId[],
|
||||
): { id: CoachAnchorId; element: HTMLElement } | null {
|
||||
lens: ReturnType<typeof createLens>,
|
||||
memory: Map<CoachAnchorId, { item: HTMLElement; box: Box }>,
|
||||
): AnchorCandidate<HTMLElement> | null {
|
||||
const candidates: AnchorCandidate<HTMLElement>[] = [];
|
||||
for (const id of anchors) {
|
||||
for (const element of document.querySelectorAll<HTMLElement>(
|
||||
for (const item of document.querySelectorAll<HTMLElement>(
|
||||
anchorSelector(id),
|
||||
)) {
|
||||
if (onScreen(element)) return { id, element };
|
||||
candidates.push({ id, item, box: toBox(item.getBoundingClientRect()) });
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const found = pickAnchor(
|
||||
anchors,
|
||||
candidates,
|
||||
(candidate) => lens.shows(candidate.item, candidate.box),
|
||||
(id) => memory.get(id),
|
||||
);
|
||||
if (found) memory.set(found.id, { item: found.item, box: found.box });
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,7 +437,10 @@ export interface CoachMarkProps extends DataAttributes {
|
||||
* Points at a real element: a ring drawn around it, a beacon on its corner,
|
||||
* and a card beside it with an arrow. The anchor is found by its
|
||||
* `data-tip-anchor` id, and the mark follows it through scrolling, resizing
|
||||
* and re-renders, flipping sides to stay on screen.
|
||||
* and re-renders, flipping sides to stay on screen. An anchor counts only
|
||||
* while most of it shows: scrolled out of a container, under a sticky header
|
||||
* or a fade band, covered, or unmounted, it gives the mark to the next anchor
|
||||
* that shows, and with none the mark waits hidden until one comes back.
|
||||
*
|
||||
* It never takes the focus. The card is a labelled region whose buttons are in
|
||||
* the tab order, the anchor is described by the card's text while the mark
|
||||
@@ -340,6 +483,12 @@ export function CoachMark({
|
||||
const afterMeasureRef = useRef<(() => void) | null>(null);
|
||||
const travelUntilRef = useRef(0);
|
||||
const shownRef = useRef(false);
|
||||
/** The last measure hid the mark: its next appearance does not travel. */
|
||||
const hiddenRef = useRef(true);
|
||||
/** The element each anchor id pointed at last, and where it was. */
|
||||
const memoryRef = useRef(
|
||||
new Map<CoachAnchorId, { item: HTMLElement; box: Box }>(),
|
||||
);
|
||||
|
||||
const cardX = useMotionValue(0);
|
||||
const cardY = useMotionValue(0);
|
||||
@@ -393,20 +542,38 @@ export function CoachMark({
|
||||
open,
|
||||
} = latest.current;
|
||||
const blocked = Boolean(document.querySelector(MODAL_SELECTOR));
|
||||
const found = blocked ? null : resolveAnchor(anchors);
|
||||
const lens = createLens();
|
||||
const found = blocked
|
||||
? null
|
||||
: resolveAnchor(anchors, lens, memoryRef.current);
|
||||
const element = found?.item ?? null;
|
||||
const hidden = blocked || (!found && !floatWithoutAnchor);
|
||||
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
||||
const { viewport } = lens;
|
||||
const size = { width: card.offsetWidth, height: card.offsetHeight };
|
||||
|
||||
const pad = spotlight ? 6 : 4;
|
||||
const ring = found
|
||||
? {
|
||||
x: found.box.x - pad,
|
||||
y: found.box.y - pad,
|
||||
width: found.box.width + pad * 2,
|
||||
height: found.box.height + pad * 2,
|
||||
}
|
||||
: null;
|
||||
const now = performance.now();
|
||||
if (
|
||||
ring &&
|
||||
shownRef.current &&
|
||||
!hiddenRef.current &&
|
||||
!hidden &&
|
||||
!reduceMotion &&
|
||||
found?.element !== anchorRef.current
|
||||
element !== anchorRef.current &&
|
||||
(Math.abs(ringX.get() - ring.x) > SAME_PLACE_PX ||
|
||||
Math.abs(ringY.get() - ring.y) > SAME_PLACE_PX)
|
||||
) {
|
||||
travelUntilRef.current = now + TRAVEL_MS;
|
||||
}
|
||||
if (hiddenRef.current) travelUntilRef.current = 0;
|
||||
const travel = now < travelUntilRef.current;
|
||||
const move = (value: MotionValue<number>, target: number) => {
|
||||
if (travel) animate(value, target, TRAVEL_SPRING);
|
||||
@@ -414,32 +581,27 @@ export function CoachMark({
|
||||
};
|
||||
// The anchor is described by the card while the mark points at it.
|
||||
const previous = anchorRef.current;
|
||||
if (previous && previous !== found?.element)
|
||||
describe(previous, bodyId, false);
|
||||
if (found) describe(found.element, bodyId, open && !hidden);
|
||||
anchorRef.current = found?.element ?? null;
|
||||
if (previous && previous !== element) describe(previous, bodyId, false);
|
||||
if (element) describe(element, bodyId, open && !hidden);
|
||||
anchorRef.current = element;
|
||||
|
||||
let side: Side | null = null;
|
||||
if (found) {
|
||||
const rect = found.element.getBoundingClientRect();
|
||||
const pad = spotlight ? 6 : 4;
|
||||
if (element && ring) {
|
||||
const radius =
|
||||
Number.parseFloat(
|
||||
getComputedStyle(found.element).borderTopLeftRadius,
|
||||
) || 6;
|
||||
const ring = {
|
||||
x: rect.left - pad,
|
||||
y: rect.top - pad,
|
||||
width: rect.width + pad * 2,
|
||||
height: rect.height + pad * 2,
|
||||
};
|
||||
const named = found.element.getAttribute("data-tip-side");
|
||||
const placed = placeCoachMark(ring, size, viewport, {
|
||||
Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 6;
|
||||
const named = element.getAttribute("data-tip-side");
|
||||
const options = {
|
||||
side: SIDE_VALUES.includes(named as Side)
|
||||
? (named as Side)
|
||||
: preferredSide,
|
||||
gap: ARROW_H + 4,
|
||||
});
|
||||
};
|
||||
// Inside the part of its scroll container that shows, when the card
|
||||
// fits there; else anywhere in the window.
|
||||
const inside = placeCoachMark(ring, size, lens.areaOf(element), options);
|
||||
const placed = inside.fits
|
||||
? inside
|
||||
: placeCoachMark(ring, size, viewport, options);
|
||||
side = placed.side;
|
||||
const arrow = arrowTransform(placed.side, placed.arrow, size);
|
||||
move(cardX, placed.x);
|
||||
@@ -458,6 +620,11 @@ export function CoachMark({
|
||||
move(cardX, (viewport.width - size.width) / 2);
|
||||
move(cardY, viewport.height - size.height - 24);
|
||||
card.style.transformOrigin = "50% 100%";
|
||||
// The spotlight closes its hole: there is nothing to show through it.
|
||||
move(ringX, ringX.get() + ringW.get() / 2);
|
||||
move(ringY, ringY.get() + ringH.get() / 2);
|
||||
move(ringW, 0);
|
||||
move(ringH, 0);
|
||||
}
|
||||
|
||||
// The first time the card shows, it settles out from its anchor.
|
||||
@@ -471,6 +638,7 @@ export function CoachMark({
|
||||
animate(enterScale, 1, ENTER_SPRING);
|
||||
}
|
||||
if (!hidden) shownRef.current = true;
|
||||
hiddenRef.current = hidden;
|
||||
|
||||
setLayout((previous) =>
|
||||
previous.anchor === (found?.id ?? null) &&
|
||||
@@ -501,48 +669,56 @@ export function CoachMark({
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
// Placed before the first paint, then kept in place by every event that can
|
||||
// move the anchor: scrolling, resizing, DOM changes, the anchor moving or
|
||||
// changing size, a candidate anchor coming into or out of view. Only a slow
|
||||
// move the anchor: scrolling anywhere (the window or any scroll container),
|
||||
// resizing, DOM changes, the anchor or its scroll containers moving or
|
||||
// changing size, a candidate anchor coming into or out of view. Events only
|
||||
// ask for a measure; the frame loop runs one per frame, before the frame's
|
||||
// render, so the mark moves in the same frame as the page. Only a slow
|
||||
// fallback poll runs on a timer.
|
||||
useLayoutEffect(() => {
|
||||
if (!mounted) return;
|
||||
const onChange = () => measure();
|
||||
const onFrame = () => measure();
|
||||
const schedule = () => frame.read(onFrame);
|
||||
let settle = 0;
|
||||
const onMutation = () => {
|
||||
window.clearTimeout(settle);
|
||||
settle = window.setTimeout(measure, MUTATION_SETTLE_MS);
|
||||
settle = window.setTimeout(schedule, MUTATION_SETTLE_MS);
|
||||
};
|
||||
const measureIfVisible = () => {
|
||||
if (document.visibilityState === "visible") measure();
|
||||
};
|
||||
window.addEventListener("resize", onChange);
|
||||
document.addEventListener("scroll", onChange, {
|
||||
window.addEventListener("resize", schedule);
|
||||
document.addEventListener("scroll", schedule, {
|
||||
capture: true,
|
||||
passive: true,
|
||||
});
|
||||
document.addEventListener("visibilitychange", measureIfVisible);
|
||||
const resize = new ResizeObserver(onChange);
|
||||
const resize = new ResizeObserver(schedule);
|
||||
if (cardRef.current) resize.observe(cardRef.current);
|
||||
const mutations = new MutationObserver(onMutation);
|
||||
mutations.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
// Candidates crossing the "mostly visible" line (0.6) change the anchor.
|
||||
const candidates = new IntersectionObserver(onChange, {
|
||||
const candidates = new IntersectionObserver(schedule, {
|
||||
threshold: [0.5, 0.6, 0.7, 1],
|
||||
});
|
||||
let watchedCandidates: Element[] = [];
|
||||
let followed: HTMLElement | null = null;
|
||||
let containers: HTMLElement[] = [];
|
||||
let stopMoves: (() => void) | null = null;
|
||||
afterMeasureRef.current = () => {
|
||||
const anchor = anchorRef.current;
|
||||
if (anchor !== followed) {
|
||||
if (followed) resize.unobserve(followed);
|
||||
for (const container of containers) resize.unobserve(container);
|
||||
stopMoves?.();
|
||||
stopMoves = null;
|
||||
followed = anchor;
|
||||
containers = anchor ? clippingAncestors(anchor) : [];
|
||||
if (anchor) {
|
||||
resize.observe(anchor);
|
||||
stopMoves = watchMoves(anchor, onChange);
|
||||
for (const container of containers) resize.observe(container);
|
||||
stopMoves = watchMoves(anchor, schedule);
|
||||
}
|
||||
}
|
||||
const selector = latest.current.anchors.map(anchorSelector).join(",");
|
||||
@@ -562,9 +738,10 @@ export function CoachMark({
|
||||
const poll = window.setInterval(measureIfVisible, FALLBACK_POLL_MS);
|
||||
return () => {
|
||||
window.clearInterval(poll);
|
||||
cancelFrame(onFrame);
|
||||
afterMeasureRef.current = null;
|
||||
window.removeEventListener("resize", onChange);
|
||||
document.removeEventListener("scroll", onChange, { capture: true });
|
||||
window.removeEventListener("resize", schedule);
|
||||
document.removeEventListener("scroll", schedule, { capture: true });
|
||||
document.removeEventListener("visibilitychange", measureIfVisible);
|
||||
resize.disconnect();
|
||||
mutations.disconnect();
|
||||
@@ -659,6 +836,7 @@ export function CoachMark({
|
||||
{spotlight && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-coach-layer=""
|
||||
data-slot="coach-mark-spotlight"
|
||||
className="pointer-events-none fixed inset-0 z-[70] h-full w-full"
|
||||
style={{ visibility }}
|
||||
@@ -683,6 +861,7 @@ export function CoachMark({
|
||||
{anchored && (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-coach-layer=""
|
||||
data-slot="coach-mark-ring"
|
||||
className="pointer-events-none fixed inset-0 z-[71] h-full w-full overflow-visible"
|
||||
>
|
||||
@@ -711,6 +890,7 @@ export function CoachMark({
|
||||
{anchored && (
|
||||
<motion.div
|
||||
aria-hidden="true"
|
||||
data-coach-layer=""
|
||||
data-slot="coach-mark-beacon"
|
||||
className="pointer-events-none fixed top-0 left-0 z-[72] -mt-1.5 -ml-1.5 size-3"
|
||||
style={{ x: beaconX, y: beaconY }}
|
||||
@@ -740,6 +920,7 @@ export function CoachMark({
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
data-coach-layer=""
|
||||
className="pointer-events-none fixed top-0 left-0 z-[73]"
|
||||
style={{ x: cardX, y: cardY, visibility }}
|
||||
>
|
||||
|
||||
@@ -3,8 +3,10 @@ import test from "node:test";
|
||||
import {
|
||||
anchorSelector,
|
||||
COACH_ANCHORS,
|
||||
clipArea,
|
||||
isRailAnchor,
|
||||
mostlyVisible,
|
||||
pickAnchor,
|
||||
placeCoachMark,
|
||||
visiblePart,
|
||||
} from "./coach-mark.ts";
|
||||
@@ -81,6 +83,179 @@ test("an anchor scrolled mostly out of its container does not count", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const NONE = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
test("a scroll container shows its content inside borders, scrollbars and covered edges", () => {
|
||||
const border = { x: 100, y: 50, width: 400, height: 300 };
|
||||
// A 1px border all round and a 12px scrollbar on the right.
|
||||
const client = { left: 1, top: 1, width: 386, height: 298 };
|
||||
assert.deepEqual(clipArea(border, client, NONE), {
|
||||
x: 101,
|
||||
y: 51,
|
||||
width: 386,
|
||||
height: 298,
|
||||
});
|
||||
// A 32px sticky header and a 24px fade band at each end.
|
||||
assert.deepEqual(
|
||||
clipArea(border, client, { top: 56, right: 0, bottom: 24, left: 0 }),
|
||||
{ x: 101, y: 107, width: 386, height: 218 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
clipArea(border, { left: 0, top: 0, width: 0, height: 0 }, NONE),
|
||||
border,
|
||||
"an inline element clips at its border box",
|
||||
);
|
||||
});
|
||||
|
||||
test("a row under the sticky header or in the fade band does not count", () => {
|
||||
const table = clipArea(
|
||||
{ x: 0, y: 100, width: 600, height: 400 },
|
||||
{ left: 0, top: 0, width: 600, height: 400 },
|
||||
{ top: 56, right: 0, bottom: 24, left: 0 },
|
||||
);
|
||||
const clips = [{ x: 0, y: 0, width: 1000, height: 700 }, table];
|
||||
const button = (y) => ({ x: 560, y, width: 28, height: 28 });
|
||||
assert.equal(mostlyVisible(button(104), clips), false, "under the header");
|
||||
assert.equal(mostlyVisible(button(140), clips), false, "in the fade band");
|
||||
assert.equal(mostlyVisible(button(170), clips), true);
|
||||
assert.equal(mostlyVisible(button(460), clips), false, "in the bottom fade");
|
||||
assert.equal(mostlyVisible(button(800), clips), false, "off the window");
|
||||
});
|
||||
|
||||
/** One info button per row, 36px apart, from the top of the table. */
|
||||
function rows(count, top = 0) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: "row-inspect",
|
||||
item: `row-${index}`,
|
||||
box: { x: 560, y: top + index * 36, width: 28, height: 28 },
|
||||
}));
|
||||
}
|
||||
const RAIL = {
|
||||
id: "rail-profiles",
|
||||
item: "rail",
|
||||
box: { x: 6, y: 60, width: 28, height: 28 },
|
||||
};
|
||||
const insideTable = (candidate) =>
|
||||
candidate.id !== "row-inspect" ||
|
||||
(candidate.box.y >= 156 && candidate.box.y + candidate.box.height <= 600);
|
||||
|
||||
test("the first anchor that shows wins, best first, in page order", () => {
|
||||
const candidates = [...rows(20, 100), RAIL];
|
||||
const found = pickAnchor(
|
||||
["row-inspect", "rail-profiles"],
|
||||
candidates,
|
||||
insideTable,
|
||||
() => undefined,
|
||||
);
|
||||
assert.equal(found.item, "row-2", "rows 0 and 1 are under the header");
|
||||
assert.equal(
|
||||
pickAnchor(
|
||||
["row-inspect", "rail-profiles"],
|
||||
[...rows(20, 1000), RAIL],
|
||||
insideTable,
|
||||
() => undefined,
|
||||
).item,
|
||||
"rail",
|
||||
"with every row scrolled away, the rail item takes the mark",
|
||||
);
|
||||
assert.equal(
|
||||
pickAnchor(["column-dns"], candidates, insideTable, () => undefined),
|
||||
null,
|
||||
"with no anchor at all there is nowhere to point",
|
||||
);
|
||||
});
|
||||
|
||||
test("the followed row keeps the mark while it shows, and the nearest row takes over", () => {
|
||||
const anchors = ["row-inspect", "rail-profiles"];
|
||||
const at = (item, y) => ({ item, box: { x: 560, y, width: 28, height: 28 } });
|
||||
// A short scroll: row 8 moved but still shows, so the mark stays on it.
|
||||
assert.equal(
|
||||
pickAnchor(anchors, [...rows(20, 0), RAIL], insideTable, () =>
|
||||
at("row-8", 388),
|
||||
).item,
|
||||
"row-8",
|
||||
);
|
||||
// Scrolled down: row 8 went under the header, or unmounted. The row
|
||||
// nearest to where it was takes the mark, not the first row that shows.
|
||||
const down = [...rows(40, -300), RAIL];
|
||||
assert.equal(
|
||||
pickAnchor(anchors, down, insideTable, () => at("row-8", 160)).item,
|
||||
"row-13",
|
||||
);
|
||||
assert.equal(
|
||||
pickAnchor(
|
||||
anchors,
|
||||
down.filter((candidate) => candidate.item !== "row-8"),
|
||||
insideTable,
|
||||
() => at("row-8", 160),
|
||||
).item,
|
||||
"row-13",
|
||||
);
|
||||
// Scrolled up: row 8 left at the bottom, and the row above it takes over.
|
||||
assert.equal(
|
||||
pickAnchor(anchors, [...rows(40, 300), RAIL], insideTable, () =>
|
||||
at("row-8", 570),
|
||||
).item,
|
||||
"row-7",
|
||||
);
|
||||
});
|
||||
|
||||
test("the card stays inside the part of its scroll container that shows", () => {
|
||||
const area = { x: 200, y: 156, width: 600, height: 420 };
|
||||
const anchor = { x: 760, y: 170, width: 36, height: 36 };
|
||||
const placed = placeCoachMark(anchor, CARD, area, { side: "bottom" });
|
||||
assert.equal(placed.fits, true);
|
||||
assert.equal(placed.side, "bottom");
|
||||
assert.ok(
|
||||
placed.x >= area.x + 8 && placed.x + CARD.width <= area.x + area.width - 8,
|
||||
);
|
||||
assert.ok(placed.y + CARD.height <= area.y + area.height - 8);
|
||||
// Near the bottom of the table it flips above, still inside.
|
||||
const low = { x: 400, y: 520, width: 36, height: 36 };
|
||||
const flipped = placeCoachMark(low, CARD, area, { side: "bottom" });
|
||||
assert.equal(flipped.side, "top");
|
||||
assert.ok(flipped.y >= area.y + 8);
|
||||
});
|
||||
|
||||
test("a card that fits never covers its anchor", () => {
|
||||
const area = { x: 200, y: 156, width: 600, height: 420 };
|
||||
const overlaps = (a, b) =>
|
||||
a.x < b.x + b.width &&
|
||||
b.x < a.x + a.width &&
|
||||
a.y < b.y + b.height &&
|
||||
b.y < a.y + a.height;
|
||||
for (let y = 160; y <= 540; y += 20) {
|
||||
for (let x = 204; x <= 760; x += 37) {
|
||||
const anchor = { x, y, width: 36, height: 36 };
|
||||
for (const side of ["top", "right", "bottom", "left"]) {
|
||||
const placed = placeCoachMark(anchor, CARD, area, { side });
|
||||
if (!placed.fits) continue;
|
||||
assert.ok(
|
||||
!overlaps(anchor, { ...CARD, x: placed.x, y: placed.y }),
|
||||
`anchor ${x},${y} preferring ${side}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("a container too small for the card does not count as room", () => {
|
||||
const strip = { x: 0, y: 0, width: 56, height: 700 };
|
||||
const rail = { x: 14, y: 300, width: 28, height: 28 };
|
||||
assert.equal(
|
||||
placeCoachMark(rail, CARD, strip, { side: "right" }).fits,
|
||||
false,
|
||||
"the rail is narrower than the card, so the window places it",
|
||||
);
|
||||
const short = { x: 0, y: 0, width: 1000, height: 150 };
|
||||
const head = { x: 400, y: 10, width: 100, height: 30 };
|
||||
assert.equal(
|
||||
placeCoachMark(head, CARD, short, { side: "right" }).fits,
|
||||
false,
|
||||
"beside the anchor, the card must also fit the area's height",
|
||||
);
|
||||
});
|
||||
|
||||
test("anchor ids are unique and map to their attribute", () => {
|
||||
assert.equal(new Set(COACH_ANCHORS).size, COACH_ANCHORS.length);
|
||||
assert.equal(anchorSelector("rail-more"), '[data-tip-anchor="rail-more"]');
|
||||
|
||||
+126
-27
@@ -96,31 +96,37 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), Math.max(min, max));
|
||||
}
|
||||
|
||||
/** The room between the anchor and the viewport edge on one side. */
|
||||
function room(anchor: Box, viewport: Size, side: Side): number {
|
||||
/** The room between the anchor and the edge of the area on one side. */
|
||||
function room(anchor: Box, area: Box, side: Side): number {
|
||||
switch (side) {
|
||||
case "top":
|
||||
return anchor.y;
|
||||
return anchor.y - area.y;
|
||||
case "bottom":
|
||||
return viewport.height - (anchor.y + anchor.height);
|
||||
return area.y + area.height - (anchor.y + anchor.height);
|
||||
case "left":
|
||||
return anchor.x;
|
||||
return anchor.x - area.x;
|
||||
case "right":
|
||||
return viewport.width - (anchor.x + anchor.width);
|
||||
return area.x + area.width - (anchor.x + anchor.width);
|
||||
}
|
||||
}
|
||||
|
||||
function toArea(area: Box | Size): Box {
|
||||
return "x" in area ? area : { x: 0, y: 0, ...area };
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts a card of `card` size next to `anchor`, `gap` away, inside the
|
||||
* viewport with `margin` to spare. The preferred side wins when the card
|
||||
* fits there; then the opposite side, then the two others. Along the edge the
|
||||
* card centers on the anchor and slides to stay on screen, and `arrow` keeps
|
||||
* pointing at the anchor's middle, never closer than `arrowInset` to a corner.
|
||||
* Puts a card of `card` size next to `anchor`, `gap` away, inside `bounds`
|
||||
* (the viewport, or the part of a scroll container that shows) with `margin`
|
||||
* to spare. The preferred side wins when the card fits there; then the
|
||||
* opposite side, then the two others. Along the edge the card centers on the
|
||||
* anchor and slides to stay inside, and `arrow` keeps pointing at the
|
||||
* anchor's middle, never closer than `arrowInset` to a corner. A card that
|
||||
* fits never covers its anchor.
|
||||
*/
|
||||
export function placeCoachMark(
|
||||
anchor: Box,
|
||||
card: Size,
|
||||
viewport: Size,
|
||||
bounds: Box | Size,
|
||||
{
|
||||
side: preferred = "bottom",
|
||||
gap = 12,
|
||||
@@ -133,6 +139,7 @@ export function placeCoachMark(
|
||||
arrowInset?: number;
|
||||
} = {},
|
||||
): Placement {
|
||||
const area = toArea(bounds);
|
||||
const order: Side[] = [
|
||||
preferred,
|
||||
OPPOSITE[preferred],
|
||||
@@ -142,43 +149,48 @@ export function placeCoachMark(
|
||||
(side === "top" || side === "bottom" ? card.height : card.width) +
|
||||
gap +
|
||||
margin;
|
||||
const across = (side: Side) =>
|
||||
(side === "top" || side === "bottom" ? card.width : card.height) +
|
||||
margin * 2;
|
||||
const breadth = (side: Side) =>
|
||||
side === "top" || side === "bottom" ? area.width : area.height;
|
||||
const chosen =
|
||||
order.find((side) => room(anchor, viewport, side) >= needs(side)) ?? null;
|
||||
order.find(
|
||||
(side) =>
|
||||
room(anchor, area, side) >= needs(side) &&
|
||||
breadth(side) >= across(side),
|
||||
) ?? null;
|
||||
const side =
|
||||
chosen ??
|
||||
order.reduce((best, side) =>
|
||||
room(anchor, viewport, side) - needs(side) >
|
||||
room(anchor, viewport, best) - needs(best)
|
||||
room(anchor, area, side) - needs(side) >
|
||||
room(anchor, area, best) - needs(best)
|
||||
? side
|
||||
: best,
|
||||
);
|
||||
|
||||
const left = area.x + margin;
|
||||
const top = area.y + margin;
|
||||
const right = area.x + area.width - margin;
|
||||
const bottom = area.y + area.height - margin;
|
||||
const centerX = anchor.x + anchor.width / 2;
|
||||
const centerY = anchor.y + anchor.height / 2;
|
||||
let x: number;
|
||||
let y: number;
|
||||
if (side === "top" || side === "bottom") {
|
||||
x = clamp(
|
||||
centerX - card.width / 2,
|
||||
margin,
|
||||
viewport.width - margin - card.width,
|
||||
);
|
||||
x = clamp(centerX - card.width / 2, left, right - card.width);
|
||||
y =
|
||||
side === "bottom"
|
||||
? anchor.y + anchor.height + gap
|
||||
: anchor.y - gap - card.height;
|
||||
y = clamp(y, margin, viewport.height - margin - card.height);
|
||||
y = clamp(y, top, bottom - card.height);
|
||||
} else {
|
||||
y = clamp(
|
||||
centerY - card.height / 2,
|
||||
margin,
|
||||
viewport.height - margin - card.height,
|
||||
);
|
||||
y = clamp(centerY - card.height / 2, top, bottom - card.height);
|
||||
x =
|
||||
side === "right"
|
||||
? anchor.x + anchor.width + gap
|
||||
: anchor.x - gap - card.width;
|
||||
x = clamp(x, margin, viewport.width - margin - card.width);
|
||||
x = clamp(x, left, right - card.width);
|
||||
}
|
||||
|
||||
const along = side === "top" || side === "bottom";
|
||||
@@ -219,3 +231,90 @@ export function mostlyVisible(box: Box, clips: readonly Box[]): boolean {
|
||||
if (!part || box.width <= 0 || box.height <= 0) return false;
|
||||
return (part.width * part.height) / (box.width * box.height) >= 0.6;
|
||||
}
|
||||
|
||||
export interface Insets {
|
||||
top: number;
|
||||
right: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The part of a clipping element that shows its content: the box inside its
|
||||
* borders and scrollbars (`client*`), less the edges where sticky chrome or a
|
||||
* fade covers what scrolls under them.
|
||||
*/
|
||||
export function clipArea(
|
||||
border: Box,
|
||||
client: { left: number; top: number; width: number; height: number },
|
||||
covered: Insets,
|
||||
): Box {
|
||||
// An inline element has no client box; its border box is all there is.
|
||||
const inner =
|
||||
client.width > 0 || client.height > 0
|
||||
? {
|
||||
x: border.x + client.left,
|
||||
y: border.y + client.top,
|
||||
width: client.width,
|
||||
height: client.height,
|
||||
}
|
||||
: border;
|
||||
return {
|
||||
x: inner.x + covered.left,
|
||||
y: inner.y + covered.top,
|
||||
width: Math.max(0, inner.width - covered.left - covered.right),
|
||||
height: Math.max(0, inner.height - covered.top - covered.bottom),
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnchorCandidate<T> {
|
||||
id: CoachAnchorId;
|
||||
item: T;
|
||||
box: Box;
|
||||
}
|
||||
|
||||
function distance(a: Box, b: Box): number {
|
||||
return Math.hypot(
|
||||
a.x + a.width / 2 - (b.x + b.width / 2),
|
||||
a.y + a.height / 2 - (b.y + b.height / 2),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks where a coach mark points. Anchor ids go best first; the first id
|
||||
* with an element that shows wins. Among the elements of one id (a button on
|
||||
* 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.
|
||||
*/
|
||||
export function pickAnchor<T>(
|
||||
anchors: readonly CoachAnchorId[],
|
||||
candidates: readonly AnchorCandidate<T>[],
|
||||
shows: (candidate: AnchorCandidate<T>) => boolean,
|
||||
recall: (id: CoachAnchorId) => { item: T; box: Box } | undefined,
|
||||
): AnchorCandidate<T> | null {
|
||||
for (const id of anchors) {
|
||||
const own = candidates.filter((candidate) => candidate.id === id);
|
||||
const last = recall(id);
|
||||
if (!last) {
|
||||
const first = own.find(shows);
|
||||
if (first) return first;
|
||||
continue;
|
||||
}
|
||||
const same = own.find((candidate) => candidate.item === last.item);
|
||||
if (same && shows(same)) return same;
|
||||
const nearest = own
|
||||
.filter((candidate) => candidate !== same)
|
||||
.map((candidate, index) => ({
|
||||
candidate,
|
||||
index,
|
||||
away: distance(candidate.box, last.box),
|
||||
}))
|
||||
.sort((a, b) => a.away - b.away || a.index - b.index);
|
||||
for (const { candidate } of nearest) {
|
||||
if (shows(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user