@octanejs/radix 0.1.2

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. package/src/useId.ts +15 -0
package/src/Popper.ts ADDED
@@ -0,0 +1,401 @@
1
+ // Ported from @radix-ui/react-popper (source:
2
+ // .radix-primitives/packages/react/popper/src/popper.tsx). The positioning primitive
3
+ // behind Tooltip/Popover/HoverCard/Menu: Anchor registers the reference element, Content
4
+ // positions against it via Floating UI (offset/shift/flip/size/arrow/hide + Radix's
5
+ // transform-origin middleware, exposing the --radix-popper-* CSS vars), Arrow renders
6
+ // inside a positioning wrapper. `@floating-ui/react-dom`'s useFloating →
7
+ // `@octanejs/floating-ui`'s bare positioning core (`usePositionFloating`).
8
+ import {
9
+ arrow as floatingUIarrow,
10
+ autoUpdate,
11
+ flip,
12
+ hide,
13
+ limitShift,
14
+ offset,
15
+ shift,
16
+ size,
17
+ usePositionFloating,
18
+ } from '@octanejs/floating-ui';
19
+ import {
20
+ createElement,
21
+ useCallback,
22
+ useEffect,
23
+ useEffectEvent,
24
+ useLayoutEffect,
25
+ useRef,
26
+ useState,
27
+ } from 'octane';
28
+
29
+ import * as ArrowPrimitive from './Arrow';
30
+ import { useComposedRefs } from './compose-refs';
31
+ import { createContextScope } from './context';
32
+ import { S, subSlot } from './internal';
33
+ import { Primitive } from './Primitive';
34
+ import { useSize } from './use-size';
35
+
36
+ export const SIDE_OPTIONS = ['top', 'right', 'bottom', 'left'] as const;
37
+ export const ALIGN_OPTIONS = ['start', 'center', 'end'] as const;
38
+ type Side = (typeof SIDE_OPTIONS)[number];
39
+ type Align = (typeof ALIGN_OPTIONS)[number];
40
+
41
+ const [createPopperContext, createPopperScope] = createContextScope('Popper');
42
+ export { createPopperScope };
43
+
44
+ interface PopperContextValue {
45
+ anchor: any;
46
+ onAnchorChange(anchor: any): void;
47
+ placementState: string | undefined;
48
+ setPlacementState: (p: string | undefined) => void;
49
+ }
50
+ const [PopperProvider, usePopperContext] = createPopperContext<PopperContextValue>('Popper');
51
+
52
+ const [PopperContentProvider, useContentContext] = createPopperContext<{
53
+ placedSide: Side;
54
+ placedAlign: Align;
55
+ onArrowChange(arrow: HTMLSpanElement | null): void;
56
+ arrowX?: number;
57
+ arrowY?: number;
58
+ shouldHideArrow: boolean;
59
+ }>('PopperContent');
60
+
61
+ export function Root(props: any): any {
62
+ const slot = S('Popper.Root');
63
+ const { __scopePopper, children } = props ?? {};
64
+ const [anchor, setAnchor] = useState<any>(null, subSlot(slot, 'anchor'));
65
+ const [placementState, setPlacementState] = useState<string | undefined>(
66
+ undefined,
67
+ subSlot(slot, 'placement'),
68
+ );
69
+ return createElement(PopperProvider, {
70
+ scope: __scopePopper,
71
+ anchor,
72
+ onAnchorChange: setAnchor,
73
+ placementState,
74
+ setPlacementState,
75
+ children,
76
+ });
77
+ }
78
+
79
+ export function Anchor(props: any): any {
80
+ const slot = S('Popper.Anchor');
81
+ const { __scopePopper, virtualRef, ref: forwardedRef, ...anchorProps } = props ?? {};
82
+ const context = usePopperContext('PopperAnchor', __scopePopper);
83
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
84
+ const onAnchorChange = context.onAnchorChange;
85
+
86
+ // For DOM anchors, set the anchor from the callback ref (commit phase) rather than an
87
+ // effect — mounting many Popper-based components at once must not cascade renders
88
+ // (radix-ui/primitives#3858).
89
+ const callbackRef = useCallback(
90
+ (node: HTMLElement | null) => {
91
+ ref.current = node;
92
+ if (node) onAnchorChange(node);
93
+ },
94
+ [onAnchorChange],
95
+ subSlot(slot, 'cb'),
96
+ );
97
+ const composedRefs = useComposedRefs(forwardedRef, callbackRef, subSlot(slot, 'refs'));
98
+
99
+ const anchorRef = useRef<any>(null, subSlot(slot, 'virtual'));
100
+ useEffect(
101
+ () => {
102
+ if (!virtualRef) return;
103
+ const previousAnchor = anchorRef.current;
104
+ anchorRef.current = virtualRef.current;
105
+ if (previousAnchor !== anchorRef.current) {
106
+ onAnchorChange(anchorRef.current);
107
+ }
108
+ },
109
+ undefined,
110
+ subSlot(slot, 'e:virtual'),
111
+ );
112
+
113
+ const sideAndAlign =
114
+ context.placementState && getSideAndAlignFromPlacement(context.placementState);
115
+ const placedSide = sideAndAlign ? sideAndAlign[0] : undefined;
116
+ const placedAlign = sideAndAlign ? sideAndAlign[1] : undefined;
117
+
118
+ return virtualRef
119
+ ? null
120
+ : createElement(Primitive.div, {
121
+ 'data-radix-popper-side': placedSide,
122
+ 'data-radix-popper-align': placedAlign,
123
+ ...anchorProps,
124
+ ref: composedRefs,
125
+ });
126
+ }
127
+
128
+ export function Content(props: any): any {
129
+ const slot = S('Popper.Content');
130
+ const {
131
+ __scopePopper,
132
+ side = 'bottom',
133
+ sideOffset = 0,
134
+ align = 'center',
135
+ alignOffset = 0,
136
+ arrowPadding = 0,
137
+ avoidCollisions = true,
138
+ collisionBoundary = [],
139
+ collisionPadding: collisionPaddingProp = 0,
140
+ sticky = 'partial',
141
+ hideWhenDetached = false,
142
+ updatePositionStrategy = 'optimized',
143
+ onPlaced,
144
+ ref: forwardedRef,
145
+ ...contentProps
146
+ } = props ?? {};
147
+
148
+ const context = usePopperContext('PopperContent', __scopePopper);
149
+
150
+ const [content, setContent] = useState<HTMLDivElement | null>(null, subSlot(slot, 'content'));
151
+ const composedRefs = useComposedRefs(forwardedRef, setContent, subSlot(slot, 'refs'));
152
+
153
+ const [arrow, setArrow] = useState<HTMLSpanElement | null>(null, subSlot(slot, 'arrow'));
154
+ const arrowSize = useSize(arrow, subSlot(slot, 'arrowSize'));
155
+ const arrowWidth = arrowSize?.width ?? 0;
156
+ const arrowHeight = arrowSize?.height ?? 0;
157
+
158
+ const desiredPlacement = side + (align !== 'center' ? '-' + align : '');
159
+
160
+ const collisionPadding =
161
+ typeof collisionPaddingProp === 'number'
162
+ ? collisionPaddingProp
163
+ : { top: 0, right: 0, bottom: 0, left: 0, ...collisionPaddingProp };
164
+
165
+ const boundary = Array.isArray(collisionBoundary) ? collisionBoundary : [collisionBoundary];
166
+ const hasExplicitBoundaries = boundary.length > 0;
167
+
168
+ const detectOverflowOptions = {
169
+ padding: collisionPadding,
170
+ boundary: boundary.filter(isNotNull),
171
+ // with `strategy: 'fixed'`, this is the only way to get it to respect boundaries
172
+ altBoundary: hasExplicitBoundaries,
173
+ };
174
+
175
+ const { refs, floatingStyles, placement, isPositioned, middlewareData } = usePositionFloating([
176
+ {
177
+ // default to `fixed` strategy so users don't have to pick and we also avoid
178
+ // focus scroll issues
179
+ strategy: 'fixed',
180
+ placement: desiredPlacement,
181
+ whileElementsMounted: (...args: any[]) => {
182
+ const cleanup = (autoUpdate as any)(...args, {
183
+ animationFrame: updatePositionStrategy === 'always',
184
+ });
185
+ return cleanup;
186
+ },
187
+ elements: { reference: context.anchor },
188
+ middleware: [
189
+ offset({ mainAxis: sideOffset + arrowHeight, alignmentAxis: alignOffset }),
190
+ avoidCollisions &&
191
+ shift({
192
+ mainAxis: true,
193
+ crossAxis: false,
194
+ limiter: sticky === 'partial' ? limitShift() : undefined,
195
+ ...detectOverflowOptions,
196
+ }),
197
+ avoidCollisions && flip({ ...detectOverflowOptions }),
198
+ size({
199
+ ...detectOverflowOptions,
200
+ apply: ({ elements, rects, availableWidth, availableHeight }: any) => {
201
+ const { width: anchorWidth, height: anchorHeight } = rects.reference;
202
+ const contentStyle = elements.floating.style;
203
+ contentStyle.setProperty('--radix-popper-available-width', `${availableWidth}px`);
204
+ contentStyle.setProperty('--radix-popper-available-height', `${availableHeight}px`);
205
+ contentStyle.setProperty('--radix-popper-anchor-width', `${anchorWidth}px`);
206
+ contentStyle.setProperty('--radix-popper-anchor-height', `${anchorHeight}px`);
207
+ },
208
+ }),
209
+ arrow && floatingUIarrow({ element: arrow, padding: arrowPadding }),
210
+ transformOrigin({ arrowWidth, arrowHeight }),
211
+ hideWhenDetached &&
212
+ hide({
213
+ strategy: 'referenceHidden',
214
+ ...detectOverflowOptions,
215
+ // `hide` detects whether the anchor is clipped — with no explicit
216
+ // collisionBoundary fall back to Floating UI's default clipping
217
+ // ancestors so an occluded submenu hides when its anchor scrolls out
218
+ // of view (radix-ui/primitives#3237).
219
+ boundary: hasExplicitBoundaries ? detectOverflowOptions.boundary : undefined,
220
+ }),
221
+ ],
222
+ },
223
+ subSlot(slot, 'floating'),
224
+ ]);
225
+
226
+ const setPlacementState = context.setPlacementState;
227
+ useLayoutEffect(
228
+ () => {
229
+ setPlacementState(placement);
230
+ return () => setPlacementState(undefined);
231
+ },
232
+ [placement],
233
+ subSlot(slot, 'e:placement'),
234
+ );
235
+
236
+ const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement);
237
+
238
+ const handlePlaced = useEffectEvent(onPlaced ?? (() => {}), subSlot(slot, 'placed'));
239
+ useLayoutEffect(
240
+ () => {
241
+ if (isPositioned) handlePlaced();
242
+ },
243
+ [isPositioned],
244
+ subSlot(slot, 'e:placed'),
245
+ );
246
+
247
+ const arrowX = middlewareData.arrow?.x;
248
+ const arrowY = middlewareData.arrow?.y;
249
+ const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
250
+
251
+ const [contentZIndex, setContentZIndex] = useState<string | undefined>(
252
+ undefined,
253
+ subSlot(slot, 'z'),
254
+ );
255
+ useLayoutEffect(
256
+ () => {
257
+ if (content) setContentZIndex(window.getComputedStyle(content).zIndex);
258
+ },
259
+ [content],
260
+ subSlot(slot, 'e:z'),
261
+ );
262
+
263
+ return createElement('div', {
264
+ ref: refs.setFloating,
265
+ 'data-radix-popper-content-wrapper': '',
266
+ style: {
267
+ ...floatingStyles,
268
+ // keep off the page when measuring
269
+ transform: isPositioned ? floatingStyles.transform : 'translate(0, -200%)',
270
+ minWidth: 'max-content',
271
+ zIndex: contentZIndex,
272
+ '--radix-popper-transform-origin': [
273
+ middlewareData.transformOrigin?.x,
274
+ middlewareData.transformOrigin?.y,
275
+ ].join(' '),
276
+ // hide the content if using the hide middleware and should be hidden — set
277
+ // visibility to hidden and disable pointer events so the UI behaves as if the
278
+ // PopperContent isn't there at all
279
+ ...(middlewareData.hide?.referenceHidden && {
280
+ visibility: 'hidden',
281
+ pointerEvents: 'none',
282
+ }),
283
+ },
284
+ // Floating UI internally calculates logical alignment based on the `dir` attribute
285
+ // on the reference/floating node — add it here so it's computed when portalled too.
286
+ dir: props?.dir,
287
+ children: createElement(PopperContentProvider, {
288
+ scope: __scopePopper,
289
+ placedSide,
290
+ placedAlign,
291
+ onArrowChange: setArrow,
292
+ arrowX,
293
+ arrowY,
294
+ shouldHideArrow: cannotCenterArrow,
295
+ children: createElement(Primitive.div, {
296
+ 'data-side': placedSide,
297
+ 'data-align': placedAlign,
298
+ ...contentProps,
299
+ ref: composedRefs,
300
+ style: {
301
+ ...contentProps.style,
302
+ // if the PopperContent hasn't been placed yet (not all measurements done)
303
+ // prevent animations so they don't kick in referring to the wrong side
304
+ animation: !isPositioned ? 'none' : undefined,
305
+ },
306
+ }),
307
+ }),
308
+ });
309
+ }
310
+
311
+ const OPPOSITE_SIDE: Record<Side, Side> = {
312
+ top: 'bottom',
313
+ right: 'left',
314
+ bottom: 'top',
315
+ left: 'right',
316
+ };
317
+
318
+ export function Arrow(props: any): any {
319
+ const { __scopePopper, ...arrowProps } = props ?? {};
320
+ const contentContext = useContentContext('PopperArrow', __scopePopper);
321
+ const baseSide = OPPOSITE_SIDE[contentContext.placedSide];
322
+ // The extra span wrapper is required because ResizeObserver reports SVG bounding
323
+ // boxes (the largest path), not layout size.
324
+ return createElement('span', {
325
+ ref: contentContext.onArrowChange,
326
+ style: {
327
+ position: 'absolute',
328
+ left: contentContext.arrowX,
329
+ top: contentContext.arrowY,
330
+ [baseSide]: 0,
331
+ transformOrigin: {
332
+ top: '',
333
+ right: '0 0',
334
+ bottom: 'center 0',
335
+ left: '100% 0',
336
+ }[contentContext.placedSide],
337
+ transform: {
338
+ top: 'translateY(100%)',
339
+ right: 'translateY(50%) rotate(90deg) translateX(-50%)',
340
+ bottom: `rotate(180deg)`,
341
+ left: 'translateY(50%) rotate(-90deg) translateX(50%)',
342
+ }[contentContext.placedSide],
343
+ visibility: contentContext.shouldHideArrow ? 'hidden' : undefined,
344
+ },
345
+ children: createElement(ArrowPrimitive.Root, {
346
+ ...arrowProps,
347
+ style: {
348
+ ...arrowProps.style,
349
+ // ensures the element can be measured correctly (mostly for if SVG)
350
+ display: 'block',
351
+ },
352
+ }),
353
+ });
354
+ }
355
+
356
+ function isNotNull<T>(value: T | null): value is T {
357
+ return value !== null;
358
+ }
359
+
360
+ const transformOrigin = (options: { arrowWidth: number; arrowHeight: number }): any => ({
361
+ name: 'transformOrigin',
362
+ options,
363
+ fn(data: any) {
364
+ const { placement, rects, middlewareData } = data;
365
+
366
+ const cannotCenterArrow = middlewareData.arrow?.centerOffset !== 0;
367
+ const isArrowHidden = cannotCenterArrow;
368
+ const arrowWidth = isArrowHidden ? 0 : options.arrowWidth;
369
+ const arrowHeight = isArrowHidden ? 0 : options.arrowHeight;
370
+
371
+ const [placedSide, placedAlign] = getSideAndAlignFromPlacement(placement);
372
+ const noArrowAlign = { start: '0%', center: '50%', end: '100%' }[placedAlign] as string;
373
+
374
+ const arrowXCenter = (middlewareData.arrow?.x ?? 0) + arrowWidth / 2;
375
+ const arrowYCenter = (middlewareData.arrow?.y ?? 0) + arrowHeight / 2;
376
+
377
+ let x = '';
378
+ let y = '';
379
+ if (placedSide === 'bottom') {
380
+ x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
381
+ y = `${-arrowHeight}px`;
382
+ } else if (placedSide === 'top') {
383
+ x = isArrowHidden ? noArrowAlign : `${arrowXCenter}px`;
384
+ y = `${rects.floating.height + arrowHeight}px`;
385
+ } else if (placedSide === 'right') {
386
+ x = `${-arrowHeight}px`;
387
+ y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
388
+ } else if (placedSide === 'left') {
389
+ x = `${rects.floating.width + arrowHeight}px`;
390
+ y = isArrowHidden ? noArrowAlign : `${arrowYCenter}px`;
391
+ }
392
+ return { data: { x, y } };
393
+ },
394
+ });
395
+
396
+ function getSideAndAlignFromPlacement(placement: string): [Side, Align] {
397
+ const [side, align = 'center'] = placement.split('-');
398
+ return [side as Side, align as Align];
399
+ }
400
+
401
+ export { Root as Popper, Anchor as PopperAnchor, Content as PopperContent, Arrow as PopperArrow };
package/src/Portal.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Ported from @radix-ui/react-portal. Renders `Primitive.div` (or, with `asChild`, the
2
+ // child itself) into `container` (default: document.body) — React's ReactDOM.createPortal
3
+ // → octane's `createPortal`-as-a-value. The `mounted` layout-effect gate mirrors Radix's
4
+ // SSR-safety dance (no document during server render; flips on first client commit).
5
+ import { createElement, createPortal, useLayoutEffect, useState } from 'octane';
6
+
7
+ import { S, subSlot } from './internal';
8
+ import { Primitive } from './Primitive';
9
+
10
+ export function Portal(props: any): any {
11
+ const slot = S('Portal');
12
+ const { container: containerProp, ...portalProps } = props ?? {};
13
+ const [mounted, setMounted] = useState(false, subSlot(slot, 'mounted'));
14
+ useLayoutEffect(() => setMounted(true), [], subSlot(slot, 'e:mount'));
15
+ const container = containerProp || (mounted && globalThis?.document?.body);
16
+ return container ? createPortal(createElement(Primitive.div, portalProps), container) : null;
17
+ }
18
+
19
+ export { Portal as Root };
@@ -0,0 +1,225 @@
1
+ // Ported from @radix-ui/react-presence (source:
2
+ // .radix-primitives/packages/react/presence/src/presence.tsx). Keeps a child mounted
3
+ // through its CSS exit animation: when `present` flips to false it stays mounted until
4
+ // `animationend`/`animationcancel` (or unmounts immediately if no animation is running).
5
+ // Pure DOM + octane hooks — in environments without CSS animations (jsdom) it collapses
6
+ // to a plain present↔mounted conditional.
7
+ import {
8
+ Children,
9
+ cloneElement,
10
+ isValidElement,
11
+ useCallback,
12
+ useEffect,
13
+ useLayoutEffect,
14
+ useRef,
15
+ useState,
16
+ } from 'octane';
17
+
18
+ import { S, subSlot } from './internal';
19
+
20
+ type MachineState = 'mounted' | 'unmountSuspended' | 'unmounted';
21
+
22
+ const TRANSITIONS: Record<MachineState, Partial<Record<string, MachineState>>> = {
23
+ mounted: { UNMOUNT: 'unmounted', ANIMATION_OUT: 'unmountSuspended' },
24
+ unmountSuspended: { MOUNT: 'mounted', ANIMATION_END: 'unmounted' },
25
+ unmounted: { MOUNT: 'mounted' },
26
+ };
27
+
28
+ function getAnimationName(styles?: CSSStyleDeclaration | null): string {
29
+ return styles?.animationName || 'none';
30
+ }
31
+
32
+ function usePresence(
33
+ present: boolean,
34
+ slot: symbol,
35
+ ): { isPresent: boolean; ref: (el: any) => void } {
36
+ const [node, setNode] = useState<HTMLElement | null>(null, subSlot(slot, 'node'));
37
+ const stylesRef = useRef<CSSStyleDeclaration | null>(null, subSlot(slot, 'styles'));
38
+ const prevPresentRef = useRef(present, subSlot(slot, 'prevPresent'));
39
+ const prevAnimationNameRef = useRef<string>('none', subSlot(slot, 'prevAnim'));
40
+ const [state, setState] = useState<MachineState>(
41
+ present ? 'mounted' : 'unmounted',
42
+ subSlot(slot, 'state'),
43
+ );
44
+
45
+ const send = useCallback(
46
+ (event: string) => {
47
+ setState((prev: MachineState) => TRANSITIONS[prev][event] ?? prev);
48
+ },
49
+ [],
50
+ subSlot(slot, 'send'),
51
+ );
52
+
53
+ useEffect(
54
+ () => {
55
+ const currentAnimationName = getAnimationName(stylesRef.current);
56
+ prevAnimationNameRef.current = state === 'mounted' ? currentAnimationName : 'none';
57
+ },
58
+ [state],
59
+ subSlot(slot, 'e:anim'),
60
+ );
61
+
62
+ useLayoutEffect(
63
+ () => {
64
+ const styles = stylesRef.current;
65
+ const wasPresent = prevPresentRef.current;
66
+ const hasPresentChanged = wasPresent !== present;
67
+ if (hasPresentChanged) {
68
+ const prevAnimationName = prevAnimationNameRef.current;
69
+ const currentAnimationName = getAnimationName(styles);
70
+ if (present) {
71
+ send('MOUNT');
72
+ } else if (currentAnimationName === 'none' || styles?.display === 'none') {
73
+ // If there is no exit animation or the element is hidden, animations won't
74
+ // run so we unmount instantly.
75
+ send('UNMOUNT');
76
+ } else {
77
+ // When `present` changes to `false`, we check changes to animation-name to
78
+ // determine whether an animation has started (computed styles, because there
79
+ // is no `animationrun` event and `animationstart` fires after
80
+ // `animation-delay` has expired — too late).
81
+ const isAnimating = prevAnimationName !== currentAnimationName;
82
+ send(wasPresent && isAnimating ? 'ANIMATION_OUT' : 'UNMOUNT');
83
+ }
84
+ prevPresentRef.current = present;
85
+ }
86
+ },
87
+ [present],
88
+ subSlot(slot, 'e:present'),
89
+ );
90
+
91
+ useLayoutEffect(
92
+ () => {
93
+ if (node) {
94
+ let timeoutId: any;
95
+ const ownerWindow = node.ownerDocument.defaultView ?? window;
96
+ // Triggering an ANIMATION_OUT during an ANIMATION_IN fires `animationcancel`
97
+ // for ANIMATION_IN after entering `unmountSuspended` — only honor the
98
+ // currently-active animation.
99
+ const handleAnimationEnd = (event: AnimationEvent): void => {
100
+ const currentAnimationName = getAnimationName(stylesRef.current);
101
+ // event.animationName is unescaped CSS syntax; escape to compare with the
102
+ // computed animation-name.
103
+ const escaped =
104
+ typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
105
+ ? CSS.escape(event.animationName)
106
+ : event.animationName;
107
+ const isCurrentAnimation = currentAnimationName.includes(escaped);
108
+ if (event.target === node && isCurrentAnimation) {
109
+ send('ANIMATION_END');
110
+ // Force the last keyframe's styles while the (kept-mounted) node waits
111
+ // for the state to commit, removing a flash of pre-animation content.
112
+ if (!prevPresentRef.current) {
113
+ const currentFillMode = node.style.animationFillMode;
114
+ node.style.animationFillMode = 'forwards';
115
+ // Reset after the node had time to unmount (for cases where the
116
+ // consumer chooses not to unmount). Sooner than setTimeout (e.g.
117
+ // rAF) still flashes.
118
+ timeoutId = ownerWindow.setTimeout(() => {
119
+ if (node.style.animationFillMode === 'forwards') {
120
+ node.style.animationFillMode = currentFillMode;
121
+ }
122
+ });
123
+ }
124
+ }
125
+ };
126
+ const handleAnimationStart = (event: AnimationEvent): void => {
127
+ if (event.target === node) {
128
+ // An animation started: record its name as the previous animation.
129
+ prevAnimationNameRef.current = getAnimationName(stylesRef.current);
130
+ }
131
+ };
132
+ node.addEventListener('animationstart', handleAnimationStart);
133
+ node.addEventListener('animationcancel', handleAnimationEnd);
134
+ node.addEventListener('animationend', handleAnimationEnd);
135
+ return () => {
136
+ ownerWindow.clearTimeout(timeoutId);
137
+ node.removeEventListener('animationstart', handleAnimationStart);
138
+ node.removeEventListener('animationcancel', handleAnimationEnd);
139
+ node.removeEventListener('animationend', handleAnimationEnd);
140
+ };
141
+ } else {
142
+ // Transition to unmounted if the node is removed prematurely (not during
143
+ // cleanup — the node may change but still exist).
144
+ send('ANIMATION_END');
145
+ }
146
+ },
147
+ [node],
148
+ subSlot(slot, 'e:node'),
149
+ );
150
+
151
+ return {
152
+ isPresent: state === 'mounted' || state === 'unmountSuspended',
153
+ ref: useCallback(
154
+ (el: HTMLElement | null) => {
155
+ stylesRef.current = el ? getComputedStyle(el) : null;
156
+ setNode(el);
157
+ },
158
+ [],
159
+ subSlot(slot, 'ref'),
160
+ ),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * Compose refs with a callback whose identity NEVER changes, even when the composed refs
166
+ * do (the latest refs are read at attach/detach time). Radix added this for the exact
167
+ * loop class we also hit: a per-render composed-ref identity makes the renderer
168
+ * detach/re-attach every commit, and since Presence's own ref calls `setNode`, an
169
+ * unstable consumer ref would loop forever (radix-ui/primitives#3664).
170
+ */
171
+ function useStableComposedRefs(
172
+ refs: any[],
173
+ slot: symbol,
174
+ ): (node: HTMLElement | null) => void | (() => void) {
175
+ const refsRef = useRef(refs, subSlot(slot, 'refsRef'));
176
+ refsRef.current = refs;
177
+ return useCallback(
178
+ (node: HTMLElement | null) => {
179
+ const currentRefs = refsRef.current;
180
+ let hasCleanup = false;
181
+ const cleanups = currentRefs.map((ref) => {
182
+ const cleanup = setRefValue(ref, node);
183
+ if (!hasCleanup && typeof cleanup === 'function') hasCleanup = true;
184
+ return cleanup;
185
+ });
186
+ if (hasCleanup) {
187
+ return () => {
188
+ for (let i = 0; i < cleanups.length; i++) {
189
+ const cleanup = cleanups[i];
190
+ if (typeof cleanup === 'function') cleanup();
191
+ else setRefValue(currentRefs[i], null);
192
+ }
193
+ };
194
+ }
195
+ },
196
+ [],
197
+ subSlot(slot, 'cb'),
198
+ );
199
+ }
200
+
201
+ function setRefValue(ref: any, value: HTMLElement | null): void | (() => void) {
202
+ if (typeof ref === 'function') return ref(value);
203
+ if (ref !== null && ref !== undefined) ref.current = value;
204
+ }
205
+
206
+ /**
207
+ * `<Presence present>{child}</Presence>` — renders `child` while present or
208
+ * exit-animating. `children` may be a single element or a render function
209
+ * `({ present }) => element` (forceMount: it always renders and forwards `present`).
210
+ */
211
+ export function Presence(props: any): any {
212
+ const { present, children } = props;
213
+ const slot = S('Presence');
214
+ const presence = usePresence(present, slot);
215
+ const forceMount = typeof children === 'function';
216
+ const child = forceMount ? children({ present: presence.isPresent }) : Children.only(children);
217
+ const childRef = isValidElement(child) ? (child as any).props?.ref : undefined;
218
+ const ref = useStableComposedRefs([presence.ref, childRef], slot);
219
+ if (forceMount || presence.isPresent) {
220
+ return isValidElement(child) ? cloneElement(child as any, { ref }) : child;
221
+ }
222
+ return null;
223
+ }
224
+
225
+ export { Presence as Root };
@@ -0,0 +1,53 @@
1
+ // Ported from @radix-ui/react-primitive. `Primitive.<tag>` renders a host element, or —
2
+ // when `asChild` is set — merges its props onto the single element child via `Slot`.
3
+ // octane is ref-as-prop, so `ref` flows through `props` like any other prop.
4
+ import { createElement, flushSync } from 'octane';
5
+
6
+ import { Slot } from './Slot';
7
+
8
+ /**
9
+ * Dispatch a custom event inside a synchronous flush (React's `ReactDOM.flushSync`
10
+ * dispatch) so any state updates the event handlers make are committed before the next
11
+ * frame — needed when dispatching from a native event that OCTANE/React didn't schedule
12
+ * (e.g. DismissableLayer's `pointerDownOutside` re-dispatch).
13
+ */
14
+ export function dispatchDiscreteCustomEvent(target: EventTarget | null, event: CustomEvent): void {
15
+ if (target) flushSync(() => target.dispatchEvent(event));
16
+ }
17
+
18
+ // The host tags Radix exposes as primitives (add more as components need them).
19
+ const NODES = [
20
+ 'a',
21
+ 'button',
22
+ 'div',
23
+ 'form',
24
+ 'h2',
25
+ 'h3',
26
+ 'img',
27
+ 'input',
28
+ 'label',
29
+ 'li',
30
+ 'nav',
31
+ 'ol',
32
+ 'p',
33
+ 'select',
34
+ 'span',
35
+ 'svg',
36
+ 'ul',
37
+ ] as const;
38
+
39
+ type PrimitiveNode = (typeof NODES)[number];
40
+
41
+ function makePrimitive(node: string) {
42
+ return function Primitive(props: any): any {
43
+ const { asChild, ...primitiveProps } = props ?? {};
44
+ // Slot must be rendered as a COMPONENT (its memoized-ref hook needs its own
45
+ // scope — two direct calls in one caller scope would collide).
46
+ return asChild ? createElement(Slot, primitiveProps) : createElement(node, primitiveProps);
47
+ };
48
+ }
49
+
50
+ export const Primitive = {} as Record<PrimitiveNode, (props: any) => any>;
51
+ for (const node of NODES) {
52
+ Primitive[node] = makePrimitive(node);
53
+ }