| 1 | // Run: tsx src/__tests__/nested-scroll-handoff.test.ts |
| 2 | |
| 3 | import { JSDOM } from "jsdom"; |
| 4 | import { |
| 5 | attachNestedScrollHandoff, |
| 6 | canElementScrollVertically, |
| 7 | normalizeWheelDelta, |
| 8 | shouldHandoffVerticalWheel, |
| 9 | } from "../lib/nestedScrollHandoff"; |
| 10 | |
| 11 | let passed = 0; |
| 12 | let failed = 0; |
| 13 | |
| 14 | function ok(value: boolean, label: string) { |
| 15 | if (value) { |
| 16 | process.stdout.write(` PASS ${label}\n`); |
| 17 | passed += 1; |
| 18 | } else { |
| 19 | process.stdout.write(` FAIL ${label}\n`); |
| 20 | failed += 1; |
| 21 | } |
| 22 | } |
| 23 | |
| 24 | function eq(actual: unknown, expected: unknown, label: string) { |
| 25 | if (actual === expected) ok(true, label); |
| 26 | else ok(false, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); |
| 27 | } |
| 28 | |
| 29 | console.log("\nnested scroll handoff"); |
| 30 | |
| 31 | const dom = new JSDOM(`<!doctype html><html><body> |
| 32 | <div id="parent" style="height:200px;overflow-y:auto"> |
| 33 | <div id="nested" data-nested-scroll style="height:100px;overflow-y:auto"> |
| 34 | <div id="inner" style="height:400px">cell</div> |
| 35 | </div> |
| 36 | <div id="table-wrap" class="md-table-scroll" style="height:80px;overflow-x:auto;overflow-y:hidden"> |
| 37 | <div id="table-cell">wide table cell</div> |
| 38 | </div> |
| 39 | </div> |
| 40 | </body></html>`, { pretendToBeVisual: true }); |
| 41 | |
| 42 | globalThis.window = dom.window as unknown as Window & typeof globalThis; |
| 43 | globalThis.document = dom.window.document; |
| 44 | globalThis.HTMLElement = dom.window.HTMLElement; |
| 45 | globalThis.Element = dom.window.Element; |
| 46 | globalThis.WheelEvent = dom.window.WheelEvent; |
| 47 | |
| 48 | const parent = document.getElementById("parent") as HTMLElement; |
| 49 | const nested = document.getElementById("nested") as HTMLElement; |
| 50 | const inner = document.getElementById("inner") as HTMLElement; |
| 51 | const tableWrap = document.getElementById("table-wrap") as HTMLElement; |
| 52 | const tableCell = document.getElementById("table-cell") as HTMLElement; |
| 53 | |
| 54 | Object.defineProperty(nested, "clientHeight", { configurable: true, value: 100 }); |
| 55 | Object.defineProperty(nested, "scrollHeight", { configurable: true, value: 400 }); |
| 56 | let nestedTop = 0; |
| 57 | Object.defineProperty(nested, "scrollTop", { |
| 58 | configurable: true, |
| 59 | get: () => nestedTop, |
| 60 | set: (v: number) => { nestedTop = v; }, |
| 61 | }); |
| 62 | |
| 63 | Object.defineProperty(tableWrap, "clientHeight", { configurable: true, value: 80 }); |
| 64 | Object.defineProperty(tableWrap, "scrollHeight", { configurable: true, value: 80 }); |
| 65 | Object.defineProperty(tableWrap, "scrollTop", { configurable: true, value: 0 }); |
| 66 | |
| 67 | Object.defineProperty(parent, "clientHeight", { configurable: true, value: 200 }); |
| 68 | Object.defineProperty(parent, "scrollHeight", { configurable: true, value: 2000 }); |
| 69 | let parentTop = 100; |
| 70 | Object.defineProperty(parent, "scrollTop", { |
| 71 | configurable: true, |
| 72 | get: () => parentTop, |
| 73 | set: (v: number) => { parentTop = v; }, |
| 74 | }); |
| 75 | |
| 76 | // Mock computed styles for overflow detection. |
| 77 | const styleMap = new Map<Element, { overflowX: string; overflowY: string }>([ |
| 78 | [nested, { overflowX: "auto", overflowY: "auto" }], |
| 79 | [tableWrap, { overflowX: "auto", overflowY: "hidden" }], |
| 80 | [parent, { overflowX: "hidden", overflowY: "auto" }], |
| 81 | [inner, { overflowX: "visible", overflowY: "visible" }], |
| 82 | [tableCell, { overflowX: "visible", overflowY: "visible" }], |
| 83 | ]); |
| 84 | dom.window.getComputedStyle = ((el: Element) => { |
| 85 | const s = styleMap.get(el) ?? { overflowX: "visible", overflowY: "visible" }; |
| 86 | return s as unknown as CSSStyleDeclaration; |
| 87 | }) as typeof getComputedStyle; |
| 88 | globalThis.getComputedStyle = dom.window.getComputedStyle; |
| 89 | |
| 90 | nestedTop = 50; |
| 91 | ok(canElementScrollVertically(nested, -20), "nested can scroll up from mid-body"); |
| 92 | ok(canElementScrollVertically(nested, 20), "nested can scroll down from mid-body"); |
| 93 | nestedTop = 0; |
| 94 | ok(!canElementScrollVertically(nested, -20), "nested cannot scroll up at top"); |
| 95 | nestedTop = 300; |
| 96 | ok(!canElementScrollVertically(nested, 20), "nested cannot scroll down at bottom"); |
| 97 | ok(!canElementScrollVertically(tableWrap, 20), "horizontal-only wrapper cannot scroll vertically"); |
| 98 | |
| 99 | nestedTop = 0; |
| 100 | ok( |
| 101 | shouldHandoffVerticalWheel(inner, parent, -40), |
| 102 | "wheel-up at nested top should handoff to parent", |
| 103 | ); |
| 104 | nestedTop = 50; |
| 105 | ok( |
| 106 | !shouldHandoffVerticalWheel(inner, parent, -40), |
| 107 | "wheel-up mid nested should not handoff", |
| 108 | ); |
| 109 | ok( |
| 110 | shouldHandoffVerticalWheel(tableCell, parent, 40), |
| 111 | "wheel over horizontal-only table wrapper handoffs vertical to parent", |
| 112 | ); |
| 113 | |
| 114 | // Integration: attach listener and dispatch wheels. |
| 115 | nestedTop = 0; |
| 116 | parentTop = 100; |
| 117 | let intents = 0; |
| 118 | const handedOffDeltas: number[] = []; |
| 119 | let clock = 1_000; |
| 120 | const handoff = attachNestedScrollHandoff({ |
| 121 | parent, |
| 122 | onParentScrollIntent: (deltaY) => { |
| 123 | intents += 1; |
| 124 | handedOffDeltas.push(deltaY); |
| 125 | }, |
| 126 | writeParentOffset: (top) => { |
| 127 | parent.scrollTop = top; |
| 128 | return true; |
| 129 | }, |
| 130 | latchHoldMs: 200, |
| 131 | now: () => clock, |
| 132 | }); |
| 133 | |
| 134 | const wheelAt = (target: Element, deltaY: number, deltaMode: number = dom.window.WheelEvent.DOM_DELTA_PIXEL) => { |
| 135 | const event = new dom.window.WheelEvent("wheel", { |
| 136 | deltaY, |
| 137 | deltaX: 0, |
| 138 | deltaMode, |
| 139 | bubbles: true, |
| 140 | cancelable: true, |
| 141 | }); |
| 142 | target.dispatchEvent(event); |
| 143 | return event; |
| 144 | }; |
| 145 | |
| 146 | const edgeUp = wheelAt(inner, -40); |
| 147 | ok(edgeUp.defaultPrevented, "edge wheel is preventDefaulted"); |
| 148 | eq(parentTop, 60, "edge wheel promotes deltaY onto parent scrollTop"); |
| 149 | ok(intents >= 1, "edge handoff notifies parent scroll intent"); |
| 150 | eq(handedOffDeltas[0], -40, "edge handoff reports normalized direction to the parent controller"); |
| 151 | |
| 152 | // After an edge handoff, latch keeps driving the parent even if nested could scroll. |
| 153 | clock += 10; // still inside latchHoldMs |
| 154 | const afterLatch = parentTop; |
| 155 | nestedTop = 50; |
| 156 | const latched = wheelAt(inner, -30); |
| 157 | ok(latched.defaultPrevented, "latched gesture continues on parent"); |
| 158 | eq(parentTop, afterLatch - 30, "latched wheel keeps applying to parent"); |
| 159 | eq(handedOffDeltas[1], -30, "latched handoff keeps reporting normalized direction"); |
| 160 | |
| 161 | // Once the latch expires, mid-body nested scroll is left to the browser. |
| 162 | clock += 500; |
| 163 | const midTopBefore = parentTop; |
| 164 | nestedTop = 50; |
| 165 | const mid = wheelAt(inner, -20); |
| 166 | ok(!mid.defaultPrevented, "mid nested wheel is left to the browser after latch expires"); |
| 167 | eq(parentTop, midTopBefore, "mid nested wheel does not move parent after latch expires"); |
| 168 | |
| 169 | const lineDelta = normalizeWheelDelta({ deltaX: 0, deltaY: 2, deltaMode: dom.window.WheelEvent.DOM_DELTA_LINE }, parent); |
| 170 | eq(lineDelta.y, 32, "line-mode wheel deltas use a stable pixel fallback"); |
| 171 | const pageDelta = normalizeWheelDelta({ deltaX: 0, deltaY: 1, deltaMode: dom.window.WheelEvent.DOM_DELTA_PAGE }, parent); |
| 172 | eq(pageDelta.y, 200, "page-mode wheel deltas use the transcript viewport height"); |
| 173 | |
| 174 | clock += 500; |
| 175 | nestedTop = 0; |
| 176 | parentTop = 500; |
| 177 | wheelAt(inner, -2, dom.window.WheelEvent.DOM_DELTA_LINE); |
| 178 | eq(parentTop, 468, "line-mode handoff applies normalized pixels instead of raw lines"); |
| 179 | |
| 180 | clock += 500; |
| 181 | parentTop = 500; |
| 182 | wheelAt(inner, -1, dom.window.WheelEvent.DOM_DELTA_PAGE); |
| 183 | eq(parentTop, 300, "page-mode handoff applies one viewport page"); |
| 184 | |
| 185 | handoff.detach(); |
| 186 | dom.window.close(); |
| 187 | |
| 188 | console.log(`\n${passed} passed, ${failed} failed`); |
| 189 | if (failed > 0) process.exit(1); |
| 190 |