@octanejs/base-ui 0.1.1 → 0.1.3

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 (67) hide show
  1. package/README.md +7 -1
  2. package/package.json +12 -4
  3. package/src/alert-dialog.ts +47 -0
  4. package/src/checkbox.ts +7 -16
  5. package/src/dialog.ts +859 -0
  6. package/src/field.ts +4 -30
  7. package/src/index.ts +3 -0
  8. package/src/number-field.ts +8 -45
  9. package/src/popover.ts +1205 -0
  10. package/src/radio.ts +3 -24
  11. package/src/slider.ts +4 -5
  12. package/src/switch.ts +4 -30
  13. package/src/utils/InternalBackdrop.ts +28 -0
  14. package/src/utils/adaptiveOriginMiddleware.ts +82 -0
  15. package/src/utils/closePart.ts +61 -0
  16. package/src/utils/constants.ts +9 -0
  17. package/src/utils/createChangeEventDetails.ts +7 -0
  18. package/src/utils/dom.ts +9 -0
  19. package/src/utils/empty.ts +7 -0
  20. package/src/utils/floating/FloatingFocusManager.ts +833 -0
  21. package/src/utils/floating/FloatingPortal.ts +268 -0
  22. package/src/utils/floating/FloatingRootStore.ts +127 -0
  23. package/src/utils/floating/FloatingTree.ts +70 -0
  24. package/src/utils/floating/FloatingTreeStore.ts +22 -0
  25. package/src/utils/floating/FocusGuard.ts +34 -0
  26. package/src/utils/floating/composite.ts +20 -0
  27. package/src/utils/floating/constants.ts +8 -0
  28. package/src/utils/floating/createAttribute.ts +4 -0
  29. package/src/utils/floating/createEventEmitter.ts +20 -0
  30. package/src/utils/floating/element.ts +54 -0
  31. package/src/utils/floating/enqueueFocus.ts +38 -0
  32. package/src/utils/floating/event.ts +53 -0
  33. package/src/utils/floating/getEmptyRootContext.ts +19 -0
  34. package/src/utils/floating/markOthers.ts +197 -0
  35. package/src/utils/floating/nodes.ts +31 -0
  36. package/src/utils/floating/tabbable.ts +260 -0
  37. package/src/utils/floating/types.ts +57 -0
  38. package/src/utils/floating/useClick.ts +174 -0
  39. package/src/utils/floating/useDismiss.ts +641 -0
  40. package/src/utils/floating/useFloating.ts +198 -0
  41. package/src/utils/floating/useFloatingRootContext.ts +81 -0
  42. package/src/utils/floating/useHoverFloatingInteraction.ts +12 -0
  43. package/src/utils/floating/useHoverReferenceInteraction.ts +17 -0
  44. package/src/utils/floating/useSyncedFloatingRootContext.ts +93 -0
  45. package/src/utils/getDisabledMountTransitionStyles.ts +12 -0
  46. package/src/utils/hideMiddleware.ts +22 -0
  47. package/src/utils/inertValue.ts +5 -0
  48. package/src/utils/mergeCleanups.ts +13 -0
  49. package/src/utils/platform.ts +46 -2
  50. package/src/utils/popupStateMapping.ts +48 -0
  51. package/src/utils/popups/index.ts +4 -0
  52. package/src/utils/popups/popupStoreUtils.ts +484 -0
  53. package/src/utils/popups/popupTriggerMap.ts +62 -0
  54. package/src/utils/popups/store.ts +147 -0
  55. package/src/utils/popups/useTriggerFocusGuards.ts +73 -0
  56. package/src/utils/store/ReactStore.ts +168 -0
  57. package/src/utils/store/Store.ts +84 -0
  58. package/src/utils/store/createSelector.ts +66 -0
  59. package/src/utils/store/useStore.ts +49 -0
  60. package/src/utils/useAnchorPositioning.ts +581 -0
  61. package/src/utils/useAnchoredPopupScrollLock.ts +50 -0
  62. package/src/utils/useAnimationFrame.ts +28 -5
  63. package/src/utils/useEnhancedClickHandler.ts +47 -0
  64. package/src/utils/useOnFirstRender.ts +11 -0
  65. package/src/utils/useOpenInteractionType.ts +32 -0
  66. package/src/utils/usePositioner.ts +48 -0
  67. package/src/utils/useScrollLock.ts +253 -0
@@ -0,0 +1,581 @@
1
+ // Ported from .base-ui/packages/react/src/utils/useAnchorPositioning.ts (v1.6.0), octane-adapted
2
+ // (slot-threaded). Provides standardized anchor positioning for popups: wraps the local Store-based
3
+ // `useFloating` (which itself wraps `@octanejs/floating-ui`'s `usePositionFloating`) and configures
4
+ // the offset/flip/shift/size/arrow/hide middleware exactly as Base UI does. Middleware factories and
5
+ // `@floating-ui/utils` helpers come from `@octanejs/floating-ui` / `@floating-ui/utils` — the same
6
+ // underlying `@floating-ui/dom` computation Base UI uses, so `positionerStyles` byte-match.
7
+ import { useState, useRef, useMemo, useEffect, useLayoutEffect } from 'octane';
8
+ import { getSide, getAlignment, getSideAxis, type Rect } from '@floating-ui/utils';
9
+ import { autoUpdate, flip, limitShift, offset, shift, size, arrow } from '@octanejs/floating-ui';
10
+
11
+ import { S, subSlot } from '../internal';
12
+ import { ownerDocument, ownerWindow } from './owner';
13
+ import { useValueAsRef } from './useValueAsRef';
14
+ import { useStableCallback } from './useStableCallback';
15
+ import { useDirection } from './DirectionContext';
16
+ import { useFloating } from './floating/useFloating';
17
+ import { hide } from './hideMiddleware';
18
+ import { DEFAULT_SIDES } from './adaptiveOriginMiddleware';
19
+
20
+ export type Side = 'top' | 'bottom' | 'left' | 'right' | 'inline-end' | 'inline-start';
21
+ export type Align = 'start' | 'center' | 'end';
22
+ export type Boundary = 'clipping-ancestors' | Element | Element[] | Rect;
23
+ export type OffsetFunction = (data: {
24
+ side: Side;
25
+ align: Align;
26
+ anchor: { width: number; height: number };
27
+ positioner: { width: number; height: number };
28
+ }) => number;
29
+
30
+ type PhysicalSide = 'top' | 'bottom' | 'left' | 'right';
31
+
32
+ function getLogicalSide(sideParam: Side, renderedSide: PhysicalSide, isRtl: boolean): Side {
33
+ const isLogicalSideParam = sideParam === 'inline-start' || sideParam === 'inline-end';
34
+ const logicalRight = isRtl ? 'inline-start' : 'inline-end';
35
+ const logicalLeft = isRtl ? 'inline-end' : 'inline-start';
36
+ return (
37
+ {
38
+ top: 'top',
39
+ right: isLogicalSideParam ? logicalRight : 'right',
40
+ bottom: 'bottom',
41
+ left: isLogicalSideParam ? logicalLeft : 'left',
42
+ } as Record<PhysicalSide, Side>
43
+ )[renderedSide];
44
+ }
45
+
46
+ function getOffsetData(state: any, sideParam: Side, isRtl: boolean) {
47
+ const { rects, placement } = state;
48
+ const data = {
49
+ side: getLogicalSide(sideParam, getSide(placement) as PhysicalSide, isRtl),
50
+ align: getAlignment(placement) || 'center',
51
+ anchor: { width: rects.reference.width, height: rects.reference.height },
52
+ positioner: { width: rects.floating.width, height: rects.floating.height },
53
+ } as const;
54
+ return data;
55
+ }
56
+
57
+ export interface CollisionAvoidance {
58
+ side?: 'flip' | 'shift' | 'none' | undefined;
59
+ align?: 'flip' | 'shift' | 'none' | undefined;
60
+ fallbackAxisSide?: 'start' | 'end' | 'none' | undefined;
61
+ }
62
+
63
+ export interface UseAnchorPositioningSharedParameters {
64
+ anchor?: any;
65
+ positionMethod?: 'absolute' | 'fixed' | undefined;
66
+ side?: Side | undefined;
67
+ sideOffset?: number | OffsetFunction | undefined;
68
+ align?: Align | undefined;
69
+ alignOffset?: number | OffsetFunction | undefined;
70
+ collisionBoundary?: Boundary | undefined;
71
+ collisionPadding?: any;
72
+ sticky?: boolean | undefined;
73
+ arrowPadding?: number | undefined;
74
+ disableAnchorTracking?: boolean | undefined;
75
+ collisionAvoidance?: CollisionAvoidance | undefined;
76
+ }
77
+
78
+ export interface UseAnchorPositioningParameters extends UseAnchorPositioningSharedParameters {
79
+ keepMounted?: boolean | undefined;
80
+ floatingRootContext?: any;
81
+ mounted: boolean;
82
+ disableAnchorTracking: boolean;
83
+ nodeId?: string | undefined;
84
+ adaptiveOrigin?: any;
85
+ collisionAvoidance: CollisionAvoidance;
86
+ shiftCrossAxis?: boolean | undefined;
87
+ lazyFlip?: boolean | undefined;
88
+ externalTree?: any;
89
+ inline?: any;
90
+ }
91
+
92
+ export interface UseAnchorPositioningReturnValue {
93
+ positionerStyles: Record<string, any>;
94
+ arrowStyles: Record<string, any>;
95
+ arrowRef: { current: Element | null };
96
+ arrowUncentered: boolean;
97
+ side: Side;
98
+ align: Align;
99
+ physicalSide: PhysicalSide;
100
+ anchorHidden: boolean;
101
+ refs: any;
102
+ context: any;
103
+ isPositioned: boolean;
104
+ update: () => void;
105
+ }
106
+
107
+ /**
108
+ * Provides standardized anchor positioning behavior for floating elements. Wraps Floating UI's
109
+ * `useFloating` hook.
110
+ */
111
+ export function useAnchorPositioning(
112
+ params: UseAnchorPositioningParameters,
113
+ slotArg?: symbol | undefined,
114
+ ): UseAnchorPositioningReturnValue {
115
+ const slot = slotArg ?? S('useAnchorPositioning');
116
+ const {
117
+ // Public parameters
118
+ anchor,
119
+ positionMethod = 'absolute',
120
+ side: sideParam = 'bottom',
121
+ sideOffset = 0,
122
+ align = 'center',
123
+ alignOffset = 0,
124
+ collisionBoundary,
125
+ collisionPadding: collisionPaddingParam = 5,
126
+ sticky = false,
127
+ arrowPadding = 5,
128
+ disableAnchorTracking = false,
129
+ inline: inlineMiddleware,
130
+ // Private parameters
131
+ keepMounted = false,
132
+ floatingRootContext,
133
+ mounted,
134
+ collisionAvoidance,
135
+ shiftCrossAxis = false,
136
+ nodeId,
137
+ adaptiveOrigin,
138
+ lazyFlip = false,
139
+ externalTree,
140
+ } = params;
141
+
142
+ const [mountSide, setMountSide] = useState<PhysicalSide | null>(null, subSlot(slot, 'mountSide'));
143
+
144
+ if (!mounted && mountSide !== null) {
145
+ setMountSide(null);
146
+ }
147
+
148
+ const collisionAvoidanceSide = collisionAvoidance.side || 'flip';
149
+ const collisionAvoidanceAlign = collisionAvoidance.align || 'flip';
150
+ const collisionAvoidanceFallbackAxisSide = collisionAvoidance.fallbackAxisSide || 'end';
151
+
152
+ const anchorFn = typeof anchor === 'function' ? anchor : undefined;
153
+ const anchorFnCallback = useStableCallback(anchorFn, subSlot(slot, 'anchorFn'));
154
+ const anchorDep = anchorFn ? anchorFnCallback : anchor;
155
+ const anchorValueRef = useValueAsRef(anchor, subSlot(slot, 'anchorRef'));
156
+ const mountedRef = useValueAsRef(mounted, subSlot(slot, 'mountedRef'));
157
+
158
+ const direction = useDirection();
159
+ const isRtl = direction === 'rtl';
160
+
161
+ const side =
162
+ mountSide ||
163
+ (
164
+ {
165
+ top: 'top',
166
+ right: 'right',
167
+ bottom: 'bottom',
168
+ left: 'left',
169
+ 'inline-end': isRtl ? 'left' : 'right',
170
+ 'inline-start': isRtl ? 'right' : 'left',
171
+ } as Record<Side, PhysicalSide>
172
+ )[sideParam];
173
+
174
+ const placement = align === 'center' ? side : `${side}-${align}`;
175
+
176
+ let collisionPadding = collisionPaddingParam as {
177
+ top: number;
178
+ right: number;
179
+ bottom: number;
180
+ left: number;
181
+ };
182
+
183
+ // Create a bias to the preferred side.
184
+ const bias = 1;
185
+ const biasTop = sideParam === 'bottom' ? bias : 0;
186
+ const biasBottom = sideParam === 'top' ? bias : 0;
187
+ const biasLeft = sideParam === 'right' ? bias : 0;
188
+ const biasRight = sideParam === 'left' ? bias : 0;
189
+
190
+ if (typeof collisionPadding === 'number') {
191
+ collisionPadding = {
192
+ top: collisionPadding + biasTop,
193
+ right: collisionPadding + biasRight,
194
+ bottom: collisionPadding + biasBottom,
195
+ left: collisionPadding + biasLeft,
196
+ };
197
+ } else if (collisionPadding) {
198
+ collisionPadding = {
199
+ top: (collisionPadding.top || 0) + biasTop,
200
+ right: (collisionPadding.right || 0) + biasRight,
201
+ bottom: (collisionPadding.bottom || 0) + biasBottom,
202
+ left: (collisionPadding.left || 0) + biasLeft,
203
+ };
204
+ }
205
+
206
+ const commonCollisionProps = {
207
+ boundary: collisionBoundary === 'clipping-ancestors' ? 'clippingAncestors' : collisionBoundary,
208
+ padding: collisionPadding,
209
+ } as const;
210
+
211
+ const arrowRef = useRef<Element | null>(null, subSlot(slot, 'arrowRef'));
212
+
213
+ // Keep these reactive if they're not functions
214
+ const sideOffsetRef = useValueAsRef(sideOffset, subSlot(slot, 'sideOffsetRef'));
215
+ const alignOffsetRef = useValueAsRef(alignOffset, subSlot(slot, 'alignOffsetRef'));
216
+ const sideOffsetDep = typeof sideOffset !== 'function' ? sideOffset : 0;
217
+ const alignOffsetDep = typeof alignOffset !== 'function' ? alignOffset : 0;
218
+
219
+ const middleware: any[] = [];
220
+
221
+ if (inlineMiddleware) {
222
+ middleware.push(inlineMiddleware);
223
+ }
224
+
225
+ // @floating-ui/dom middleware factories take options only; Base UI's trailing deps arrays are a
226
+ // @floating-ui/react-dom memoization affordance — @octanejs/floating-ui recomputes via deepEqual
227
+ // on the middleware array instead, so the deps are dropped here. `sideOffsetDep`/`alignOffsetDep`
228
+ // remain referenced below (in flip/shift config) to keep the reactive read explicit.
229
+ void sideOffsetDep;
230
+ void alignOffsetDep;
231
+
232
+ middleware.push(
233
+ offset((state: any) => {
234
+ const data = getOffsetData(state, sideParam, isRtl);
235
+
236
+ const sideAxis =
237
+ typeof sideOffsetRef.current === 'function'
238
+ ? sideOffsetRef.current(data)
239
+ : sideOffsetRef.current;
240
+ const alignAxis =
241
+ typeof alignOffsetRef.current === 'function'
242
+ ? alignOffsetRef.current(data)
243
+ : alignOffsetRef.current;
244
+
245
+ return {
246
+ mainAxis: sideAxis,
247
+ crossAxis: alignAxis,
248
+ alignmentAxis: alignAxis,
249
+ };
250
+ }),
251
+ );
252
+
253
+ const shiftDisabled = collisionAvoidanceAlign === 'none' && collisionAvoidanceSide !== 'shift';
254
+ const crossAxisShiftEnabled =
255
+ !shiftDisabled && (sticky || shiftCrossAxis || collisionAvoidanceSide === 'shift');
256
+
257
+ const flipMiddleware =
258
+ collisionAvoidanceSide === 'none'
259
+ ? null
260
+ : flip({
261
+ ...commonCollisionProps,
262
+ padding: {
263
+ top: collisionPadding.top + bias,
264
+ right: collisionPadding.right + bias,
265
+ bottom: collisionPadding.bottom + bias,
266
+ left: collisionPadding.left + bias,
267
+ },
268
+ mainAxis: !shiftCrossAxis && collisionAvoidanceSide === 'flip',
269
+ crossAxis: collisionAvoidanceAlign === 'flip' ? 'alignment' : false,
270
+ fallbackAxisSideDirection: collisionAvoidanceFallbackAxisSide,
271
+ });
272
+ const shiftMiddleware = shiftDisabled
273
+ ? null
274
+ : shift((data: any) => {
275
+ const html = ownerDocument(data.elements.floating).documentElement;
276
+ return {
277
+ ...commonCollisionProps,
278
+ rootBoundary: shiftCrossAxis
279
+ ? { x: 0, y: 0, width: html.clientWidth, height: html.clientHeight }
280
+ : undefined,
281
+ mainAxis: collisionAvoidanceAlign !== 'none',
282
+ crossAxis: crossAxisShiftEnabled,
283
+ limiter:
284
+ sticky || shiftCrossAxis
285
+ ? undefined
286
+ : limitShift((limitData: any) => {
287
+ if (!arrowRef.current) {
288
+ return {};
289
+ }
290
+ const { width, height } = arrowRef.current.getBoundingClientRect();
291
+ const sideAxis = getSideAxis(getSide(limitData.placement));
292
+ const arrowSize = sideAxis === 'y' ? width : height;
293
+ const offsetAmount =
294
+ sideAxis === 'y'
295
+ ? collisionPadding.left + collisionPadding.right
296
+ : collisionPadding.top + collisionPadding.bottom;
297
+ return {
298
+ offset: arrowSize / 2 + offsetAmount / 2,
299
+ };
300
+ }),
301
+ };
302
+ });
303
+
304
+ // https://floating-ui.com/docs/flip#combining-with-shift
305
+ if (
306
+ collisionAvoidanceSide === 'shift' ||
307
+ collisionAvoidanceAlign === 'shift' ||
308
+ align === 'center'
309
+ ) {
310
+ middleware.push(shiftMiddleware, flipMiddleware);
311
+ } else {
312
+ middleware.push(flipMiddleware, shiftMiddleware);
313
+ }
314
+
315
+ middleware.push(
316
+ size({
317
+ ...commonCollisionProps,
318
+ apply({ elements: { floating }, availableWidth, availableHeight, rects }: any) {
319
+ if (!mountedRef.current) {
320
+ return;
321
+ }
322
+
323
+ const floatingStyle = floating.style;
324
+ floatingStyle.setProperty('--available-width', `${availableWidth}px`);
325
+ floatingStyle.setProperty('--available-height', `${availableHeight}px`);
326
+
327
+ const dpr = ownerWindow(floating).devicePixelRatio || 1;
328
+ const { x, y, width, height } = rects.reference;
329
+ const anchorWidth = (Math.round((x + width) * dpr) - Math.round(x * dpr)) / dpr;
330
+ const anchorHeight = (Math.round((y + height) * dpr) - Math.round(y * dpr)) / dpr;
331
+
332
+ floatingStyle.setProperty('--anchor-width', `${anchorWidth}px`);
333
+ floatingStyle.setProperty('--anchor-height', `${anchorHeight}px`);
334
+ },
335
+ }),
336
+ arrow((state: any) => ({
337
+ element: arrowRef.current || ownerDocument(state.elements.floating).createElement('div'),
338
+ padding: arrowPadding,
339
+ offsetParent: 'floating',
340
+ })),
341
+ {
342
+ name: 'transformOrigin',
343
+ fn(state: any) {
344
+ const { elements, middlewareData, placement: renderedPlacement, rects, y } = state;
345
+
346
+ const currentRenderedSide = getSide(renderedPlacement);
347
+ const currentRenderedAxis = getSideAxis(currentRenderedSide);
348
+ const arrowEl = arrowRef.current;
349
+ const arrowX = middlewareData.arrow?.x || 0;
350
+ const arrowY = middlewareData.arrow?.y || 0;
351
+ const arrowWidth = (arrowEl as any)?.clientWidth || 0;
352
+ const arrowHeight = (arrowEl as any)?.clientHeight || 0;
353
+ const transformX = arrowX + arrowWidth / 2;
354
+ const transformY = arrowY + arrowHeight / 2;
355
+ const shiftY = Math.abs(middlewareData.shift?.y || 0);
356
+ const halfAnchorHeight = rects.reference.height / 2;
357
+ const sideOffsetValue =
358
+ typeof sideOffset === 'function'
359
+ ? sideOffset(getOffsetData(state, sideParam, isRtl))
360
+ : sideOffset;
361
+ const isOverlappingAnchor = shiftY > sideOffsetValue;
362
+
363
+ const adjacentTransformOrigin = {
364
+ top: `${transformX}px calc(100% + ${sideOffsetValue}px)`,
365
+ bottom: `${transformX}px ${-sideOffsetValue}px`,
366
+ left: `calc(100% + ${sideOffsetValue}px) ${transformY}px`,
367
+ right: `${-sideOffsetValue}px ${transformY}px`,
368
+ }[currentRenderedSide];
369
+ const overlapTransformOrigin = `${transformX}px ${rects.reference.y + halfAnchorHeight - y}px`;
370
+
371
+ elements.floating.style.setProperty(
372
+ '--transform-origin',
373
+ crossAxisShiftEnabled && currentRenderedAxis === 'y' && isOverlappingAnchor
374
+ ? overlapTransformOrigin
375
+ : adjacentTransformOrigin,
376
+ );
377
+
378
+ return {};
379
+ },
380
+ },
381
+ hide,
382
+ adaptiveOrigin,
383
+ );
384
+
385
+ useLayoutEffect(
386
+ () => {
387
+ // Ensure positioning doesn't run initially for `keepMounted` elements that
388
+ // aren't initially open.
389
+ if (!mounted && floatingRootContext) {
390
+ floatingRootContext.update({
391
+ referenceElement: null,
392
+ floatingElement: null,
393
+ domReferenceElement: null,
394
+ positionReference: null,
395
+ });
396
+ }
397
+ },
398
+ [mounted, floatingRootContext],
399
+ subSlot(slot, 'e:reset'),
400
+ );
401
+
402
+ const autoUpdateOptions = useMemo(
403
+ () => ({
404
+ elementResize: !disableAnchorTracking && typeof ResizeObserver !== 'undefined',
405
+ layoutShift: !disableAnchorTracking && typeof IntersectionObserver !== 'undefined',
406
+ }),
407
+ [disableAnchorTracking],
408
+ subSlot(slot, 'm:auto'),
409
+ );
410
+
411
+ const {
412
+ refs,
413
+ elements,
414
+ x,
415
+ y,
416
+ middlewareData,
417
+ update,
418
+ placement: renderedPlacement,
419
+ context,
420
+ isPositioned,
421
+ floatingStyles: originalFloatingStyles,
422
+ } = useFloating(
423
+ {
424
+ rootContext: floatingRootContext,
425
+ open: keepMounted ? mounted : undefined,
426
+ placement,
427
+ middleware,
428
+ strategy: positionMethod,
429
+ whileElementsMounted: keepMounted
430
+ ? undefined
431
+ : (...args: any[]) => (autoUpdate as any)(...args, autoUpdateOptions),
432
+ nodeId,
433
+ externalTree,
434
+ },
435
+ subSlot(slot, 'floating'),
436
+ );
437
+
438
+ const { sideX, sideY } = middlewareData.adaptiveOrigin || DEFAULT_SIDES;
439
+
440
+ // Default to `fixed` when not positioned to prevent `autoFocus` scroll jumps.
441
+ const resolvedPosition: 'absolute' | 'fixed' = isPositioned ? positionMethod : 'fixed';
442
+
443
+ const floatingStyles = useMemo<Record<string, any>>(
444
+ () => {
445
+ const base: Record<string, any> = adaptiveOrigin
446
+ ? { position: resolvedPosition, [sideX]: x, [sideY]: y }
447
+ : { position: resolvedPosition, ...originalFloatingStyles };
448
+ if (!isPositioned) {
449
+ base.opacity = 0;
450
+ }
451
+ return base;
452
+ },
453
+ [adaptiveOrigin, resolvedPosition, sideX, x, sideY, y, originalFloatingStyles, isPositioned],
454
+ subSlot(slot, 'm:fs'),
455
+ );
456
+
457
+ const registeredPositionReferenceRef = useRef<any>(null, subSlot(slot, 'regPosRef'));
458
+
459
+ useLayoutEffect(
460
+ () => {
461
+ if (!mounted) {
462
+ return;
463
+ }
464
+
465
+ const anchorValue = anchorValueRef.current;
466
+ const resolvedAnchor = typeof anchorValue === 'function' ? anchorValue() : anchorValue;
467
+ const unwrappedElement =
468
+ (isRef(resolvedAnchor) ? resolvedAnchor.current : resolvedAnchor) || null;
469
+ const finalAnchor = unwrappedElement || null;
470
+
471
+ if (finalAnchor !== registeredPositionReferenceRef.current) {
472
+ refs.setPositionReference(finalAnchor);
473
+ registeredPositionReferenceRef.current = finalAnchor;
474
+ }
475
+ },
476
+ [mounted, refs, anchorDep, anchorValueRef],
477
+ subSlot(slot, 'e:posref1'),
478
+ );
479
+
480
+ useEffect(
481
+ () => {
482
+ if (!mounted) {
483
+ return;
484
+ }
485
+
486
+ const anchorValue = anchorValueRef.current;
487
+
488
+ // Refs from parent components are set after useLayoutEffect runs and are available in useEffect.
489
+ if (typeof anchorValue === 'function') {
490
+ return;
491
+ }
492
+
493
+ if (isRef(anchorValue) && anchorValue.current !== registeredPositionReferenceRef.current) {
494
+ refs.setPositionReference(anchorValue.current);
495
+ registeredPositionReferenceRef.current = anchorValue.current;
496
+ }
497
+ },
498
+ [mounted, refs, anchorDep, anchorValueRef],
499
+ subSlot(slot, 'e:posref2'),
500
+ );
501
+
502
+ useEffect(
503
+ () => {
504
+ if (keepMounted && mounted && elements.reference && elements.floating) {
505
+ return (autoUpdate as any)(
506
+ elements.reference,
507
+ elements.floating,
508
+ update,
509
+ autoUpdateOptions,
510
+ );
511
+ }
512
+ return undefined;
513
+ },
514
+ [keepMounted, mounted, elements, update, autoUpdateOptions],
515
+ subSlot(slot, 'e:auto'),
516
+ );
517
+
518
+ const renderedSide = getSide(renderedPlacement) as PhysicalSide;
519
+ const logicalRenderedSide = getLogicalSide(sideParam, renderedSide, isRtl);
520
+ const renderedAlign = (getAlignment(renderedPlacement) || 'center') as Align;
521
+ const anchorHidden = Boolean(middlewareData.hide?.referenceHidden);
522
+
523
+ // Locks the flip (makes it "sticky") so it doesn't prefer a given placement.
524
+ useLayoutEffect(
525
+ () => {
526
+ if (lazyFlip && mounted && isPositioned) {
527
+ setMountSide(renderedSide);
528
+ }
529
+ },
530
+ [lazyFlip, mounted, isPositioned, renderedSide],
531
+ subSlot(slot, 'e:lazyflip'),
532
+ );
533
+
534
+ const arrowStyles = useMemo(
535
+ () => ({
536
+ position: 'absolute' as const,
537
+ top: middlewareData.arrow?.y,
538
+ left: middlewareData.arrow?.x,
539
+ }),
540
+ [middlewareData.arrow],
541
+ subSlot(slot, 'm:arrow'),
542
+ );
543
+
544
+ const arrowUncentered = middlewareData.arrow?.centerOffset !== 0;
545
+
546
+ return useMemo(
547
+ () => ({
548
+ positionerStyles: floatingStyles,
549
+ arrowStyles,
550
+ arrowRef,
551
+ arrowUncentered,
552
+ side: logicalRenderedSide,
553
+ align: renderedAlign,
554
+ physicalSide: renderedSide,
555
+ anchorHidden,
556
+ refs,
557
+ context,
558
+ isPositioned,
559
+ update,
560
+ }),
561
+ [
562
+ floatingStyles,
563
+ arrowStyles,
564
+ arrowRef,
565
+ arrowUncentered,
566
+ logicalRenderedSide,
567
+ renderedAlign,
568
+ renderedSide,
569
+ anchorHidden,
570
+ refs,
571
+ context,
572
+ isPositioned,
573
+ update,
574
+ ],
575
+ subSlot(slot, 'm:ret'),
576
+ );
577
+ }
578
+
579
+ function isRef(param: any): param is { current: any } {
580
+ return param != null && 'current' in param;
581
+ }
@@ -0,0 +1,50 @@
1
+ // Ported from .base-ui/packages/react/src/utils/useAnchoredPopupScrollLock.ts (v1.6.0),
2
+ // octane-adapted (slot-threaded). Touch-opened popups normally skip scroll lock so a swipe outside
3
+ // can still dismiss; this re-enables scroll lock only when the popup is effectively full-width
4
+ // (leaving too little outside space for a reliable swipe).
5
+ import { useState, useLayoutEffect } from 'octane';
6
+
7
+ import { S, subSlot } from '../internal';
8
+ import { ownerDocument } from './owner';
9
+ import { useScrollLock } from './useScrollLock';
10
+
11
+ const VIEWPORT_WIDTH_TOLERANCE_PX = 20;
12
+
13
+ export function useAnchoredPopupScrollLock(
14
+ enabled: boolean,
15
+ touchOpen: boolean,
16
+ positionerElement: HTMLElement | null,
17
+ referenceElement: Element | null,
18
+ ): void {
19
+ const slot = S('useAnchoredPopupScrollLock');
20
+ const [touchOpenShouldLockScroll, setTouchOpenShouldLockScroll] = useState(
21
+ false,
22
+ subSlot(slot, 'lock'),
23
+ );
24
+
25
+ useLayoutEffect(
26
+ () => {
27
+ if (!enabled || !touchOpen || positionerElement == null) {
28
+ setTouchOpenShouldLockScroll(false);
29
+ return;
30
+ }
31
+
32
+ const viewportWidth = ownerDocument(positionerElement).documentElement.clientWidth;
33
+ const popupWidth = positionerElement.offsetWidth;
34
+
35
+ setTouchOpenShouldLockScroll(
36
+ viewportWidth > 0 &&
37
+ popupWidth > 0 &&
38
+ popupWidth >= viewportWidth - VIEWPORT_WIDTH_TOLERANCE_PX,
39
+ );
40
+ },
41
+ [enabled, touchOpen, positionerElement],
42
+ subSlot(slot, 'eff'),
43
+ );
44
+
45
+ useScrollLock(
46
+ enabled && (!touchOpen || touchOpenShouldLockScroll),
47
+ referenceElement,
48
+ subSlot(slot, 'scroll'),
49
+ );
50
+ }
@@ -9,6 +9,11 @@ import { useLayoutEffect, useMemo, useRef } from 'octane';
9
9
 
10
10
  import { S, splitSlot, subSlot } from '../internal';
11
11
 
12
+ export interface Frame {
13
+ request: (fn: FrameRequestCallback) => number;
14
+ cancel: () => void;
15
+ }
16
+
12
17
  export const AnimationFrame = {
13
18
  request(fn: FrameRequestCallback): number {
14
19
  return requestAnimationFrame(fn);
@@ -18,13 +23,31 @@ export const AnimationFrame = {
18
23
  cancelAnimationFrame(id);
19
24
  }
20
25
  },
26
+ // A per-instance frame that supersedes its own pending callback (like `useAnimationFrame`'s
27
+ // return, but usable outside a component — e.g. useScrollLock's resize handler).
28
+ create(): Frame {
29
+ let id: number | null = null;
30
+ return {
31
+ request(fn: FrameRequestCallback): number {
32
+ if (id != null) {
33
+ cancelAnimationFrame(id);
34
+ }
35
+ id = requestAnimationFrame((ts) => {
36
+ id = null;
37
+ fn(ts);
38
+ });
39
+ return id;
40
+ },
41
+ cancel(): void {
42
+ if (id != null) {
43
+ cancelAnimationFrame(id);
44
+ id = null;
45
+ }
46
+ },
47
+ };
48
+ },
21
49
  };
22
50
 
23
- export interface Frame {
24
- request: (fn: FrameRequestCallback) => number;
25
- cancel: () => void;
26
- }
27
-
28
51
  export function useAnimationFrame(...args: any[]): Frame {
29
52
  const [, slotArg] = splitSlot(['_', ...args]);
30
53
  const slot = slotArg ?? S('useAnimationFrame');