1// How long a row the list just moved ignores presses. A click that was already
2// on its way when the row slid under the pointer lands within this window.
3const MOVED_ROW_PRESS_DELAY_MS = 500;
4// Moves this soon after the user's own press or key are theirs, such as the next
5// row sliding up after Settle. Same window as Cumulative Layout Shift's
6// hadRecentInput.
7const OWN_INPUT_WINDOW_MS = 500;
8
9const PRESS_FOLLOW_UPS = ["click", "auxclick", "dblclick", "contextmenu"] as const;
10
11// Editing text cannot move rows. Enter and modified keys in a field can, such as
12// sending a message or a shortcut.
13function editsText(event: KeyboardEvent) {
14 const target = event.target;
15 const editable =
16 target instanceof HTMLElement &&
17 (target.isContentEditable ||
18 target instanceof HTMLInputElement ||
19 target instanceof HTMLTextAreaElement);
20 return editable && event.key !== "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey;
21}
22
23function readTops(list: HTMLElement) {
24 const tops = new Map<HTMLElement, number>();
25 for (const child of list.children) {
26 if (child instanceof HTMLElement) tops.set(child, child.offsetTop);
27 }
28 return tops;
29}
30
31/**
32 * Drops presses on rows that something other than the user just moved, so a
33 * thread settling, waking or arriving above the pointer cannot hand its click
34 * to the neighbouring row. Call `update` after each commit that can move rows.
35 * The list must be positioned so every direct child's offsetTop shares its origin.
36 */
37export function createSidebarShiftGuard(list: HTMLElement) {
38 const view = list.ownerDocument.defaultView;
39 let tops = readTops(list);
40 const movedAt = new WeakMap<Element, number>();
41 let lastInputAt = Number.NEGATIVE_INFINITY;
42 // Pointers still down on a dropped press. Only their own release or cancel ends the drop, so
43 // a key or another pointer meanwhile cannot hand that press's click to the row.
44 const heldDrops = new Set<number>();
45 let pressDropped = false;
46 let releasedDrop: number | null = null;
47
48 const rowOf = (target: EventTarget | null) => {
49 let node = target instanceof Node ? target : null;
50 while (node !== null && node.parentNode !== list) node = node.parentNode;
51 return node instanceof Element ? node : null;
52 };
53 const swallow = (event: Event) => {
54 event.preventDefault();
55 event.stopPropagation();
56 };
57 const onPointerDown = (event: PointerEvent) => {
58 // A new press from the same pointer means its earlier release was missed.
59 heldDrops.delete(event.pointerId);
60 if (releasedDrop === event.pointerId) releasedDrop = null;
61 const now = performance.now();
62 const row = rowOf(event.target);
63 const moved = row === null ? undefined : movedAt.get(row);
64 if (moved !== undefined && now - moved < MOVED_ROW_PRESS_DELAY_MS) {
65 heldDrops.add(event.pointerId);
66 pressDropped = true;
67 swallow(event);
68 return;
69 }
70 pressDropped = false;
71 lastInputAt = now;
72 };
73 const onPointerUp = (event: PointerEvent) => {
74 const dropped = heldDrops.delete(event.pointerId);
75 releasedDrop = dropped ? event.pointerId : null;
76 pressDropped = dropped;
77 if (dropped) {
78 if (rowOf(event.target) !== null) swallow(event);
79 return;
80 }
81 lastInputAt = performance.now();
82 };
83 const onPointerCancel = (event: PointerEvent) => {
84 heldDrops.delete(event.pointerId);
85 };
86 const onKeyDown = (event: KeyboardEvent) => {
87 pressDropped = false;
88 if (!editsText(event)) lastInputAt = performance.now();
89 };
90 const onPressFollowUp = (event: MouseEvent) => {
91 // Keyboard activation clicks carry no click count; only pointer presses are dropped.
92 if (event.type === "click" && event.detail === 0) return;
93 const dropped =
94 event instanceof PointerEvent && event.pointerId >= 0
95 ? heldDrops.has(event.pointerId) || releasedDrop === event.pointerId
96 : pressDropped;
97 if (dropped && rowOf(event.target) !== null) swallow(event);
98 };
99
100 // Window capture runs before React and dnd-kit see the press.
101 view?.addEventListener("pointerdown", onPointerDown, { capture: true });
102 view?.addEventListener("pointerup", onPointerUp, { capture: true });
103 view?.addEventListener("pointercancel", onPointerCancel, { capture: true });
104 view?.addEventListener("keydown", onKeyDown, { capture: true });
105 for (const type of PRESS_FOLLOW_UPS) {
106 view?.addEventListener(type, onPressFollowUp, { capture: true });
107 }
108
109 return {
110 update() {
111 const next = readTops(list);
112 const now = performance.now();
113 // A list filling from empty slid nothing under the pointer.
114 const ownMove = tops.size === 0 || now - lastInputAt < OWN_INPUT_WINDOW_MS;
115 for (const [row, top] of next) {
116 const previous = tops.get(row);
117 if (previous === top) continue;
118 // A nudge of under half a row, such as a header settling, still leaves the row the pointer
119 // aimed at under it.
120 if (previous !== undefined && Math.abs(top - previous) * 2 < row.offsetHeight) continue;
121 // The latest move decides: one the user caused clears an earlier mark.
122 if (ownMove) movedAt.delete(row);
123 else movedAt.set(row, now);
124 }
125 tops = next;
126 },
127 dispose() {
128 view?.removeEventListener("pointerdown", onPointerDown, { capture: true });
129 view?.removeEventListener("pointerup", onPointerUp, { capture: true });
130 view?.removeEventListener("pointercancel", onPointerCancel, { capture: true });
131 view?.removeEventListener("keydown", onKeyDown, { capture: true });
132 for (const type of PRESS_FOLLOW_UPS) {
133 view?.removeEventListener(type, onPressFollowUp, { capture: true });
134 }
135 },
136 };
137}