@barefootjs/client 0.34.0 → 0.35.0
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/csr-adapter.js +9 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/runtime/component.d.ts +29 -7
- package/dist/runtime/component.d.ts.map +1 -1
- package/dist/runtime/index.d.ts +2 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +101 -13
- package/dist/runtime/map-array-lazy.d.ts +29 -0
- package/dist/runtime/map-array-lazy.d.ts.map +1 -1
- package/dist/runtime/map-array.d.ts +12 -2
- package/dist/runtime/map-array.d.ts.map +1 -1
- package/dist/runtime/portal.d.ts +44 -0
- package/dist/runtime/portal.d.ts.map +1 -1
- package/dist/runtime/standalone.js +98 -10
- package/dist/runtime/track-position.d.ts +37 -0
- package/dist/runtime/track-position.d.ts.map +1 -0
- package/dist/shims.d.ts +1 -0
- package/dist/shims.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -0
- package/src/runtime/component.ts +33 -7
- package/src/runtime/index.ts +4 -0
- package/src/runtime/map-array-lazy.ts +45 -2
- package/src/runtime/map-array.ts +54 -13
- package/src/runtime/portal.ts +154 -0
- package/src/runtime/track-position.ts +47 -0
- package/src/shims.ts +4 -0
package/src/runtime/map-array.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* Single-root loops continue to flow through the legacy path verbatim.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { createSignal, createEffect, createRoot } from '@barefootjs/client/reactive'
|
|
21
|
+
import { createSignal, createEffect, createRoot, batch } from '@barefootjs/client/reactive'
|
|
22
22
|
import { hydratedScopes } from './hydration-state.ts'
|
|
23
23
|
import { setRowMountPoint, type RowMountPoint } from './component.ts'
|
|
24
24
|
import {
|
|
@@ -80,8 +80,30 @@ type ItemScope<T> = {
|
|
|
80
80
|
scopeComments: ScopeCommentPair | null
|
|
81
81
|
dispose: () => void
|
|
82
82
|
setItem: (v: T) => void
|
|
83
|
+
/**
|
|
84
|
+
* Push this row's CURRENT position (#2859). A same-key reorder never
|
|
85
|
+
* re-invokes `renderItem` — only `setItem`/`setIndex` run — so anything
|
|
86
|
+
* the row body derives from the raw `.map()` index parameter (rather
|
|
87
|
+
* than from the item itself) stays live only because the compiler
|
|
88
|
+
* rewrites references to that parameter into a call through the
|
|
89
|
+
* accessor `renderItem` received (`wrapLoopParamAsAccessor`,
|
|
90
|
+
* ir-to-client-js/utils.ts) instead of using the plain number this
|
|
91
|
+
* signal was seeded from.
|
|
92
|
+
*/
|
|
93
|
+
setIndex: (i: number) => void
|
|
83
94
|
}
|
|
84
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Shape `renderItem` callbacks are compiled to. `index` mirrors `item`:
|
|
98
|
+
* an accessor, not a plain number, so a row body's reference to the raw
|
|
99
|
+
* `.map()` index parameter — rewritten to a call by
|
|
100
|
+
* `wrapLoopParamAsAccessor` — reads the row's CURRENT position instead of
|
|
101
|
+
* the value captured when the row was first created (#2859). `existing`
|
|
102
|
+
* stays optional/untyped-per-call-site since `mapArray` and
|
|
103
|
+
* `mapArrayAnchored` return different element shapes.
|
|
104
|
+
*/
|
|
105
|
+
export type RenderItem<T, E, R> = (item: () => T, index: () => number, existing?: E) => R
|
|
106
|
+
|
|
85
107
|
/**
|
|
86
108
|
* Find loop boundary comment markers in a container.
|
|
87
109
|
*
|
|
@@ -327,7 +349,7 @@ function removeScope<T>(scope: ItemScope<T>): void {
|
|
|
327
349
|
function createItemScope<T>(
|
|
328
350
|
item: T,
|
|
329
351
|
index: number,
|
|
330
|
-
renderItem:
|
|
352
|
+
renderItem: RenderItem<T, HTMLElement, HTMLElement>,
|
|
331
353
|
existingPrimary?: HTMLElement,
|
|
332
354
|
existingExtras?: HTMLElement[],
|
|
333
355
|
existingStart?: Comment | null,
|
|
@@ -337,6 +359,7 @@ function createItemScope<T>(
|
|
|
337
359
|
let primaryEl!: HTMLElement
|
|
338
360
|
let dispose!: () => void
|
|
339
361
|
let setItem!: (v: T) => void
|
|
362
|
+
let setIndex!: (i: number) => void
|
|
340
363
|
let extras: HTMLElement[] = []
|
|
341
364
|
let startMarker: Comment | null = null
|
|
342
365
|
let scopeComments: ScopeCommentPair | null = null
|
|
@@ -344,7 +367,9 @@ function createItemScope<T>(
|
|
|
344
367
|
createRoot((d) => {
|
|
345
368
|
dispose = d
|
|
346
369
|
const [itemAccessor, itemSetter] = createSignal(item)
|
|
370
|
+
const [indexAccessor, indexSetter] = createSignal(index)
|
|
347
371
|
setItem = itemSetter
|
|
372
|
+
setIndex = indexSetter
|
|
348
373
|
// Fresh row: hand the mount point down so the row's own root — whether a
|
|
349
374
|
// `createComponent` call or a `mountRowRoot(clone)` — observes a connected
|
|
350
375
|
// element when the body's tail initialises its children. A body that does
|
|
@@ -358,7 +383,7 @@ function createItemScope<T>(
|
|
|
358
383
|
const ownsRowMount = !existingPrimary && !!rowMount
|
|
359
384
|
const prevRowMount = ownsRowMount ? setRowMountPoint(rowMount) : null
|
|
360
385
|
try {
|
|
361
|
-
primaryEl = renderItem(itemAccessor,
|
|
386
|
+
primaryEl = renderItem(itemAccessor, indexAccessor, existingPrimary)
|
|
362
387
|
} catch (err) {
|
|
363
388
|
// A row that connected itself and then failed to finish would stay
|
|
364
389
|
// visible as a half-built row. Detached rows never could, so undo the
|
|
@@ -399,7 +424,7 @@ function createItemScope<T>(
|
|
|
399
424
|
primaryEl.remove()
|
|
400
425
|
}
|
|
401
426
|
|
|
402
|
-
return { startMarker, primaryEl, extras, scopeComments, dispose, setItem }
|
|
427
|
+
return { startMarker, primaryEl, extras, scopeComments, dispose, setItem, setIndex }
|
|
403
428
|
}
|
|
404
429
|
|
|
405
430
|
/**
|
|
@@ -427,7 +452,7 @@ export function mapArray<T>(
|
|
|
427
452
|
accessor: () => T[],
|
|
428
453
|
container: HTMLElement | null,
|
|
429
454
|
getKey: ((item: T, index: number) => string) | null,
|
|
430
|
-
renderItem:
|
|
455
|
+
renderItem: RenderItem<T, HTMLElement, HTMLElement>,
|
|
431
456
|
markerId?: string,
|
|
432
457
|
bfId?: string,
|
|
433
458
|
keyAttrName: string = BF_KEY,
|
|
@@ -573,6 +598,7 @@ export function mapArray<T>(
|
|
|
573
598
|
scopeComments: range.scopeComments,
|
|
574
599
|
dispose: () => {},
|
|
575
600
|
setItem: () => {},
|
|
601
|
+
setIndex: () => {},
|
|
576
602
|
})
|
|
577
603
|
}
|
|
578
604
|
}
|
|
@@ -645,8 +671,13 @@ export function mapArray<T>(
|
|
|
645
671
|
const existing = scopes.get(key)
|
|
646
672
|
if (existing) {
|
|
647
673
|
// Same key: update per-item signal — fine-grained effects handle DOM updates.
|
|
648
|
-
// Element is preserved (no dispose, no re-render).
|
|
649
|
-
|
|
674
|
+
// Element is preserved (no dispose, no re-render). `setIndex` pushes this
|
|
675
|
+
// row's CURRENT position (#2859) — batched with `setItem` so a row whose
|
|
676
|
+
// effect reads both doesn't run twice for one reconcile pass.
|
|
677
|
+
batch(() => {
|
|
678
|
+
existing.setItem(item)
|
|
679
|
+
existing.setIndex(i)
|
|
680
|
+
})
|
|
650
681
|
desiredOrder.push(existing)
|
|
651
682
|
} else {
|
|
652
683
|
// New item: create in isolated scope. The row is mounted at the end of
|
|
@@ -779,6 +810,8 @@ type AnchorScope<T> = {
|
|
|
779
810
|
pending: DocumentFragment | null
|
|
780
811
|
dispose: () => void
|
|
781
812
|
setItem: (v: T) => void
|
|
813
|
+
/** See `ItemScope.setIndex` (#2859) — same per-row "current position" push. */
|
|
814
|
+
setIndex: (i: number) => void
|
|
782
815
|
}
|
|
783
816
|
|
|
784
817
|
const ITEM_PREFIX = `${BF_LOOP_ITEM}:`
|
|
@@ -845,23 +878,26 @@ function createAnchorScope<T>(
|
|
|
845
878
|
item: T,
|
|
846
879
|
index: number,
|
|
847
880
|
key: string,
|
|
848
|
-
renderItem:
|
|
881
|
+
renderItem: RenderItem<T, Comment, DocumentFragment | Comment>,
|
|
849
882
|
existingAnchor?: Comment,
|
|
850
883
|
): AnchorScope<T> {
|
|
851
884
|
let dispose!: () => void
|
|
852
885
|
let setItem!: (v: T) => void
|
|
886
|
+
let setIndex!: (i: number) => void
|
|
853
887
|
let returned!: DocumentFragment | Comment
|
|
854
888
|
|
|
855
889
|
createRoot((d) => {
|
|
856
890
|
dispose = d
|
|
857
891
|
const [itemAccessor, itemSetter] = createSignal(item)
|
|
892
|
+
const [indexAccessor, indexSetter] = createSignal(index)
|
|
858
893
|
setItem = itemSetter
|
|
859
|
-
|
|
894
|
+
setIndex = indexSetter
|
|
895
|
+
returned = renderItem(itemAccessor, indexAccessor, existingAnchor)
|
|
860
896
|
return undefined
|
|
861
897
|
})
|
|
862
898
|
|
|
863
899
|
if (existingAnchor) {
|
|
864
|
-
return { anchor: existingAnchor, pending: null, dispose, setItem }
|
|
900
|
+
return { anchor: existingAnchor, pending: null, dispose, setItem, setIndex }
|
|
865
901
|
}
|
|
866
902
|
// CSR: renderItem returns a fragment whose first child is the anchor.
|
|
867
903
|
const frag = returned as DocumentFragment
|
|
@@ -871,7 +907,7 @@ function createAnchorScope<T>(
|
|
|
871
907
|
if (anchor && !anchor.nodeValue?.startsWith(ITEM_PREFIX)) {
|
|
872
908
|
anchor.nodeValue = loopItemMarker(key)
|
|
873
909
|
}
|
|
874
|
-
return { anchor, pending: frag, dispose, setItem }
|
|
910
|
+
return { anchor, pending: frag, dispose, setItem, setIndex }
|
|
875
911
|
}
|
|
876
912
|
|
|
877
913
|
/**
|
|
@@ -886,7 +922,7 @@ export function mapArrayAnchored<T>(
|
|
|
886
922
|
accessor: () => T[],
|
|
887
923
|
container: HTMLElement | null,
|
|
888
924
|
getKey: ((item: T, index: number) => string) | null,
|
|
889
|
-
renderItem:
|
|
925
|
+
renderItem: RenderItem<T, Comment, DocumentFragment | Comment>,
|
|
890
926
|
markerId?: string,
|
|
891
927
|
bfId?: string,
|
|
892
928
|
): void {
|
|
@@ -949,7 +985,12 @@ export function mapArrayAnchored<T>(
|
|
|
949
985
|
|
|
950
986
|
const existing = scopes.get(key)
|
|
951
987
|
if (existing) {
|
|
952
|
-
|
|
988
|
+
// See the identical `mapArray` branch above (#2859): batched so a row
|
|
989
|
+
// whose effect reads both item and index doesn't run twice.
|
|
990
|
+
batch(() => {
|
|
991
|
+
existing.setItem(item)
|
|
992
|
+
existing.setIndex(i)
|
|
993
|
+
})
|
|
953
994
|
desiredOrder.push(existing)
|
|
954
995
|
} else {
|
|
955
996
|
const scope = createAnchorScope(item, i, key, renderItem)
|
package/src/runtime/portal.ts
CHANGED
|
@@ -126,6 +126,143 @@ export function cleanupPortalPlaceholder(portalId: string): void {
|
|
|
126
126
|
placeholder?.remove()
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
/**
|
|
130
|
+
* A portal whose deferral subject (see `createPortal`'s insertion rule) was
|
|
131
|
+
* not yet in the document when `createPortal` ran (#2717). Its element is
|
|
132
|
+
* ALREADY a child of `container`; once the subject connects it is
|
|
133
|
+
* re-appended, which moves it to the container's end.
|
|
134
|
+
*/
|
|
135
|
+
interface PendingPortal {
|
|
136
|
+
element: HTMLElement
|
|
137
|
+
container: HTMLElement
|
|
138
|
+
/** The node whose connection is awaited: the owner, or the element's former parent. */
|
|
139
|
+
subject: Node
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Creation-ordered queue of portals waiting for their subject to connect. */
|
|
143
|
+
const pendingPortals: PendingPortal[] = []
|
|
144
|
+
/** Alive only while `pendingPortals` is non-empty. */
|
|
145
|
+
let pendingObserver: MutationObserver | null = null
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `Element.moveBefore()` — not yet in TS's bundled `lib.dom.d.ts` — moves an
|
|
149
|
+
* already-connected node without the side effects `appendChild`/
|
|
150
|
+
* `insertBefore` are specified to have on one: it resets `<iframe>` load
|
|
151
|
+
* state, `<video>`/`<audio>` playback position, CSS animation/transition
|
|
152
|
+
* state, `:focus`/`:active`, and native `popover`/fullscreen state. The
|
|
153
|
+
* reorder below is a genuine move of an already-connected node (the
|
|
154
|
+
* element is already `container`'s child from `createPortal`'s initial
|
|
155
|
+
* append), so it must prefer `moveBefore` and fall back to `appendChild`
|
|
156
|
+
* only where the API isn't supported yet — same fallback shape as the
|
|
157
|
+
* `MutationObserver` feature-detection above.
|
|
158
|
+
*/
|
|
159
|
+
interface MoveBeforeCapable {
|
|
160
|
+
moveBefore(node: Node, child: Node | null): void
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Move `element` to `container`'s end, preserving any live state a plain re-`appendChild` would reset (see `MoveBeforeCapable`). */
|
|
164
|
+
function moveToContainerEnd(container: HTMLElement, element: HTMLElement): void {
|
|
165
|
+
const moveBefore = (container as Partial<MoveBeforeCapable>).moveBefore
|
|
166
|
+
if (typeof moveBefore === 'function') {
|
|
167
|
+
moveBefore.call(container, element, null)
|
|
168
|
+
} else {
|
|
169
|
+
container.appendChild(element)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-append every pending portal whose subject has connected since the
|
|
175
|
+
* last check, in creation order — the same order the hydration path
|
|
176
|
+
* produces when the owner is already connected and each `createPortal`
|
|
177
|
+
* appends synchronously. The element is already in `container`, so this
|
|
178
|
+
* moves the connected node to the container's end (see `moveToContainerEnd`),
|
|
179
|
+
* after the root that connected it, rather than inserting a new one.
|
|
180
|
+
* Pending entries whose subject is still detached are kept; an entry
|
|
181
|
+
* whose element has left the container in the meantime (removed or moved
|
|
182
|
+
* by the caller without `unmount`) is dropped rather than re-inserted.
|
|
183
|
+
*/
|
|
184
|
+
function flushPendingPortals(): void {
|
|
185
|
+
for (const pending of pendingPortals.slice()) {
|
|
186
|
+
if (!pending.subject.isConnected) continue
|
|
187
|
+
pendingPortals.splice(pendingPortals.indexOf(pending), 1)
|
|
188
|
+
if (pending.element.parentNode === pending.container) {
|
|
189
|
+
moveToContainerEnd(pending.container, pending.element)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (pendingPortals.length === 0 && pendingObserver) {
|
|
193
|
+
pendingObserver.disconnect()
|
|
194
|
+
pendingObserver = null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function enqueuePendingPortal(pending: PendingPortal): void {
|
|
199
|
+
pendingPortals.push(pending)
|
|
200
|
+
if (!pendingObserver) {
|
|
201
|
+
// Observe the whole document: the subject is connected by whoever holds
|
|
202
|
+
// the component root (`document.body.appendChild(root)` in a CSR boot,
|
|
203
|
+
// a placeholder `replaceWith` higher up the tree, …) — the runtime has
|
|
204
|
+
// no hook of its own at that moment, so the DOM's own insertion
|
|
205
|
+
// notification is the one signal that covers every caller. Callbacks
|
|
206
|
+
// run as a microtask, before the next paint, so the element is never
|
|
207
|
+
// rendered at its pre-reorder position.
|
|
208
|
+
pendingObserver = new MutationObserver(flushPendingPortals)
|
|
209
|
+
pendingObserver.observe(document, { childList: true, subtree: true })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function cancelPendingPortal(element: HTMLElement): void {
|
|
214
|
+
const idx = pendingPortals.findIndex(p => p.element === element)
|
|
215
|
+
if (idx >= 0) pendingPortals.splice(idx, 1)
|
|
216
|
+
if (pendingPortals.length === 0 && pendingObserver) {
|
|
217
|
+
pendingObserver.disconnect()
|
|
218
|
+
pendingObserver = null
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Insertion rule (#2717): the portal's content is appended to `container`
|
|
224
|
+
* synchronously, at call time, and — when the component it belongs to is
|
|
225
|
+
* not yet in the document — re-appended once that component connects,
|
|
226
|
+
* which moves it to the container's end.
|
|
227
|
+
*
|
|
228
|
+
* - Owner already connected (hydration: SSR markup is in the document
|
|
229
|
+
* before `init` runs), or nothing to wait for: a single append, at the
|
|
230
|
+
* container's end — unchanged behaviour.
|
|
231
|
+
* - Owner not yet connected (a client-side mount: `materializeComponent`
|
|
232
|
+
* runs `init` — and so the `ref` callbacks that call this — BEFORE the
|
|
233
|
+
* bare-`createComponent` caller connects the root): the element is
|
|
234
|
+
* appended now all the same, and a reorder is queued; once the owner
|
|
235
|
+
* connects, pending portals are re-appended in creation order.
|
|
236
|
+
*
|
|
237
|
+
* Without the reorder the two construction paths disagree on
|
|
238
|
+
* `document.body`'s child order: hydration yields `[root, …portals]`
|
|
239
|
+
* while a CSR mount yields `[…portals, root]`, because the root is
|
|
240
|
+
* appended AFTER its portals were. Child order is user-visible (paint
|
|
241
|
+
* order between equal-`z-index` overlays, focus traversal, `querySelector`
|
|
242
|
+
* results), so both paths converge on the hydration answer.
|
|
243
|
+
*
|
|
244
|
+
* The append itself is never deferred: the element is connected to the
|
|
245
|
+
* document at every point in time, only its position among the
|
|
246
|
+
* container's children settles asynchronously (as a microtask, before the
|
|
247
|
+
* next paint). Portal consumers measure layout synchronously in the same
|
|
248
|
+
* tick as their `ref` callback — `createEffect`'s first run is synchronous,
|
|
249
|
+
* and the floating-position components (popover, dropdown-menu, context-
|
|
250
|
+
* menu, …) read `offsetWidth`/`offsetHeight` of the portaled element there,
|
|
251
|
+
* gated only on their open signal — so a portal that was briefly absent
|
|
252
|
+
* from the document would hand them zero-sized boxes with nothing to
|
|
253
|
+
* re-trigger the measurement once it landed.
|
|
254
|
+
*
|
|
255
|
+
* The subject whose connection is awaited is the `ownerScope` when given
|
|
256
|
+
* and it lies outside the element; otherwise the element's former parent.
|
|
257
|
+
* The self-owner shape (`ownerScope === element`: a child component whose
|
|
258
|
+
* own root carries `bf-s`, e.g. DialogOverlay/DialogContent) needs that
|
|
259
|
+
* fallback, since the immediate append connects the element — and so the
|
|
260
|
+
* owner — right away, while the component under construction is the tree
|
|
261
|
+
* it was taken from. An element in a detached tree with no owner (a
|
|
262
|
+
* fragment-root component, whose scope lives on a comment) is the same
|
|
263
|
+
* case. A bare element with neither has nothing that will ever connect
|
|
264
|
+
* it, so there is nothing to wait for.
|
|
265
|
+
*/
|
|
129
266
|
export function createPortal(
|
|
130
267
|
children: PortalChildren,
|
|
131
268
|
container: HTMLElement = document.body,
|
|
@@ -161,11 +298,28 @@ export function createPortal(
|
|
|
161
298
|
}
|
|
162
299
|
}
|
|
163
300
|
|
|
301
|
+
// The reorder subject, resolved BEFORE the append moves the element:
|
|
302
|
+
// the declared owner when it lies outside the element, else the
|
|
303
|
+
// element's former parent (see the insertion rule above). Only a parent
|
|
304
|
+
// the caller handed us counts — the string path parses into a fragment,
|
|
305
|
+
// which never connects; the same goes for a caller-built fragment.
|
|
306
|
+
const owner = options?.ownerScope
|
|
307
|
+
const formerParent = children instanceof HTMLElement ? children.parentNode : null
|
|
308
|
+
const subject: Node | null =
|
|
309
|
+
owner && !element.contains(owner) ? owner : formerParent instanceof Element ? formerParent : null
|
|
310
|
+
|
|
164
311
|
container.appendChild(element)
|
|
165
312
|
|
|
313
|
+
// `MutationObserver` is the reorder primitive; an environment without it
|
|
314
|
+
// keeps the plain synchronous append (the pre-#2717 behaviour).
|
|
315
|
+
if (subject && !subject.isConnected && typeof MutationObserver !== 'undefined') {
|
|
316
|
+
enqueuePendingPortal({ element, container, subject })
|
|
317
|
+
}
|
|
318
|
+
|
|
166
319
|
return {
|
|
167
320
|
element,
|
|
168
321
|
unmount(): void {
|
|
322
|
+
cancelPendingPortal(element)
|
|
169
323
|
if (element.parentNode) {
|
|
170
324
|
element.parentNode.removeChild(element)
|
|
171
325
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BarefootJS - Floating-element position tracking
|
|
3
|
+
*
|
|
4
|
+
* Keeps a `position: fixed` overlay (menu, popover, listbox, hover card)
|
|
5
|
+
* anchored to its trigger for as long as it is open. Shared by every
|
|
6
|
+
* site/ui overlay that positions itself from `getBoundingClientRect()`
|
|
7
|
+
* so the decision below is made in one place (#2848).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Run `update` now, re-run it on every scroll (capture phase, so a
|
|
12
|
+
* nested scroll container counts too) and on resize, and return the
|
|
13
|
+
* dispose that detaches both listeners.
|
|
14
|
+
*
|
|
15
|
+
* The dispose re-runs `update` ONCE, synchronously, before detaching —
|
|
16
|
+
* that final sample is the whole point of this helper. `scroll` events
|
|
17
|
+
* are coalesced per rendering frame and report the scroll position at
|
|
18
|
+
* dispatch time, not at scroll time. A programmatic scroll that landed
|
|
19
|
+
* in the current frame (a `focus()` on an offscreen item, a
|
|
20
|
+
* `scrollIntoView()`) has therefore not dispatched yet when a close
|
|
21
|
+
* runs in the same frame; the listener is gone by the time the event
|
|
22
|
+
* fires, and whatever position the listener would have written is lost.
|
|
23
|
+
* Without the final sample the closed element's inline position depends
|
|
24
|
+
* on whether a frame boundary happened to fall between that scroll and
|
|
25
|
+
* the close — measured as the `dropdown-menu` idempotence oracle
|
|
26
|
+
* landing on `top: -580px` / `-606px` / `33px` for the same action
|
|
27
|
+
* sequence. Sampling once at dispose makes the closed position a
|
|
28
|
+
* function of the geometry at close time only.
|
|
29
|
+
*
|
|
30
|
+
* (An `overflow: hidden` scroll lock does not narrow this window: it
|
|
31
|
+
* blocks user gestures, never programmatic scrolling, on `html` and
|
|
32
|
+
* `body` alike — verified in Chromium against the fixture-hydrate host.)
|
|
33
|
+
*
|
|
34
|
+
* @param update - Positions the element from current geometry.
|
|
35
|
+
* @returns Dispose: re-runs `update` once, then detaches the listeners.
|
|
36
|
+
*/
|
|
37
|
+
export function trackPosition(update: () => void): () => void {
|
|
38
|
+
update()
|
|
39
|
+
const onChange = () => update()
|
|
40
|
+
window.addEventListener('scroll', onChange, true)
|
|
41
|
+
window.addEventListener('resize', onChange)
|
|
42
|
+
return () => {
|
|
43
|
+
window.removeEventListener('scroll', onChange, true)
|
|
44
|
+
window.removeEventListener('resize', onChange)
|
|
45
|
+
update()
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/shims.ts
CHANGED
|
@@ -52,3 +52,7 @@ export function findSiblingSlot(
|
|
|
52
52
|
export function cleanupPortalPlaceholder(_portalId: string): void {
|
|
53
53
|
return browserOnly('cleanupPortalPlaceholder')
|
|
54
54
|
}
|
|
55
|
+
|
|
56
|
+
export function trackPosition(_update: () => void): () => void {
|
|
57
|
+
return browserOnly('trackPosition')
|
|
58
|
+
}
|