@dimina-kit/view-anchor 0.1.0-dev.20260618090552 → 0.1.0-dev.20260624040247
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/view-anchor.d.ts +2 -2
- package/dist/view-anchor.js +18 -18
- package/package.json +4 -2
- package/src/follow-geometry-press-drag.fix.test.ts +10 -13
- package/src/placement.test.ts +22 -25
- package/src/react.test.tsx +13 -17
- package/src/react.ts +1 -1
- package/src/view-anchor.test.ts +56 -69
- package/src/view-anchor.ts +20 -20
package/dist/react.d.ts
CHANGED
|
@@ -23,7 +23,7 @@ export type ViewAnchorRef = (el: HTMLElement | null) => void;
|
|
|
23
23
|
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
24
24
|
* `update`; on unmount → publish ZERO then `dispose`.
|
|
25
25
|
*
|
|
26
|
-
* Why ZERO on disappearance
|
|
26
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
27
27
|
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
28
28
|
* `dispose()` only stops observing — it deliberately never publishes again
|
|
29
29
|
* (its Contract 6/7). But the host only collapses the native view when it
|
package/dist/react.js
CHANGED
|
@@ -6,7 +6,7 @@ import { createViewAnchor } from './view-anchor.js';
|
|
|
6
6
|
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
7
7
|
* `update`; on unmount → publish ZERO then `dispose`.
|
|
8
8
|
*
|
|
9
|
-
* Why ZERO on disappearance
|
|
9
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
10
10
|
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
11
11
|
* `dispose()` only stops observing — it deliberately never publishes again
|
|
12
12
|
* (its Contract 6/7). But the host only collapses the native view when it
|
package/dist/view-anchor.d.ts
CHANGED
|
@@ -50,7 +50,7 @@ export interface PlacementAnchorOptions {
|
|
|
50
50
|
*/
|
|
51
51
|
guardDisplayNone?: boolean;
|
|
52
52
|
/**
|
|
53
|
-
* Opt-in capture-phase ancestor-scroll follow
|
|
53
|
+
* Opt-in capture-phase ancestor-scroll follow. When true, the anchor
|
|
54
54
|
* listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
|
|
55
55
|
* bubble, but reach `window` while capturing), so an ancestor scroll
|
|
56
56
|
* container scrolling the target re-measures and re-publishes. With
|
|
@@ -60,7 +60,7 @@ export interface PlacementAnchorOptions {
|
|
|
60
60
|
*/
|
|
61
61
|
followScroll?: boolean;
|
|
62
62
|
/**
|
|
63
|
-
* Opt-in windowed RAF geometry sentinel
|
|
63
|
+
* Opt-in windowed RAF geometry sentinel. Catches ancestor
|
|
64
64
|
* transform / reflow moves that no DOM event reports. The sentinel is
|
|
65
65
|
* NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
|
|
66
66
|
* splitter pointerdown, or an explicit `pulse()`), polls geometry once per
|
package/dist/view-anchor.js
CHANGED
|
@@ -173,7 +173,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
173
173
|
let io = null;
|
|
174
174
|
let lastPublished = null;
|
|
175
175
|
let disposed = false;
|
|
176
|
-
// ── Windowed RAF geometry sentinel state
|
|
176
|
+
// ── Windowed RAF geometry sentinel state ──────────────────
|
|
177
177
|
// The sentinel is a windowed poll, opened on demand and auto-closing once
|
|
178
178
|
// the geometry goes steady. It publishes IN-FRAME (no nested defer): each
|
|
179
179
|
// frame measures, and either publishes a changed rect synchronously or
|
|
@@ -183,12 +183,12 @@ export function createPlacementAnchor(target, opts) {
|
|
|
183
183
|
let steadyFrames = 0;
|
|
184
184
|
const STEADY_CLOSE_FRAMES = 2;
|
|
185
185
|
// True while a [role="separator"] splitter drag is in progress: set on the
|
|
186
|
-
// capture-phase pointerdown that opened the window, cleared on pointerup
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
186
|
+
// capture-phase pointerdown that opened the window, cleared on pointerup.
|
|
187
|
+
// A held pointer means the drag may still resume after a static pause, so a
|
|
188
|
+
// steady run while held must NOT close the sentinel — it only closes once the
|
|
189
|
+
// pointer is released. Without this gate a press that pauses a couple of
|
|
190
|
+
// frames before the drag actually moves would close mid-press and drop the
|
|
191
|
+
// entire subsequent drag.
|
|
192
192
|
let pointerHeld = false;
|
|
193
193
|
// Absolute time (performance.now()) past which a `pulse(durationMs)` window
|
|
194
194
|
// force-closes even if the geometry is still changing — the upper bound that
|
|
@@ -227,7 +227,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
227
227
|
sentinelDeadline = null;
|
|
228
228
|
return;
|
|
229
229
|
}
|
|
230
|
-
//
|
|
230
|
+
// Upper bound: a pulse window past its deadline closes regardless of
|
|
231
231
|
// motion, so a target that changes every frame can't keep the sentinel alive.
|
|
232
232
|
if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
|
|
233
233
|
sentinelDeadline = null;
|
|
@@ -236,7 +236,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
236
236
|
const p = computePlacement();
|
|
237
237
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
238
238
|
steadyFrames++;
|
|
239
|
-
// Steady-close only fires once the pointer is RELEASED
|
|
239
|
+
// Steady-close only fires once the pointer is RELEASED: while a
|
|
240
240
|
// splitter drag is held, a static pause is a hesitation, not the end of
|
|
241
241
|
// the drag, so we keep polling (re-arm below) and let `steadyFrames`
|
|
242
242
|
// accrue — it converges to a close within N frames after pointerup.
|
|
@@ -251,7 +251,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
251
251
|
steadyFrames = 0;
|
|
252
252
|
}
|
|
253
253
|
// `publish` may have synchronously disposed (or hidden) the anchor; re-read
|
|
254
|
-
// live state so a re-entrant teardown leaves ZERO scheduled frames
|
|
254
|
+
// live state so a re-entrant teardown leaves ZERO scheduled frames.
|
|
255
255
|
if (!disposed && visible) {
|
|
256
256
|
rafId = requestAnimationFrame(sentinelFrame); // keep polling
|
|
257
257
|
}
|
|
@@ -275,18 +275,18 @@ export function createPlacementAnchor(target, opts) {
|
|
|
275
275
|
sentinelDeadline = null;
|
|
276
276
|
pointerHeld = false;
|
|
277
277
|
};
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
// without it, a single synchronous emit() follows the new rect
|
|
278
|
+
// An ancestor scroll moved the target's screen rect. With the sentinel on,
|
|
279
|
+
// open the window so the whole scroll burst is followed frame-by-frame;
|
|
280
|
+
// without it, a single synchronous emit() follows the new rect.
|
|
281
281
|
const onScroll = () => {
|
|
282
282
|
if (followGeometry)
|
|
283
283
|
openSentinel();
|
|
284
284
|
else
|
|
285
285
|
emit();
|
|
286
286
|
};
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
287
|
+
// A capture-phase pointerdown on a [role="separator"] splitter handle marks
|
|
288
|
+
// the start of a drag that moves the target via ancestor reflow (no RO tick)
|
|
289
|
+
// → open the sentinel.
|
|
290
290
|
const onPointerDown = (e) => {
|
|
291
291
|
const t = e.target;
|
|
292
292
|
if (t && t.closest && t.closest('[role="separator"]')) {
|
|
@@ -294,7 +294,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
294
294
|
openSentinel();
|
|
295
295
|
}
|
|
296
296
|
};
|
|
297
|
-
//
|
|
297
|
+
// Pointer released: the drag is over, so a steady run may now close the
|
|
298
298
|
// sentinel. Re-open it (a no-op if already polling) so the steady-close
|
|
299
299
|
// threshold is reached even if the geometry was already static at release.
|
|
300
300
|
const onPointerUp = () => {
|
|
@@ -378,7 +378,7 @@ export function createPlacementAnchor(target, opts) {
|
|
|
378
378
|
stopObserving();
|
|
379
379
|
},
|
|
380
380
|
pulse(durationMs) {
|
|
381
|
-
// Imperative window open
|
|
381
|
+
// Imperative window open: start the animation-follow window. It
|
|
382
382
|
// closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
|
|
383
383
|
// at that deadline — whichever comes first. The deadline is the upper bound
|
|
384
384
|
// that guarantees a still-animating target cannot keep the sentinel
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dimina-kit/view-anchor",
|
|
3
|
-
"version": "0.1.0-dev.
|
|
3
|
+
"version": "0.1.0-dev.20260624040247",
|
|
4
4
|
"description": "Engine-agnostic primitive that keeps a main-process native view (Electron WebContentsView) aligned to a DOM element's geometry.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dimina",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"@testing-library/react": "^16.3.2",
|
|
53
53
|
"@types/react": "^18.3.12",
|
|
54
54
|
"@vitejs/plugin-react": "^6.0.1",
|
|
55
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
55
56
|
"esbuild": "^0.28.0",
|
|
56
57
|
"eslint": "^10.2.1",
|
|
57
58
|
"jsdom": "^29.0.2",
|
|
@@ -71,6 +72,7 @@
|
|
|
71
72
|
"check-types": "tsc --noEmit",
|
|
72
73
|
"lint": "eslint . --max-warnings 0",
|
|
73
74
|
"test": "vitest run",
|
|
74
|
-
"test:dev": "vitest"
|
|
75
|
+
"test:dev": "vitest",
|
|
76
|
+
"test:coverage": "vitest run --coverage"
|
|
75
77
|
}
|
|
76
78
|
}
|
|
@@ -5,23 +5,20 @@ import type { Placement } from './types.js'
|
|
|
5
5
|
// ── press-pause-drag: the windowed RAF geometry sentinel must NOT close
|
|
6
6
|
// while the pointer is still held down ───────────────────────────────
|
|
7
7
|
//
|
|
8
|
-
//
|
|
9
|
-
// `followGeometry` sentinel (view-anchor-following.md §2.D / §6).
|
|
8
|
+
// Locks the press-pause-drag behaviour of the `followGeometry` sentinel.
|
|
10
9
|
//
|
|
11
|
-
// The
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// drags) closes the window mid-press, and the entire subsequent drag is
|
|
18
|
-
// dropped (the native view freezes at its pre-drag position).
|
|
10
|
+
// The windowed sentinel only closes on *steady* frames AFTER release
|
|
11
|
+
// (pointerup), NOT after N consecutive identical frames unconditionally — a
|
|
12
|
+
// press that pauses before the drag actually starts (very common: user clicks
|
|
13
|
+
// the splitter, hesitates a frame or two, THEN drags) must not close the
|
|
14
|
+
// window mid-press and drop the entire subsequent drag (freezing the native
|
|
15
|
+
// view at its pre-drag position).
|
|
19
16
|
//
|
|
20
17
|
// This file pins:
|
|
21
18
|
// 1. press-pause-drag: pointerdown → ≥2 static frames (current close
|
|
22
19
|
// threshold) → rect starts moving → the movement is STILL followed.
|
|
23
|
-
//
|
|
24
|
-
// drag frames find
|
|
20
|
+
// The pointerHeld gate keeps the sentinel open through the static pause
|
|
21
|
+
// so the later drag frames still find a scheduled rAF.
|
|
25
22
|
// 2. no-regression: pointerup → static frames → the sentinel still
|
|
26
23
|
// eventually closes (we must not "fix" #1 by keeping the window open
|
|
27
24
|
// forever / spinning a rAF while idle).
|
|
@@ -124,7 +121,7 @@ function buildElement(rect: { x: number; y: number; w: number; h: number }): {
|
|
|
124
121
|
}
|
|
125
122
|
|
|
126
123
|
/** A `[role="separator"]` splitter (the drag handle): a capture-phase
|
|
127
|
-
* pointerdown matching it opens the sentinel window
|
|
124
|
+
* pointerdown matching it opens the sentinel window. */
|
|
128
125
|
function buildSplitter(): HTMLElement {
|
|
129
126
|
const sep = document.createElement('div')
|
|
130
127
|
sep.setAttribute('role', 'separator')
|
package/src/placement.test.ts
CHANGED
|
@@ -1,37 +1,35 @@
|
|
|
1
1
|
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
|
|
2
2
|
|
|
3
3
|
// ─────────────────────────────────────────────────────────────────────
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// `
|
|
7
|
-
// convention
|
|
8
|
-
//
|
|
9
|
-
//
|
|
4
|
+
// Tests for the explicit `Placement` API, the alternative to encoding
|
|
5
|
+
// "hidden" as the magic geometric value `{x:0,y:0,width:0,height:0}` (the
|
|
6
|
+
// `present:false → ZERO bounds` convention in types.ts/view-anchor.ts). That
|
|
7
|
+
// ZERO convention leaks a compositor/host concept ("detach the view") into the
|
|
8
|
+
// geometry layer, and is ambiguous: a view that is genuinely 0-wide is
|
|
9
|
+
// indistinguishable from a hidden one.
|
|
10
10
|
//
|
|
11
|
-
//
|
|
11
|
+
// The replacement contract:
|
|
12
12
|
//
|
|
13
13
|
// type Placement =
|
|
14
14
|
// | { visible: true; bounds: Bounds }
|
|
15
15
|
// | { visible: false }
|
|
16
16
|
//
|
|
17
17
|
// measure produces a `Placement`; the sink consumes a `Placement`.
|
|
18
|
-
// Visibility is EXPLICIT (a discriminant), never inferred from a 0.
|
|
18
|
+
// Visibility is EXPLICIT (a discriminant), never inferred from a 0. The
|
|
19
|
+
// explicit API NEVER reintroduces a ZERO-bounds path for "hidden".
|
|
19
20
|
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// it throws) rather than a whole-file compile error that vitest would skip.
|
|
25
|
-
// The implementer makes them GREEN by adding the explicit API — WITHOUT
|
|
26
|
-
// reintroducing any ZERO-bounds path for "hidden".
|
|
21
|
+
// The symbols (`measurePlacement`, `createPlacementAnchor`, `Placement`) are
|
|
22
|
+
// imported dynamically so a missing export surfaces as a runtime/assertion
|
|
23
|
+
// failure (an `undefined` that throws when called) rather than a whole-file
|
|
24
|
+
// compile error that vitest would skip.
|
|
27
25
|
// ─────────────────────────────────────────────────────────────────────
|
|
28
26
|
|
|
29
27
|
import type { Bounds } from './types.js'
|
|
30
28
|
|
|
31
|
-
// The explicit API is imported through this indirection so that a
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
29
|
+
// The explicit API is imported through this indirection so that a missing
|
|
30
|
+
// export is observable as `undefined` at runtime (→ a failing assertion /
|
|
31
|
+
// TypeError), not a static "module has no exported member" compile error that
|
|
32
|
+
// would prevent the whole suite from running.
|
|
35
33
|
import * as viewAnchorModule from './view-anchor.js'
|
|
36
34
|
|
|
37
35
|
// The shape the explicit API must produce/consume. Mirrored locally (not
|
|
@@ -128,11 +126,10 @@ function buildElement(rect: {
|
|
|
128
126
|
}
|
|
129
127
|
}
|
|
130
128
|
|
|
131
|
-
// ── Resolve the
|
|
129
|
+
// ── Resolve the explicit API at call time ─────────
|
|
132
130
|
//
|
|
133
|
-
// Each
|
|
134
|
-
//
|
|
135
|
-
// a RED behavioural failure, which is what TDD wants here.
|
|
131
|
+
// Each helper asserts the export is a function before calling it, so an absent
|
|
132
|
+
// export surfaces as a clear assertion failure rather than a TypeError.
|
|
136
133
|
|
|
137
134
|
interface MaybeModule {
|
|
138
135
|
// measurePlacement(target): Placement — pure measure, no IPC.
|
|
@@ -154,7 +151,7 @@ function measurePlacement(target: HTMLElement): ExpectedPlacement {
|
|
|
154
151
|
const fn = mod.measurePlacement
|
|
155
152
|
expect(
|
|
156
153
|
typeof fn,
|
|
157
|
-
'explicit API `measurePlacement`
|
|
154
|
+
'explicit API `measurePlacement` must be a function export from view-anchor',
|
|
158
155
|
).toBe('function')
|
|
159
156
|
return fn!(target)
|
|
160
157
|
}
|
|
@@ -169,7 +166,7 @@ function createPlacementAnchor(
|
|
|
169
166
|
const fn = mod.createPlacementAnchor
|
|
170
167
|
expect(
|
|
171
168
|
typeof fn,
|
|
172
|
-
'explicit API `createPlacementAnchor`
|
|
169
|
+
'explicit API `createPlacementAnchor` must be a function export from view-anchor',
|
|
173
170
|
).toBe('function')
|
|
174
171
|
return fn!(target, opts)
|
|
175
172
|
}
|
package/src/react.test.tsx
CHANGED
|
@@ -160,10 +160,10 @@ describe('useViewAnchor — ref attach', () => {
|
|
|
160
160
|
// the leaf div conditionally rendered), failing to dispose leaks the RO
|
|
161
161
|
// and keeps publishing against a detached element.
|
|
162
162
|
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
163
|
+
// This contract ALSO requires emitting one ZERO on detach. The anchor's
|
|
164
|
+
// follower is a main-process WebContentsView; the host only collapses it on
|
|
165
|
+
// `{0,0,0,0}`. Without a ZERO the native view stays frozen at its last bounds
|
|
166
|
+
// and occludes content. See `react.ts`.
|
|
167
167
|
|
|
168
168
|
describe('useViewAnchor — ref null disposes', () => {
|
|
169
169
|
it('detaching the DOM node publishes ZERO once, disconnects the observer, and stops publishing', () => {
|
|
@@ -195,11 +195,9 @@ describe('useViewAnchor — ref null disposes', () => {
|
|
|
195
195
|
})
|
|
196
196
|
expect(ro.disconnected).toBe(true)
|
|
197
197
|
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
// correct behaviour is: a vanished anchor MUST publish exactly one ZERO so
|
|
202
|
-
// the host collapses the native view.
|
|
198
|
+
// A vanished anchor MUST publish exactly one ZERO so the host collapses the
|
|
199
|
+
// native view — detach publishing nothing would strand the native
|
|
200
|
+
// WebContentsView at its old bounds.
|
|
203
201
|
expect(publish).toHaveBeenCalledTimes(1)
|
|
204
202
|
expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
|
|
205
203
|
publish.mockClear()
|
|
@@ -296,9 +294,9 @@ describe('useViewAnchor — opts/deps change re-publishes', () => {
|
|
|
296
294
|
// Bug it catches: a hook that does not dispose on unmount leaks the RO and
|
|
297
295
|
// can throw when a queued RAF fires against a torn-down IPC channel.
|
|
298
296
|
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
297
|
+
// Unmount must ALSO collapse the native view with one ZERO (same reasoning as
|
|
298
|
+
// Contract 9 — the follower is a main-process WebContentsView the host only
|
|
299
|
+
// collapses on `{0,0,0,0}`).
|
|
302
300
|
|
|
303
301
|
describe('useViewAnchor — unmount disposes', () => {
|
|
304
302
|
it('unmounting the component publishes ZERO once, disconnects the observer, and never publishes after', () => {
|
|
@@ -318,10 +316,8 @@ describe('useViewAnchor — unmount disposes', () => {
|
|
|
318
316
|
|
|
319
317
|
expect(ro.disconnected).toBe(true)
|
|
320
318
|
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// native view stranded. Correct behaviour: unmount publishes exactly one
|
|
324
|
-
// ZERO to collapse the native view.
|
|
319
|
+
// Unmount publishes exactly one ZERO to collapse the native view —
|
|
320
|
+
// publishing nothing would leave the native view stranded.
|
|
325
321
|
expect(publish).toHaveBeenCalledTimes(1)
|
|
326
322
|
expect(publish).toHaveBeenCalledWith({ x: 0, y: 0, width: 0, height: 0 })
|
|
327
323
|
publish.mockClear()
|
|
@@ -380,7 +376,7 @@ describe('useViewAnchor — independent instances', () => {
|
|
|
380
376
|
})
|
|
381
377
|
})
|
|
382
378
|
|
|
383
|
-
// ── Remount with present transition
|
|
379
|
+
// ── Remount with present transition ──────────────
|
|
384
380
|
// Production coupling: the debug cell is *unmounted* when hidden and *remounted*
|
|
385
381
|
// when shown, so the element's mount/unmount and `options.present` flip together
|
|
386
382
|
// (present=false ⟺ unmounted, present=true ⟺ mounted). On "show", React commits
|
package/src/react.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type ViewAnchorRef = (el: HTMLElement | null) => void
|
|
|
28
28
|
* detach (`null`) → publish ZERO then `dispose()`; on `opts`/`deps` change →
|
|
29
29
|
* `update`; on unmount → publish ZERO then `dispose`.
|
|
30
30
|
*
|
|
31
|
-
* Why ZERO on disappearance
|
|
31
|
+
* Why ZERO on disappearance: the anchor's follower is a *main-process*
|
|
32
32
|
* `WebContentsView`, not a DOM node. When the anchored element vanishes, core
|
|
33
33
|
* `dispose()` only stops observing — it deliberately never publishes again
|
|
34
34
|
* (its Contract 6/7). But the host only collapses the native view when it
|
package/src/view-anchor.test.ts
CHANGED
|
@@ -499,12 +499,12 @@ void _boundsShape
|
|
|
499
499
|
|
|
500
500
|
// ── display:none / first-frame guard (opt-in) ───────────────────────
|
|
501
501
|
//
|
|
502
|
-
//
|
|
503
|
-
// `
|
|
504
|
-
//
|
|
502
|
+
// An ADDITIVE, OPT-IN hardening of `createPlacementAnchor`, gated behind the
|
|
503
|
+
// `guardDisplayNone?: boolean` option (default false = the unguarded
|
|
504
|
+
// behaviour).
|
|
505
505
|
//
|
|
506
|
-
// Two
|
|
507
|
-
//
|
|
506
|
+
// Two behaviours, ONLY when `guardDisplayNone: true`:
|
|
507
|
+
// First-frame guard — `visible:true` but the measured rect has
|
|
508
508
|
// `width === 0 || height === 0` (no geometry box: unmounted /
|
|
509
509
|
// display:none / unstable first layout) → publish `{ visible:false }`
|
|
510
510
|
// (a detach, NO bounds) instead of `{ visible:true, bounds:{...,0,0} }`.
|
|
@@ -512,27 +512,22 @@ void _boundsShape
|
|
|
512
512
|
// the native view flashing at (0,0). NOTE: only ZERO WIDTH OR HEIGHT
|
|
513
513
|
// triggers this — a NON-zero box at position (0,0) is still a normal
|
|
514
514
|
// `{ visible:true }`.
|
|
515
|
-
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
// `
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
// `{ visible:true, bounds }`.
|
|
515
|
+
// display:none guard — the anchor attaches an `IntersectionObserver` on the
|
|
516
|
+
// target. When the target goes display:none (IO reports
|
|
517
|
+
// `isIntersecting:false` with a zero-area `boundingClientRect`) → publish
|
|
518
|
+
// `{ visible:false }`. When it comes back with a non-zero box (IO
|
|
519
|
+
// intersecting again / a subsequent measure yields non-zero
|
|
520
|
+
// width&height) → re-measure → publish `{ visible:true, bounds }`.
|
|
522
521
|
//
|
|
523
|
-
// Caller intent still wins
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
-
//
|
|
522
|
+
// Caller intent still wins: a caller-`visible:false` anchor is detached
|
|
523
|
+
// regardless — the guard NEVER flips a caller's `visible:false` to true, and
|
|
524
|
+
// NEVER detaches purely because a NON-zero box scrolled off-screen (a non-zero
|
|
525
|
+
// box at a negative/off-screen origin stays `visible:true` with its real,
|
|
526
|
+
// possibly negative-origin, bounds).
|
|
528
527
|
//
|
|
529
|
-
// This
|
|
530
|
-
// "never schedules a requestAnimationFrame" guard
|
|
531
|
-
//
|
|
532
|
-
//
|
|
533
|
-
// These tests must be RED today: `guardDisplayNone` is not implemented, so
|
|
534
|
-
// no IntersectionObserver is wired and a 0×0 visible measure still publishes
|
|
535
|
-
// `{ visible:true, bounds:0×0 }`.
|
|
528
|
+
// This guard adds an IntersectionObserver, NOT a RAF — so the existing
|
|
529
|
+
// "never schedules a requestAnimationFrame" guard stays valid. We assert it
|
|
530
|
+
// here too (`rafSpy`/`cancelSpy` from the shared harness).
|
|
536
531
|
|
|
537
532
|
// A controllable fake IntersectionObserver (jsdom has none). Mirrors the
|
|
538
533
|
// FakeResizeObserver style: records observed elements + disconnect, captures
|
|
@@ -762,33 +757,26 @@ describe('createPlacementAnchor — display:none / first-frame guard (opt-in)',
|
|
|
762
757
|
})
|
|
763
758
|
})
|
|
764
759
|
|
|
765
|
-
// ── scroll + windowed RAF geometry sentinel (opt-in
|
|
760
|
+
// ── scroll + windowed RAF geometry sentinel (opt-in) ────
|
|
766
761
|
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
// §2.C / §2.D / §2.F / §6 / §7. Both default OFF (= today's behaviour).
|
|
762
|
+
// ADDITIVE, OPT-IN follow options on `createPlacementAnchor`. Both default
|
|
763
|
+
// OFF (= the unfollowed behaviour).
|
|
770
764
|
//
|
|
771
|
-
// followScroll?: boolean —
|
|
772
|
-
// followGeometry?: boolean —
|
|
773
|
-
// pulse(durationMs?) —
|
|
765
|
+
// followScroll?: boolean — ancestor-scroll capture listener.
|
|
766
|
+
// followGeometry?: boolean — windowed RAF geometry sentinel.
|
|
767
|
+
// pulse(durationMs?) — imperative "open the sentinel window".
|
|
774
768
|
//
|
|
775
769
|
// ────────────────────────────────────────────────────────────────────
|
|
776
|
-
//
|
|
777
|
-
//
|
|
778
|
-
//
|
|
779
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
// `
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
// tests below re-pin that the DEFAULT / event-driven paths still schedule
|
|
786
|
-
// NO rAF, so the original guarantee holds wherever the sentinel is off.
|
|
770
|
+
// The package-wide "never RAF" invariant is INTENTIONALLY NARROWED by
|
|
771
|
+
// `followGeometry` (opt-in) to "EVENT-driven publishes (ResizeObserver /
|
|
772
|
+
// window resize / scroll) are never RAF-deferred". The sentinel rAF is a
|
|
773
|
+
// POLLING mechanism that publishes SYNCHRONOUSLY within its own frame — it is
|
|
774
|
+
// NOT a deferral of an event-driven publish. The `createViewAnchor` "never
|
|
775
|
+
// schedules a requestAnimationFrame" test (above) stays valid, because
|
|
776
|
+
// `followGeometry` defaults off and the forward `createViewAnchor` core never
|
|
777
|
+
// opts in. The tests below re-pin that the DEFAULT / event-driven paths still
|
|
778
|
+
// schedule NO rAF, so the guarantee holds wherever the sentinel is off.
|
|
787
779
|
// ────────────────────────────────────────────────────────────────────
|
|
788
|
-
//
|
|
789
|
-
// These tests must be RED today: `followScroll` / `followGeometry` /
|
|
790
|
-
// `pulse` are unimplemented, so no capture scroll listener / pointerdown
|
|
791
|
-
// listener / RAF sentinel exists.
|
|
792
780
|
|
|
793
781
|
// A CONTROLLABLE fake requestAnimationFrame. The shared `rafSpy` only
|
|
794
782
|
// returns 0 and stores nothing (it's a regression guard, not a driver), so
|
|
@@ -842,7 +830,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
842
830
|
|
|
843
831
|
/** Build a `[role="separator"]` splitter element (the drag handle). A
|
|
844
832
|
* capture-phase pointerdown whose target matches `[role="separator"]`
|
|
845
|
-
* opens the sentinel window
|
|
833
|
+
* opens the sentinel window. */
|
|
846
834
|
function buildSplitter(): HTMLElement {
|
|
847
835
|
const sep = document.createElement('div')
|
|
848
836
|
sep.setAttribute('role', 'separator')
|
|
@@ -851,7 +839,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
851
839
|
}
|
|
852
840
|
|
|
853
841
|
/** Dispatch a capture-phase scroll on window (an ancestor scroll container
|
|
854
|
-
* scrolling —
|
|
842
|
+
* scrolling — scroll doesn't bubble, but reaches window in capture). */
|
|
855
843
|
function dispatchCaptureScroll(): void {
|
|
856
844
|
window.dispatchEvent(new Event('scroll'))
|
|
857
845
|
}
|
|
@@ -864,8 +852,8 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
864
852
|
}
|
|
865
853
|
|
|
866
854
|
/** Pointer release — ends a splitter drag so the sentinel may steady-close
|
|
867
|
-
* (
|
|
868
|
-
* window directly so a capture/bubble window listener sees it. */
|
|
855
|
+
* (close only happens after release). Dispatched bubbling from the target
|
|
856
|
+
* and on window directly so a capture/bubble window listener sees it. */
|
|
869
857
|
function dispatchPointerup(target: HTMLElement): void {
|
|
870
858
|
target.dispatchEvent(new Event('pointerup', { bubbles: true }))
|
|
871
859
|
window.dispatchEvent(new Event('pointerup'))
|
|
@@ -888,12 +876,11 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
888
876
|
): PulseHandle =>
|
|
889
877
|
createPlacementAnchor(el, o as FollowOpts) as PulseHandle
|
|
890
878
|
|
|
891
|
-
// ── A. followScroll — capture-phase ancestor scroll
|
|
879
|
+
// ── A. followScroll — capture-phase ancestor scroll ──────────
|
|
892
880
|
|
|
893
881
|
// A1: capture scroll re-publishes the freshly-measured rect (basic scroll
|
|
894
|
-
// follow works WITHOUT the geometry sentinel —
|
|
895
|
-
//
|
|
896
|
-
// emit()").
|
|
882
|
+
// follow works WITHOUT the geometry sentinel — with followGeometry off,
|
|
883
|
+
// the scroll callback still does a single synchronous emit()).
|
|
897
884
|
it('A1) followScroll (followGeometry off): a capture-phase scroll re-publishes the new measured rect synchronously', () => {
|
|
898
885
|
const publish = vi.fn<(p: Placement) => void>()
|
|
899
886
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
@@ -960,8 +947,8 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
960
947
|
removeSpy.mockRestore()
|
|
961
948
|
})
|
|
962
949
|
|
|
963
|
-
// A3:
|
|
964
|
-
//
|
|
950
|
+
// A3: a followScroll capture-scroll OPENS the RAF sentinel window when
|
|
951
|
+
// followGeometry is ALSO on (so it follows every frame of a scroll
|
|
965
952
|
// burst, not just the one synchronous emit).
|
|
966
953
|
it('A3) followScroll + followGeometry: a capture scroll opens the RAF sentinel (a frame becomes scheduled)', () => {
|
|
967
954
|
const publish = vi.fn<(p: Placement) => void>()
|
|
@@ -982,9 +969,9 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
982
969
|
expect(raf.pending).toBeGreaterThanOrEqual(1)
|
|
983
970
|
})
|
|
984
971
|
|
|
985
|
-
// ── B. followGeometry — windowed RAF geometry sentinel
|
|
972
|
+
// ── B. followGeometry — windowed RAF geometry sentinel ─────
|
|
986
973
|
|
|
987
|
-
// B-idle:
|
|
974
|
+
// B-idle: IDLE (no scroll / pointerdown / pulse) schedules NO rAF.
|
|
988
975
|
// The sentinel is windowed: static cost is exactly zero.
|
|
989
976
|
it('B-idle) followGeometry on but idle: NO rAF is ever scheduled (windowed = zero static cost)', () => {
|
|
990
977
|
const publish = vi.fn<(p: Placement) => void>()
|
|
@@ -1064,10 +1051,10 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1064
1051
|
})
|
|
1065
1052
|
|
|
1066
1053
|
// B-close: after the pointer is RELEASED, N=2 consecutive UNCHANGED frames
|
|
1067
|
-
// cancel the rAF (steady = stop) — no further frame scheduled.
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1054
|
+
// cancel the rAF (steady = stop) — no further frame scheduled. A steady run
|
|
1055
|
+
// while the pointer is still HELD is a mid-drag pause and must NOT close
|
|
1056
|
+
// (see follow-geometry-press-drag.fix.test.ts); close is gated on pointerup,
|
|
1057
|
+
// so this test releases before going steady.
|
|
1071
1058
|
it('B-close) pointerup then N=2 consecutive unchanged frames → sentinel stops (cancelAnimationFrame / no further frame scheduled)', () => {
|
|
1072
1059
|
const publish = vi.fn<(p: Placement) => void>()
|
|
1073
1060
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
@@ -1095,7 +1082,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1095
1082
|
expect(raf.request.mock.calls.length).toBe(requestsBefore)
|
|
1096
1083
|
})
|
|
1097
1084
|
|
|
1098
|
-
// ── F. pulse() — explicit window open
|
|
1085
|
+
// ── F. pulse() — explicit window open ────────────────────────
|
|
1099
1086
|
|
|
1100
1087
|
// F-open: pulse() opens the sentinel and it follows subsequent rect changes.
|
|
1101
1088
|
it('F-open) pulse() opens the sentinel; it follows rect changes on subsequent frames', () => {
|
|
@@ -1137,13 +1124,13 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1137
1124
|
expect(raf.request.mock.calls.length).toBe(requestsBefore)
|
|
1138
1125
|
})
|
|
1139
1126
|
|
|
1140
|
-
// ──
|
|
1141
|
-
//
|
|
1127
|
+
// ── Event-driven publishes stay SYNCHRONOUS even with the sentinel enabled;
|
|
1128
|
+
// the sentinel rAF publishes in-frame, not deferred. ──
|
|
1142
1129
|
|
|
1143
1130
|
// B-sync: a ResizeObserver tick still publishes SYNCHRONOUSLY (in the event
|
|
1144
1131
|
// stack) WITHOUT scheduling a rAF, even with followGeometry enabled. This
|
|
1145
|
-
// is the
|
|
1146
|
-
it('
|
|
1132
|
+
// is the narrowing made concrete: event-driven ≠ RAF-deferred.
|
|
1133
|
+
it('with followGeometry ENABLED, a ResizeObserver tick publishes synchronously and schedules NO rAF', () => {
|
|
1147
1134
|
const publish = vi.fn<(p: Placement) => void>()
|
|
1148
1135
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1149
1136
|
mk(el, { visible: true, followGeometry: true, publish })
|
|
@@ -1158,7 +1145,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1158
1145
|
visible: true,
|
|
1159
1146
|
bounds: { x: 3, y: 4, width: 100, height: 100 },
|
|
1160
1147
|
})
|
|
1161
|
-
// …and the event-driven path did NOT route through a rAF (the
|
|
1148
|
+
// …and the event-driven path did NOT route through a rAF (the invariant
|
|
1162
1149
|
// survives, narrowed: event-driven publishes are never RAF-deferred).
|
|
1163
1150
|
expect(raf.request).not.toHaveBeenCalled()
|
|
1164
1151
|
})
|
|
@@ -1166,7 +1153,7 @@ describe('createPlacementAnchor — scroll + windowed RAF geometry sentinel (opt
|
|
|
1166
1153
|
// Default-path "still no rAF": with followGeometry OFF / unset, NOTHING in
|
|
1167
1154
|
// the increment-2 surface schedules a rAF — the original package-wide
|
|
1168
1155
|
// "never RAF" guarantee holds for the default + event-driven paths.
|
|
1169
|
-
it('
|
|
1156
|
+
it('followGeometry OFF (default): RO + resize + scroll-less lifecycle schedules NO rAF', () => {
|
|
1170
1157
|
const publish = vi.fn<(p: Placement) => void>()
|
|
1171
1158
|
const { el, setRect } = buildElement({ x: 0, y: 0, w: 100, h: 100 })
|
|
1172
1159
|
const handle = mk(el, { visible: true, publish }) // followGeometry unset
|
package/src/view-anchor.ts
CHANGED
|
@@ -166,7 +166,7 @@ export interface PlacementAnchorOptions {
|
|
|
166
166
|
*/
|
|
167
167
|
guardDisplayNone?: boolean
|
|
168
168
|
/**
|
|
169
|
-
* Opt-in capture-phase ancestor-scroll follow
|
|
169
|
+
* Opt-in capture-phase ancestor-scroll follow. When true, the anchor
|
|
170
170
|
* listens for `scroll` on `window` in the CAPTURE phase (scroll events don't
|
|
171
171
|
* bubble, but reach `window` while capturing), so an ancestor scroll
|
|
172
172
|
* container scrolling the target re-measures and re-publishes. With
|
|
@@ -176,7 +176,7 @@ export interface PlacementAnchorOptions {
|
|
|
176
176
|
*/
|
|
177
177
|
followScroll?: boolean
|
|
178
178
|
/**
|
|
179
|
-
* Opt-in windowed RAF geometry sentinel
|
|
179
|
+
* Opt-in windowed RAF geometry sentinel. Catches ancestor
|
|
180
180
|
* transform / reflow moves that no DOM event reports. The sentinel is
|
|
181
181
|
* NON-resident: it is OPENED on demand (a scroll burst, a `[role="separator"]`
|
|
182
182
|
* splitter pointerdown, or an explicit `pulse()`), polls geometry once per
|
|
@@ -262,7 +262,7 @@ export function createPlacementAnchor(
|
|
|
262
262
|
let lastPublished: Placement | null = null
|
|
263
263
|
let disposed = false
|
|
264
264
|
|
|
265
|
-
// ── Windowed RAF geometry sentinel state
|
|
265
|
+
// ── Windowed RAF geometry sentinel state ──────────────────
|
|
266
266
|
// The sentinel is a windowed poll, opened on demand and auto-closing once
|
|
267
267
|
// the geometry goes steady. It publishes IN-FRAME (no nested defer): each
|
|
268
268
|
// frame measures, and either publishes a changed rect synchronously or
|
|
@@ -272,12 +272,12 @@ export function createPlacementAnchor(
|
|
|
272
272
|
let steadyFrames = 0
|
|
273
273
|
const STEADY_CLOSE_FRAMES = 2
|
|
274
274
|
// True while a [role="separator"] splitter drag is in progress: set on the
|
|
275
|
-
// capture-phase pointerdown that opened the window, cleared on pointerup
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
//
|
|
280
|
-
//
|
|
275
|
+
// capture-phase pointerdown that opened the window, cleared on pointerup.
|
|
276
|
+
// A held pointer means the drag may still resume after a static pause, so a
|
|
277
|
+
// steady run while held must NOT close the sentinel — it only closes once the
|
|
278
|
+
// pointer is released. Without this gate a press that pauses a couple of
|
|
279
|
+
// frames before the drag actually moves would close mid-press and drop the
|
|
280
|
+
// entire subsequent drag.
|
|
281
281
|
let pointerHeld = false
|
|
282
282
|
// Absolute time (performance.now()) past which a `pulse(durationMs)` window
|
|
283
283
|
// force-closes even if the geometry is still changing — the upper bound that
|
|
@@ -319,7 +319,7 @@ export function createPlacementAnchor(
|
|
|
319
319
|
sentinelDeadline = null
|
|
320
320
|
return
|
|
321
321
|
}
|
|
322
|
-
//
|
|
322
|
+
// Upper bound: a pulse window past its deadline closes regardless of
|
|
323
323
|
// motion, so a target that changes every frame can't keep the sentinel alive.
|
|
324
324
|
if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
|
|
325
325
|
sentinelDeadline = null
|
|
@@ -328,7 +328,7 @@ export function createPlacementAnchor(
|
|
|
328
328
|
const p = computePlacement()
|
|
329
329
|
if (lastPublished && samePlacement(lastPublished, p)) {
|
|
330
330
|
steadyFrames++
|
|
331
|
-
// Steady-close only fires once the pointer is RELEASED
|
|
331
|
+
// Steady-close only fires once the pointer is RELEASED: while a
|
|
332
332
|
// splitter drag is held, a static pause is a hesitation, not the end of
|
|
333
333
|
// the drag, so we keep polling (re-arm below) and let `steadyFrames`
|
|
334
334
|
// accrue — it converges to a close within N frames after pointerup.
|
|
@@ -342,7 +342,7 @@ export function createPlacementAnchor(
|
|
|
342
342
|
steadyFrames = 0
|
|
343
343
|
}
|
|
344
344
|
// `publish` may have synchronously disposed (or hidden) the anchor; re-read
|
|
345
|
-
// live state so a re-entrant teardown leaves ZERO scheduled frames
|
|
345
|
+
// live state so a re-entrant teardown leaves ZERO scheduled frames.
|
|
346
346
|
if (!disposed && visible) {
|
|
347
347
|
rafId = requestAnimationFrame(sentinelFrame) // keep polling
|
|
348
348
|
} else {
|
|
@@ -366,17 +366,17 @@ export function createPlacementAnchor(
|
|
|
366
366
|
pointerHeld = false
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
-
//
|
|
370
|
-
//
|
|
371
|
-
// without it, a single synchronous emit() follows the new rect
|
|
369
|
+
// An ancestor scroll moved the target's screen rect. With the sentinel on,
|
|
370
|
+
// open the window so the whole scroll burst is followed frame-by-frame;
|
|
371
|
+
// without it, a single synchronous emit() follows the new rect.
|
|
372
372
|
const onScroll = (): void => {
|
|
373
373
|
if (followGeometry) openSentinel()
|
|
374
374
|
else emit()
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
377
|
+
// A capture-phase pointerdown on a [role="separator"] splitter handle marks
|
|
378
|
+
// the start of a drag that moves the target via ancestor reflow (no RO tick)
|
|
379
|
+
// → open the sentinel.
|
|
380
380
|
const onPointerDown = (e: Event): void => {
|
|
381
381
|
const t = e.target as Element | null
|
|
382
382
|
if (t && t.closest && t.closest('[role="separator"]')) {
|
|
@@ -385,7 +385,7 @@ export function createPlacementAnchor(
|
|
|
385
385
|
}
|
|
386
386
|
}
|
|
387
387
|
|
|
388
|
-
//
|
|
388
|
+
// Pointer released: the drag is over, so a steady run may now close the
|
|
389
389
|
// sentinel. Re-open it (a no-op if already polling) so the steady-close
|
|
390
390
|
// threshold is reached even if the geometry was already static at release.
|
|
391
391
|
const onPointerUp = (): void => {
|
|
@@ -469,7 +469,7 @@ export function createPlacementAnchor(
|
|
|
469
469
|
stopObserving()
|
|
470
470
|
},
|
|
471
471
|
pulse(durationMs?: number): void {
|
|
472
|
-
// Imperative window open
|
|
472
|
+
// Imperative window open: start the animation-follow window. It
|
|
473
473
|
// closes on steady (N=2 unchanged frames) OR, when `durationMs` is given,
|
|
474
474
|
// at that deadline — whichever comes first. The deadline is the upper bound
|
|
475
475
|
// that guarantees a still-animating target cannot keep the sentinel
|