@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.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +45 -0
- package/src/AccessibleIcon.ts +26 -0
- package/src/Accordion.ts +282 -0
- package/src/AlertDialog.ts +110 -0
- package/src/Arrow.ts +21 -0
- package/src/AspectRatio.ts +34 -0
- package/src/Avatar.ts +152 -0
- package/src/Checkbox.ts +325 -0
- package/src/Collapsible.ts +170 -0
- package/src/ContextMenu.ts +303 -0
- package/src/Dialog.ts +308 -0
- package/src/DismissableLayer.ts +413 -0
- package/src/DropdownMenu.ts +275 -0
- package/src/FocusScope.ts +266 -0
- package/src/Form.ts +621 -0
- package/src/HoverCard.ts +318 -0
- package/src/Label.ts +25 -0
- package/src/Menu.ts +1057 -0
- package/src/Menubar.ts +572 -0
- package/src/NavigationMenu.ts +1236 -0
- package/src/OneTimePasswordField.ts +977 -0
- package/src/PasswordToggleField.ts +495 -0
- package/src/Popover.ts +354 -0
- package/src/Popper.ts +401 -0
- package/src/Portal.ts +19 -0
- package/src/Presence.ts +225 -0
- package/src/Primitive.ts +53 -0
- package/src/Progress.ts +92 -0
- package/src/Radio.ts +262 -0
- package/src/RadioGroup.ts +212 -0
- package/src/RovingFocusGroup.ts +259 -0
- package/src/ScrollArea.ts +966 -0
- package/src/Select.ts +1901 -0
- package/src/Separator.ts +36 -0
- package/src/Slider.ts +745 -0
- package/src/Slot.ts +105 -0
- package/src/Switch.ts +278 -0
- package/src/Tabs.ts +177 -0
- package/src/Toast.ts +923 -0
- package/src/Toggle.ts +31 -0
- package/src/ToggleGroup.ts +157 -0
- package/src/Toolbar.ts +130 -0
- package/src/Tooltip.ts +669 -0
- package/src/VisuallyHidden.ts +29 -0
- package/src/collection.ts +97 -0
- package/src/compose-event-handlers.ts +15 -0
- package/src/compose-refs.ts +53 -0
- package/src/context.ts +109 -0
- package/src/direction.ts +19 -0
- package/src/focus-guards.ts +56 -0
- package/src/index.ts +54 -0
- package/src/internal.ts +42 -0
- package/src/scroll-lock.ts +57 -0
- package/src/use-callback-ref.ts +28 -0
- package/src/use-effect-event.ts +36 -0
- package/src/use-is-hydrated.ts +23 -0
- package/src/use-previous.ts +28 -0
- package/src/use-size.ts +57 -0
- package/src/useControllableState.ts +78 -0
- 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 };
|
package/src/Presence.ts
ADDED
|
@@ -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 };
|
package/src/Primitive.ts
ADDED
|
@@ -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
|
+
}
|