view-anchor 0.2.2 → 1.0.0-beta.1

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.
Files changed (46) hide show
  1. package/README.md +71 -87
  2. package/README.zh-CN.md +59 -64
  3. package/dist/index.d.ts +5 -7
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2 -3
  6. package/dist/protocol-publisher.d.ts +7 -9
  7. package/dist/protocol-publisher.d.ts.map +1 -1
  8. package/dist/protocol-publisher.js +2 -8
  9. package/dist/protocol-types.d.ts +5 -8
  10. package/dist/protocol-types.d.ts.map +1 -1
  11. package/dist/protocol-types.js +3 -6
  12. package/dist/protocol.d.ts +2 -2
  13. package/dist/protocol.d.ts.map +1 -1
  14. package/dist/protocol.js +10 -2
  15. package/dist/react.d.ts +2 -15
  16. package/dist/react.d.ts.map +1 -1
  17. package/dist/react.js +43 -38
  18. package/dist/{size-advertiser.d.ts → size-anchor.d.ts} +6 -5
  19. package/dist/size-anchor.d.ts.map +1 -0
  20. package/dist/size-anchor.js +131 -0
  21. package/dist/types.d.ts +26 -32
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/view-anchor.d.ts +52 -33
  24. package/dist/view-anchor.d.ts.map +1 -1
  25. package/dist/view-anchor.js +186 -265
  26. package/docs/bidirectional-design.md +29 -34
  27. package/docs/index.html +656 -366
  28. package/docs/mechanism.md +35 -57
  29. package/docs/performance-report.md +12 -54
  30. package/docs/protocol.md +12 -3
  31. package/package.json +4 -6
  32. package/src/index.ts +5 -19
  33. package/src/protocol-publisher.ts +10 -16
  34. package/src/protocol-types.ts +5 -8
  35. package/src/protocol.ts +13 -7
  36. package/src/react.ts +51 -64
  37. package/src/size-anchor.ts +131 -0
  38. package/src/types.ts +29 -37
  39. package/src/view-anchor.ts +221 -287
  40. package/dist/measure-loop.d.ts +0 -24
  41. package/dist/measure-loop.d.ts.map +0 -1
  42. package/dist/measure-loop.js +0 -89
  43. package/dist/size-advertiser.d.ts.map +0 -1
  44. package/dist/size-advertiser.js +0 -94
  45. package/src/measure-loop.ts +0 -101
  46. package/src/size-advertiser.ts +0 -108
@@ -1,15 +1,15 @@
1
- import type { Bounds, Placement, Publisher, ViewAnchorOptions, ViewAnchorHandle } from './types.js'
1
+ import type { Bounds, Placement, Publisher } from './types.js'
2
2
  import { watchAbort } from './abort.js'
3
3
 
4
- const ZERO: Bounds = { x: 0, y: 0, width: 0, height: 0 }
5
-
6
4
  // Replaces a disposed instance's publish callback so a retained handle does
7
5
  // not keep the caller's original callback (and whatever it captured) alive.
8
6
  const NOOP_PUBLISH = (): false => false
9
7
 
10
- // Round to integer pixels. Width and height are clamped to >= 0 (0 represents
11
- // a collapsed rect). Coordinates (x, y) can be negative when an element is
12
- // scrolled out of view; clamping them to 0 would pin the view to the screen edge.
8
+ // Default holdSelector: omitting the option keeps tracking presses on a role=separator splitter.
9
+ export const DEFAULT_HOLD_SELECTOR = '[role="separator"]'
10
+
11
+ // Round to integer pixels. Width and height are clamped to >= 0; x and y can be
12
+ // negative, so a view scrolled out of sight is not pinned to the screen edge.
13
13
  const clampRect = (r: { x: number; y: number; width: number; height: number }): Bounds => ({
14
14
  x: Math.round(r.x),
15
15
  y: Math.round(r.y),
@@ -17,180 +17,77 @@ const clampRect = (r: { x: number; y: number; width: number; height: number }):
17
17
  height: Math.max(0, Math.round(r.height)),
18
18
  })
19
19
 
20
- /**
21
- * Bind a native view or external surface to the geometry of `target`.
22
- *
23
- * - `present === true`: measures `target.getBoundingClientRect()` and publishes
24
- * immediately, then re-measures synchronously on ResizeObserver and window resize.
25
- * - `present === false`: publishes a zero rect ({ x: 0, y: 0, width: 0, height: 0 })
26
- * and stops observing.
27
- * - `update(opts)`: re-applies options immediately.
28
- * - `dispose()`: stops observing and prevents any further publishes.
29
- *
30
- * Synchronous publishing: measurement and publishing occur directly in the
31
- * observer tick. Applying geometry outside the DOM may already be delayed;
32
- * adding requestAnimationFrame would add another frame of visual lag during drag
33
- * operations. High-frequency updates are deduplicated against the last accepted rect.
34
- */
35
- export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle {
36
- let present = opts.present
37
- let publish = opts.publish
38
- let observer: ResizeObserver | null = null
39
- // Local, clearable alias for the `target` parameter so dispose() can drop
40
- // the strong reference without widening the public parameter's type.
41
- let targetRef: HTMLElement | null = target
42
- // Last rect sent to publish. Reset on apply() so state changes (such as zoom)
43
- // force a re-publish even if the geometry did not change.
44
- let lastPublished: Bounds | null = null
45
- let publicationRevision = 0
46
- let disposed = false
47
-
48
- const measure = (): Bounds | null => {
49
- const r = targetRef!.getBoundingClientRect()
50
- // Drop ticks with non-finite values (NaN / Infinity are not usable geometry).
51
- if (
52
- !Number.isFinite(r.left) ||
53
- !Number.isFinite(r.top) ||
54
- !Number.isFinite(r.width) ||
55
- !Number.isFinite(r.height)
56
- )
57
- return null
58
- return clampRect({ x: r.left, y: r.top, width: r.width, height: r.height })
59
- }
60
-
61
- const sameRect = (a: Bounds, b: Bounds): boolean =>
62
- a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height
63
-
64
- const publishCandidate = (candidate: Bounds): boolean => {
65
- const previous = lastPublished
66
- const attempt = ++publicationRevision
67
- lastPublished = candidate
68
- try {
69
- const accepted = publish(candidate) !== false
70
- // A reentrant dispose() during publish() already cleared lastPublished;
71
- // do not resurrect the pre-dispose value over that terminal state.
72
- if (!accepted && publicationRevision === attempt && !disposed) lastPublished = previous
73
- return accepted
74
- } catch (error) {
75
- if (publicationRevision === attempt && !disposed) lastPublished = previous
76
- throw error
77
- }
78
- }
79
-
80
- // Measure and publish synchronously on each observer tick.
81
- // Drops duplicate rects to coalesce same-frame resize events.
82
- const emit = (): void => {
83
- if (disposed || !present) return
84
- const m = measure()
85
- if (!m) return
86
- if (lastPublished && sameRect(lastPublished, m)) return
87
- publishCandidate(m)
88
- }
89
-
90
- const startObserving = (): void => {
91
- if (observer) return
92
- observer = new ResizeObserver(emit)
93
- observer.observe(targetRef!)
94
- window.addEventListener('resize', emit)
95
- }
96
-
97
- const stopObserving = (): void => {
98
- if (observer) {
99
- observer.disconnect()
100
- observer = null
101
- }
102
- window.removeEventListener('resize', emit)
103
- }
104
-
105
- // Apply current options synchronously. Reset lastPublished so state changes
106
- // always re-publish even if dimensions have not changed.
107
- const apply = (): void => {
108
- lastPublished = null
109
- if (present) {
110
- startObserving()
111
- const measured = measure()
112
- if (measured) publishCandidate(measured)
113
- } else {
114
- stopObserving()
115
- publishCandidate(ZERO)
116
- }
117
- }
118
-
119
- let removeAbortListener = (): void => {}
120
-
121
- const dispose = (): void => {
122
- if (disposed) return
123
- disposed = true
124
- removeAbortListener()
125
- removeAbortListener = (): void => {}
126
- stopObserving()
127
- targetRef = null
128
- publish = NOOP_PUBLISH
129
- lastPublished = null
130
- }
131
-
132
- if (opts.signal?.aborted) dispose()
133
- else {
134
- removeAbortListener = watchAbort(opts.signal, dispose)
135
- apply()
136
- }
137
-
138
- return {
139
- update(next: ViewAnchorOptions): void {
140
- if (disposed) return
141
- publish = next.publish
142
- present = next.present
143
- apply()
144
- },
145
- dispose,
146
- }
147
- }
148
-
149
- // --- Explicit Placement API ---
150
-
151
- export interface PlacementAnchorOptions {
20
+ export interface ViewAnchorOptions {
152
21
  /**
153
22
  * Whether the native view should be visible. When true, publishes
154
- * { visible: true, bounds }; when false, publishes { visible: false }.
23
+ * { visible: true, bounds }; when false, publishes { visible: false } and
24
+ * stops observing, so a rejected hidden placement is only sent again by the
25
+ * next update().
155
26
  */
156
27
  visible: boolean
157
28
  /** Receives each explicit Placement. */
158
29
  publish: Publisher<Placement>
159
- /** Stops this anchor when aborted. An already-aborted signal starts no work. */
30
+ /**
31
+ * Stops this anchor when aborted. An already-aborted signal starts no work.
32
+ * Read once at creation only — update()'s type does not accept this field.
33
+ */
160
34
  signal?: AbortSignal
161
35
  /**
162
36
  * When true, targets with zero area (such as display: none or unmounted elements)
163
37
  * publish { visible: false } instead of { visible: true, bounds: 0x0 }, and an
164
38
  * IntersectionObserver tracks display: none transitions. Default is false.
165
- * Sticky across update(): omitting it preserves the current setting.
39
+ * update() applies the same default: omitting it resets to false.
166
40
  */
167
- guardDisplayNone?: boolean
41
+ treatZeroAreaAsHidden?: boolean
168
42
  /**
169
43
  * When true, listens for capture-phase scroll events on window to re-measure
170
44
  * when an ancestor container scrolls. Default is false.
171
- * Sticky across update(): omitting it preserves the current setting.
45
+ * update() applies the same default: omitting it resets to false.
172
46
  */
173
47
  followScroll?: boolean
174
48
  /**
175
49
  * When true, polls geometry per animation frame during active motion (scrolls,
176
- * splitter dragging, or pulse()) and auto-closes when steady. Zero idle overhead.
177
- * Sticky across update(): omitting it preserves the current setting.
50
+ * splitter dragging, or pulse()) and auto-closes when steady.
51
+ * update() applies the same default: omitting it resets to false.
178
52
  */
179
53
  followGeometry?: boolean
54
+ /**
55
+ * CSS selector for the press-and-hold target that keeps followGeometry open
56
+ * for the duration of a pointer press. Only meaningful when followGeometry
57
+ * is true; the pointer listeners are mounted only while both are set. A capture-phase pointerdown whose
58
+ * target matches `closest(holdSelector)` opens frame following until release.
59
+ * Default is `[role="separator"]`. Pass null to disable. update() applies
60
+ * the same default when omitted. An invalid selector throws synchronously
61
+ * (SyntaxError) before any option is applied.
62
+ */
63
+ holdSelector?: string | null
64
+ /**
65
+ * When true (the default), a measurement identical to the last accepted
66
+ * Placement is not published again. When false, every usable frame publishes
67
+ * even if the Placement is unchanged; the steady-frame close and the
68
+ * invalid/hidden frame caps still compare values either way. Hidden frames
69
+ * are never published by frame following regardless of this option. Omitting
70
+ * it in update() resets to true.
71
+ */
72
+ dedupe?: boolean
180
73
  }
181
74
 
182
- export interface PlacementAnchorHandle {
75
+ export interface ViewAnchorHandle {
183
76
  /**
184
- * Apply new options and re-publish immediately.
185
- * guardDisplayNone, followScroll, and followGeometry are sticky: omitting a flag
186
- * preserves its current value. Pass an explicit false to disable one.
77
+ * Apply a full set of options and re-publish immediately. Omitted options
78
+ * reset to defaults: treatZeroAreaAsHidden/followScroll/followGeometry → false,
79
+ * holdSelector → `[role="separator"]` (pass null to disable), dedupe → true.
80
+ * `signal` is not accepted here — it cannot be changed after creation.
187
81
  */
188
- update(opts: PlacementAnchorOptions): void
82
+ update(opts: Omit<ViewAnchorOptions, 'signal'>): void
189
83
  /** Stop observing; never publish again. */
190
84
  dispose(): void
191
85
  /**
192
- * Open the animation frame sentinel window. Auto-closes once stable
86
+ * Open a frame-following window. Auto-closes once stable
193
87
  * or after durationMs. No-op if followGeometry is false.
88
+ * durationMs is an upper bound, not a minimum: the window closes as soon as
89
+ * two frames measure the same rect, so a transition that starts slowly
90
+ * (sub-pixel movement in its first frames) may not be followed to the end.
194
91
  */
195
92
  pulse(durationMs?: number): void
196
93
  }
@@ -221,22 +118,30 @@ const samePlacement = (a: Placement, b: Placement): boolean => {
221
118
  }
222
119
 
223
120
  /**
224
- * Explicit-visibility variant of createViewAnchor.
121
+ * Bind a native view or external surface to the geometry of `target`.
122
+ *
123
+ * When `visible === true`, publishes { visible: true, bounds } immediately,
124
+ * then re-measures synchronously on ResizeObserver and window resize.
125
+ * When `visible === false`, publishes { visible: false } and stops observing.
126
+ * Publishes are deduplicated against the last accepted Placement by default
127
+ * (see the `dedupe` option).
225
128
  */
226
- export function createPlacementAnchor(
227
- target: HTMLElement,
228
- opts: PlacementAnchorOptions,
229
- ): PlacementAnchorHandle {
129
+ export function createViewAnchor(target: HTMLElement, opts: ViewAnchorOptions): ViewAnchorHandle {
130
+ // A non-empty holdSelector must be a syntactically valid CSS selector
131
+ // (matches() throws SyntaxError otherwise). Validate before any state is
132
+ // created so a bad selector never partially applies.
133
+ if (opts.holdSelector) target.matches(opts.holdSelector)
230
134
  let visible = opts.visible
231
135
  let publish = opts.publish
232
- let guardDisplayNone = opts.guardDisplayNone ?? false
136
+ let treatZeroAreaAsHidden = opts.treatZeroAreaAsHidden ?? false
233
137
  let followScroll = opts.followScroll ?? false
234
138
  let followGeometry = opts.followGeometry ?? false
235
- // Local, clearable alias for the `target` parameter so dispose() can drop
236
- // the strong reference without widening the public parameter's type.
139
+ let holdSelector = opts.holdSelector === undefined ? DEFAULT_HOLD_SELECTOR : opts.holdSelector
140
+ let dedupe = opts.dedupe ?? true
141
+ // Clearable alias so dispose() can drop the reference.
237
142
  let targetRef: HTMLElement | null = target
238
143
  let observer: ResizeObserver | null = null
239
- let io: IntersectionObserver | null = null
144
+ let intersectionObserver: IntersectionObserver | null = null
240
145
  let scrollListening = false
241
146
  let geometryListening = false
242
147
  const capture = { capture: true }
@@ -245,21 +150,26 @@ export function createPlacementAnchor(
245
150
  let publicationRevision = 0
246
151
  let disposed = false
247
152
 
248
- // --- Windowed RAF geometry sentinel state ---
249
- // The sentinel polls per frame during active movement and auto-closes once
153
+ // --- Frame following (followGeometry) state ---
154
+ // Frame following polls per frame during active movement and auto-closes once
250
155
  // geometry settles. While closed, no frame is scheduled (zero idle overhead).
251
156
  let rafId: number | null = null
252
157
  let steadyFrames = 0
253
158
  const STEADY_CLOSE_FRAMES = 2
254
159
  const MAX_HIDDEN_FOLLOW_FRAMES = 30
255
160
  const MAX_INVALID_FOLLOW_FRAMES = 30
161
+ let hiddenFrames = 0
256
162
  let invalidFrames = 0
257
- // True while a [role="separator"] splitter drag is held.
258
- let pointerHeld = false
259
- let activePointerId: number | undefined | null = null
260
- let sentinelDeadline: number | null = null
261
-
262
- const computePlacement = (): Placement | null => {
163
+ // Pointer ids currently matching holdSelector and held down. Non-empty
164
+ // keeps frame following open regardless of deadline or steady frames.
165
+ const heldPointerIds = new Set<number>()
166
+ // The rect measured on the previous followed frame, independent of whether
167
+ // publish() accepted it. Drives the steady/moving decision so a publish()
168
+ // that keeps rejecting an unchanged measurement cannot keep resetting it.
169
+ let lastFramePlacement: Placement | null = null
170
+ let followDeadline: number | null = null
171
+
172
+ const measureTarget = (): Placement | null => {
263
173
  const p = measurePlacement(targetRef!)
264
174
  if (
265
175
  p.visible &&
@@ -269,13 +179,13 @@ export function createPlacementAnchor(
269
179
  !Number.isFinite(p.bounds.height))
270
180
  )
271
181
  return null
272
- if (guardDisplayNone && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
182
+ if (treatZeroAreaAsHidden && p.visible && (p.bounds.width === 0 || p.bounds.height === 0)) {
273
183
  return { visible: false }
274
184
  }
275
185
  return p
276
186
  }
277
187
 
278
- const publishCandidate = (candidate: Placement): boolean => {
188
+ const publishPlacement = (candidate: Placement): boolean => {
279
189
  const previous = lastPublished
280
190
  const attempt = ++publicationRevision
281
191
  lastPublished = candidate
@@ -291,121 +201,127 @@ export function createPlacementAnchor(
291
201
  }
292
202
  }
293
203
 
294
- const emit = (): void => {
204
+ const measureAndPublish = (): void => {
295
205
  if (disposed || !visible) return
296
- const p = computePlacement()
206
+ const p = measureTarget()
297
207
  if (!p) return
298
- if (lastPublished && samePlacement(lastPublished, p)) return
299
- publishCandidate(p)
208
+ if (dedupe && lastPublished && samePlacement(lastPublished, p)) return
209
+ publishPlacement(p)
300
210
  }
301
211
 
302
- const shouldCloseOnHiddenPoll = (): boolean => {
303
- if (lastPublished?.visible === false) return true
304
- return steadyFrames++ >= MAX_HIDDEN_FOLLOW_FRAMES
212
+ // Polls again next frame if following is still wanted after this frame's work.
213
+ const scheduleFollowFrame = (): void => {
214
+ if (!disposed && visible && followGeometry) rafId = requestAnimationFrame(followFrame)
215
+ else followDeadline = null
305
216
  }
306
217
 
307
- const sentinelFrame = (): void => {
218
+ const followFrame = (): void => {
308
219
  rafId = null
309
- if (disposed || !visible) {
310
- sentinelDeadline = null
311
- return
312
- }
313
- if (sentinelDeadline !== null && performance.now() >= sentinelDeadline) {
314
- sentinelDeadline = null
220
+ if (
221
+ disposed ||
222
+ !visible ||
223
+ (heldPointerIds.size === 0 && followDeadline !== null && performance.now() >= followDeadline)
224
+ ) {
225
+ followDeadline = null
315
226
  return
316
227
  }
317
- const p = computePlacement()
228
+ const p = measureTarget()
318
229
  if (!p) {
319
- if (invalidFrames++ >= MAX_INVALID_FOLLOW_FRAMES) {
320
- sentinelDeadline = null
321
- return
322
- }
323
- if (!disposed && visible && followGeometry) {
324
- rafId = requestAnimationFrame(sentinelFrame)
325
- } else {
326
- sentinelDeadline = null
327
- }
230
+ if (++invalidFrames >= MAX_INVALID_FOLLOW_FRAMES) followDeadline = null
231
+ else scheduleFollowFrame()
328
232
  return
329
233
  }
330
234
  invalidFrames = 0
331
235
  if (!p.visible) {
332
- if (shouldCloseOnHiddenPoll()) {
333
- sentinelDeadline = null
334
- return
335
- }
336
- if (!disposed && visible && followGeometry) {
337
- rafId = requestAnimationFrame(sentinelFrame)
236
+ // Hidden frames are never published here; stop once hidden is already
237
+ // published or the target stays hidden past its budget.
238
+ if (lastPublished?.visible === false || ++hiddenFrames >= MAX_HIDDEN_FOLLOW_FRAMES) {
239
+ followDeadline = null
338
240
  } else {
339
- sentinelDeadline = null
241
+ scheduleFollowFrame()
340
242
  }
341
243
  return
342
244
  }
343
- if (lastPublished && samePlacement(lastPublished, p)) {
344
- steadyFrames++
345
- if (steadyFrames >= STEADY_CLOSE_FRAMES && !pointerHeld) {
346
- sentinelDeadline = null
245
+ // Steadiness is judged against what was measured last frame, not against
246
+ // what publish() accepted: a candidate that publish() keeps rejecting
247
+ // must not keep looking "different" forever just because publishPlacement
248
+ // rolls lastPublished back on rejection, which would spin the frame loop.
249
+ const steadyFrame = lastFramePlacement !== null && samePlacement(lastFramePlacement, p)
250
+ lastFramePlacement = p
251
+ if (!dedupe || !lastPublished || !samePlacement(lastPublished, p)) publishPlacement(p)
252
+ if (steadyFrame) {
253
+ if (++steadyFrames >= STEADY_CLOSE_FRAMES && heldPointerIds.size === 0) {
254
+ followDeadline = null
347
255
  return
348
256
  }
349
257
  } else {
350
- publishCandidate(p)
351
258
  steadyFrames = 0
259
+ hiddenFrames = 0
352
260
  }
353
- if (!disposed && visible && followGeometry) {
354
- rafId = requestAnimationFrame(sentinelFrame)
355
- } else {
356
- sentinelDeadline = null
357
- }
261
+ scheduleFollowFrame()
358
262
  }
359
263
 
360
- const openSentinel = (): void => {
264
+ const startFrameFollow = (): void => {
361
265
  if (!followGeometry || disposed) return
362
266
  steadyFrames = 0
267
+ hiddenFrames = 0
268
+ // Seed from the last accepted placement so the first post-open frame
269
+ // doesn't look unsteady on an unchanged rect.
270
+ lastFramePlacement = lastPublished
363
271
  if (rafId === null) {
364
272
  invalidFrames = 0
365
- rafId = requestAnimationFrame(sentinelFrame)
273
+ rafId = requestAnimationFrame(followFrame)
366
274
  }
367
275
  }
368
276
 
369
- const closeSentinel = (): void => {
277
+ const stopFrameFollow = (): void => {
370
278
  if (rafId !== null) {
371
279
  cancelAnimationFrame(rafId)
372
280
  rafId = null
373
281
  }
374
282
  steadyFrames = 0
283
+ hiddenFrames = 0
375
284
  invalidFrames = 0
376
- sentinelDeadline = null
377
- pointerHeld = false
378
- activePointerId = null
285
+ lastFramePlacement = lastPublished
286
+ followDeadline = null
287
+ heldPointerIds.clear()
379
288
  }
380
289
 
381
290
  const onScroll = (): void => {
382
- if (followGeometry) openSentinel()
383
- else emit()
291
+ if (followGeometry) startFrameFollow()
292
+ else measureAndPublish()
384
293
  }
385
294
 
386
295
  const onPointerDown = (e: Event): void => {
387
296
  const t = e.target as Element | null
388
- if (t && t.closest && t.closest('[role="separator"]')) {
389
- if (!pointerHeld) activePointerId = (e as PointerEvent).pointerId
390
- pointerHeld = true
391
- openSentinel()
297
+ if (holdSelector && t && t.closest && t.closest(holdSelector)) {
298
+ // The first of possibly several concurrently-held pointers drops any
299
+ // pulse() deadline: a held press has no time limit.
300
+ if (heldPointerIds.size === 0) followDeadline = null
301
+ heldPointerIds.add((e as PointerEvent).pointerId)
302
+ startFrameFollow()
392
303
  }
393
304
  }
394
305
 
395
- const releasePointer = (e?: Event): void => {
396
- if (!pointerHeld) return
397
- if (e && activePointerId !== (e as PointerEvent).pointerId) return
398
- pointerHeld = false
399
- activePointerId = null
400
- openSentinel()
306
+ // pointerId undefined means "release everything" (window blur, or an event
307
+ // that never carried a pointerId, matching the single-pointer tests that
308
+ // dispatch plain Events). Otherwise only that one id's hold ends, and the
309
+ // frame following only leaves its held (no-deadline) state once none remain.
310
+ const releasePointer = (pointerId?: number): void => {
311
+ if (heldPointerIds.size === 0) return
312
+ if (pointerId !== undefined && !heldPointerIds.delete(pointerId)) return
313
+ if (pointerId === undefined) heldPointerIds.clear()
314
+ if (heldPointerIds.size > 0) return
315
+ followDeadline = null
316
+ startFrameFollow()
401
317
  }
402
318
 
403
319
  const onPointerUp = (e: Event): void => {
404
- releasePointer(e)
320
+ releasePointer((e as PointerEvent).pointerId)
405
321
  }
406
322
 
407
323
  const onPointerCancel = (e: Event): void => {
408
- releasePointer(e)
324
+ releasePointer((e as PointerEvent).pointerId)
409
325
  }
410
326
 
411
327
  const onWindowBlur = (): void => {
@@ -413,15 +329,20 @@ export function createPlacementAnchor(
413
329
  }
414
330
 
415
331
  const startOptionalObserving = (): void => {
416
- if (guardDisplayNone && !io && typeof IntersectionObserver !== 'undefined') {
417
- io = new IntersectionObserver(emit)
418
- io.observe(targetRef!)
332
+ if (
333
+ treatZeroAreaAsHidden &&
334
+ !intersectionObserver &&
335
+ typeof IntersectionObserver !== 'undefined'
336
+ ) {
337
+ intersectionObserver = new IntersectionObserver(measureAndPublish)
338
+ intersectionObserver.observe(targetRef!)
419
339
  }
420
340
  if (followScroll && !scrollListening) {
421
341
  window.addEventListener('scroll', onScroll, passiveCapture)
422
342
  scrollListening = true
423
343
  }
424
- if (followGeometry && !geometryListening) {
344
+ // Mounted only while both followGeometry and holdSelector are set.
345
+ if (followGeometry && holdSelector && !geometryListening) {
425
346
  window.addEventListener('pointerdown', onPointerDown, capture)
426
347
  window.addEventListener('pointerup', onPointerUp, capture)
427
348
  window.addEventListener('pointercancel', onPointerCancel, capture)
@@ -430,49 +351,47 @@ export function createPlacementAnchor(
430
351
  }
431
352
  }
432
353
 
433
- const stopOptionalObserving = (): void => {
434
- if (io && !guardDisplayNone) {
435
- io.disconnect()
436
- io = null
437
- }
438
- if (scrollListening && !followScroll) {
439
- window.removeEventListener('scroll', onScroll, passiveCapture)
440
- scrollListening = false
441
- }
442
- if (geometryListening && !followGeometry) {
443
- window.removeEventListener('pointerdown', onPointerDown, capture)
444
- window.removeEventListener('pointerup', onPointerUp, capture)
445
- window.removeEventListener('pointercancel', onPointerCancel, capture)
446
- window.removeEventListener('blur', onWindowBlur)
447
- geometryListening = false
448
- closeSentinel()
449
- }
354
+ const stopIntersection = (): void => {
355
+ if (!intersectionObserver) return
356
+ intersectionObserver.disconnect()
357
+ intersectionObserver = null
450
358
  }
451
359
 
452
- const stopAllOptionalObserving = (): void => {
453
- if (io) {
454
- io.disconnect()
455
- io = null
456
- }
457
- if (scrollListening) {
458
- window.removeEventListener('scroll', onScroll, passiveCapture)
459
- scrollListening = false
460
- }
461
- if (geometryListening) {
462
- window.removeEventListener('pointerdown', onPointerDown, capture)
463
- window.removeEventListener('pointerup', onPointerUp, capture)
464
- window.removeEventListener('pointercancel', onPointerCancel, capture)
465
- window.removeEventListener('blur', onWindowBlur)
466
- geometryListening = false
360
+ const stopScroll = (): void => {
361
+ if (!scrollListening) return
362
+ window.removeEventListener('scroll', onScroll, passiveCapture)
363
+ scrollListening = false
364
+ }
365
+
366
+ const stopGeometryListeners = (): void => {
367
+ if (!geometryListening) return
368
+ window.removeEventListener('pointerdown', onPointerDown, capture)
369
+ window.removeEventListener('pointerup', onPointerUp, capture)
370
+ window.removeEventListener('pointercancel', onPointerCancel, capture)
371
+ window.removeEventListener('blur', onWindowBlur)
372
+ geometryListening = false
373
+ }
374
+
375
+ // Detaches only the optional observers whose flag has been turned off.
376
+ const stopDisabledObserving = (): void => {
377
+ if (!treatZeroAreaAsHidden) stopIntersection()
378
+ if (!followScroll) stopScroll()
379
+ // The pointer listeners are only useful while both flags hold; drop them
380
+ // (and any held-pointer state they were tracking) as soon as either lapses.
381
+ if ((!followGeometry || !holdSelector) && geometryListening) {
382
+ stopGeometryListeners()
383
+ heldPointerIds.clear()
467
384
  }
468
- closeSentinel()
385
+ // Independent of whether the listeners were mounted: turning followGeometry
386
+ // off must always stop frame following, not just when holdSelector was set.
387
+ if (!followGeometry) stopFrameFollow()
469
388
  }
470
389
 
471
390
  const startObserving = (): void => {
472
391
  if (observer) return
473
- observer = new ResizeObserver(emit)
392
+ observer = new ResizeObserver(measureAndPublish)
474
393
  observer.observe(targetRef!)
475
- window.addEventListener('resize', emit)
394
+ window.addEventListener('resize', measureAndPublish)
476
395
  startOptionalObserving()
477
396
  }
478
397
 
@@ -481,20 +400,23 @@ export function createPlacementAnchor(
481
400
  observer.disconnect()
482
401
  observer = null
483
402
  }
484
- window.removeEventListener('resize', emit)
485
- stopAllOptionalObserving()
403
+ window.removeEventListener('resize', measureAndPublish)
404
+ stopIntersection()
405
+ stopScroll()
406
+ stopGeometryListeners()
407
+ stopFrameFollow()
486
408
  }
487
409
 
488
- const apply = (): void => {
410
+ const applyOptions = (): void => {
489
411
  lastPublished = null
490
412
  if (visible) {
491
413
  startObserving()
492
- const placement = computePlacement()
493
- if (placement) publishCandidate(placement)
414
+ const placement = measureTarget()
415
+ if (placement) publishPlacement(placement)
494
416
  } else {
495
417
  stopObserving()
496
418
  const hidden: Placement = { visible: false }
497
- publishCandidate(hidden)
419
+ publishPlacement(hidden)
498
420
  }
499
421
  }
500
422
 
@@ -514,33 +436,45 @@ export function createPlacementAnchor(
514
436
  if (opts.signal?.aborted) dispose()
515
437
  else {
516
438
  removeAbortListener = watchAbort(opts.signal, dispose)
517
- apply()
439
+ // A throwing first publish must not leave the observer/listeners it just
440
+ // started (startObserving() runs before the first publishPlacement())
441
+ // mounted on a handle the caller never got to dispose.
442
+ try {
443
+ applyOptions()
444
+ } catch (error) {
445
+ dispose()
446
+ throw error
447
+ }
518
448
  }
519
449
 
520
450
  return {
521
- update(next: PlacementAnchorOptions): void {
451
+ update(next: Omit<ViewAnchorOptions, 'signal'>): void {
522
452
  if (disposed) return
453
+ // Validate before touching any state so a bad selector throws
454
+ // atomically and the previous holdSelector stays in effect.
455
+ if (next.holdSelector) targetRef!.matches(next.holdSelector)
523
456
  publish = next.publish
524
457
  visible = next.visible
525
- // Omitting a flag preserves its current value so callers that only
526
- // pass { visible, publish } don't inadvertently disable enabled flags.
527
- guardDisplayNone = next.guardDisplayNone ?? guardDisplayNone
528
- followScroll = next.followScroll ?? followScroll
529
- followGeometry = next.followGeometry ?? followGeometry
458
+ // Full configuration: omitted flags reset to defaults.
459
+ treatZeroAreaAsHidden = next.treatZeroAreaAsHidden ?? false
460
+ followScroll = next.followScroll ?? false
461
+ followGeometry = next.followGeometry ?? false
462
+ holdSelector = next.holdSelector === undefined ? DEFAULT_HOLD_SELECTOR : next.holdSelector
463
+ dedupe = next.dedupe ?? true
530
464
  if (visible && observer) {
531
- stopOptionalObserving()
465
+ stopDisabledObserving()
532
466
  startOptionalObserving()
533
467
  }
534
- apply()
468
+ applyOptions()
535
469
  },
536
470
  dispose,
537
471
  pulse(durationMs?: number): void {
538
472
  if (disposed || !followGeometry) return
539
473
  if (durationMs !== undefined && durationMs > 0) {
540
474
  const next = performance.now() + durationMs
541
- sentinelDeadline = sentinelDeadline === null ? next : Math.max(sentinelDeadline, next)
475
+ followDeadline = followDeadline === null ? next : Math.max(followDeadline, next)
542
476
  }
543
- openSentinel()
477
+ startFrameFollow()
544
478
  },
545
479
  }
546
480
  }