@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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-context-menu (source:
|
|
2
|
+
// .radix-primitives/packages/react/context-menu/src/context-menu.tsx). A right-click /
|
|
3
|
+
// long-press wrapper over the shared Menu primitive: Trigger renders a virtual Popper
|
|
4
|
+
// anchor pinned to the pointer position (contextmenu event, or a 700ms touch/pen long
|
|
5
|
+
// press) and Content force-positions side=right/align=start from that point. The
|
|
6
|
+
// dev-only "open before interaction" console.warn is not ported (repo policy: port
|
|
7
|
+
// functional outcomes only).
|
|
8
|
+
import { createElement, useCallback, useEffect, useRef } from 'octane';
|
|
9
|
+
|
|
10
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
11
|
+
import { createContextScope } from './context';
|
|
12
|
+
import { S, subSlot } from './internal';
|
|
13
|
+
import * as MenuPrimitive from './Menu';
|
|
14
|
+
import { createMenuScope } from './Menu';
|
|
15
|
+
import { Primitive } from './Primitive';
|
|
16
|
+
import { useControllableState } from './useControllableState';
|
|
17
|
+
|
|
18
|
+
type Point = { x: number; y: number };
|
|
19
|
+
|
|
20
|
+
const CONTEXT_MENU_NAME = 'ContextMenu';
|
|
21
|
+
|
|
22
|
+
const [createContextMenuContext, createContextMenuScope] = createContextScope(CONTEXT_MENU_NAME, [
|
|
23
|
+
createMenuScope,
|
|
24
|
+
]);
|
|
25
|
+
export { createContextMenuScope };
|
|
26
|
+
const useMenuScope = createMenuScope();
|
|
27
|
+
|
|
28
|
+
interface ContextMenuContextValue {
|
|
29
|
+
open: boolean;
|
|
30
|
+
onOpenChange(open: boolean): void;
|
|
31
|
+
modal: boolean;
|
|
32
|
+
hasInteractedRef: { current: boolean };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const [ContextMenuProvider, useContextMenuContext] =
|
|
36
|
+
createContextMenuContext<ContextMenuContextValue>(CONTEXT_MENU_NAME);
|
|
37
|
+
|
|
38
|
+
export function Root(props: any): any {
|
|
39
|
+
const slot = S('ContextMenu.Root');
|
|
40
|
+
const {
|
|
41
|
+
__scopeContextMenu,
|
|
42
|
+
children,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
open: openProp,
|
|
45
|
+
dir,
|
|
46
|
+
modal = true,
|
|
47
|
+
} = props ?? {};
|
|
48
|
+
const hasInteractedRef = useRef(false, subSlot(slot, 'interacted'));
|
|
49
|
+
const [open, setOpen] = useControllableState<boolean>(
|
|
50
|
+
{ prop: openProp, defaultProp: false, onChange: onOpenChange },
|
|
51
|
+
subSlot(slot, 'open'),
|
|
52
|
+
);
|
|
53
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
54
|
+
|
|
55
|
+
return createElement(ContextMenuProvider, {
|
|
56
|
+
scope: __scopeContextMenu,
|
|
57
|
+
open,
|
|
58
|
+
onOpenChange: setOpen,
|
|
59
|
+
modal,
|
|
60
|
+
hasInteractedRef,
|
|
61
|
+
children: createElement(MenuPrimitive.Root, {
|
|
62
|
+
...menuScope,
|
|
63
|
+
dir,
|
|
64
|
+
open,
|
|
65
|
+
onOpenChange: setOpen,
|
|
66
|
+
modal,
|
|
67
|
+
children,
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function Trigger(props: any): any {
|
|
73
|
+
const slot = S('ContextMenu.Trigger');
|
|
74
|
+
const { __scopeContextMenu, disabled = false, ref: forwardedRef, ...triggerProps } = props ?? {};
|
|
75
|
+
const context = useContextMenuContext('ContextMenuTrigger', __scopeContextMenu);
|
|
76
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
77
|
+
const pointRef = useRef<Point>({ x: 0, y: 0 }, subSlot(slot, 'point'));
|
|
78
|
+
const virtualRef = useRef(
|
|
79
|
+
{
|
|
80
|
+
getBoundingClientRect: () => DOMRect.fromRect({ width: 0, height: 0, ...pointRef.current }),
|
|
81
|
+
},
|
|
82
|
+
subSlot(slot, 'virtual'),
|
|
83
|
+
);
|
|
84
|
+
const longPressTimerRef = useRef(0, subSlot(slot, 'timer'));
|
|
85
|
+
const clearLongPress = useCallback(
|
|
86
|
+
() => window.clearTimeout(longPressTimerRef.current),
|
|
87
|
+
[],
|
|
88
|
+
subSlot(slot, 'clear'),
|
|
89
|
+
);
|
|
90
|
+
const handleOpen = (event: MouseEvent | PointerEvent): void => {
|
|
91
|
+
context.hasInteractedRef.current = true;
|
|
92
|
+
pointRef.current = { x: event.clientX, y: event.clientY };
|
|
93
|
+
context.onOpenChange(true);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
useEffect(() => clearLongPress, [clearLongPress], subSlot(slot, 'e:clear'));
|
|
97
|
+
useEffect(
|
|
98
|
+
() => void (disabled && clearLongPress()),
|
|
99
|
+
[disabled, clearLongPress],
|
|
100
|
+
subSlot(slot, 'e:disabled'),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
return [
|
|
104
|
+
createElement(MenuPrimitive.Anchor, { key: 'anchor', ...menuScope, virtualRef }),
|
|
105
|
+
createElement(Primitive.span, {
|
|
106
|
+
key: 'trigger',
|
|
107
|
+
'data-state': context.open ? 'open' : 'closed',
|
|
108
|
+
'data-disabled': disabled ? '' : undefined,
|
|
109
|
+
...triggerProps,
|
|
110
|
+
ref: forwardedRef,
|
|
111
|
+
// prevent iOS context menu from appearing
|
|
112
|
+
style: { WebkitTouchCallout: 'none', ...props?.style },
|
|
113
|
+
// if trigger is disabled, enable the native Context Menu
|
|
114
|
+
onContextMenu: disabled
|
|
115
|
+
? props?.onContextMenu
|
|
116
|
+
: composeEventHandlers(props?.onContextMenu, (event: MouseEvent) => {
|
|
117
|
+
// clearing the long press here because some platforms already support
|
|
118
|
+
// long press to trigger a `contextmenu` event
|
|
119
|
+
clearLongPress();
|
|
120
|
+
handleOpen(event);
|
|
121
|
+
event.preventDefault();
|
|
122
|
+
}),
|
|
123
|
+
onPointerDown: disabled
|
|
124
|
+
? props?.onPointerDown
|
|
125
|
+
: composeEventHandlers(
|
|
126
|
+
props?.onPointerDown,
|
|
127
|
+
whenTouchOrPen((event: PointerEvent) => {
|
|
128
|
+
// clear the long press here in case there's multiple touch points
|
|
129
|
+
clearLongPress();
|
|
130
|
+
if (context.open) {
|
|
131
|
+
context.onOpenChange(false);
|
|
132
|
+
}
|
|
133
|
+
longPressTimerRef.current = window.setTimeout(() => handleOpen(event), 700);
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
onPointerMove: disabled
|
|
137
|
+
? props?.onPointerMove
|
|
138
|
+
: composeEventHandlers(props?.onPointerMove, whenTouchOrPen(clearLongPress)),
|
|
139
|
+
onPointerCancel: disabled
|
|
140
|
+
? props?.onPointerCancel
|
|
141
|
+
: composeEventHandlers(props?.onPointerCancel, whenTouchOrPen(clearLongPress)),
|
|
142
|
+
onPointerUp: disabled
|
|
143
|
+
? props?.onPointerUp
|
|
144
|
+
: composeEventHandlers(props?.onPointerUp, whenTouchOrPen(clearLongPress)),
|
|
145
|
+
}),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function Portal(props: any): any {
|
|
150
|
+
const slot = S('ContextMenu.Portal');
|
|
151
|
+
const { __scopeContextMenu, ...portalProps } = props ?? {};
|
|
152
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
153
|
+
return createElement(MenuPrimitive.Portal, { ...menuScope, ...portalProps });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function Content(props: any): any {
|
|
157
|
+
const slot = S('ContextMenu.Content');
|
|
158
|
+
const { __scopeContextMenu, ...contentProps } = props ?? {};
|
|
159
|
+
const context = useContextMenuContext('ContextMenuContent', __scopeContextMenu);
|
|
160
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
161
|
+
const hasInteractedOutsideRef = useRef(false, subSlot(slot, 'outside'));
|
|
162
|
+
|
|
163
|
+
return createElement(MenuPrimitive.Content, {
|
|
164
|
+
...menuScope,
|
|
165
|
+
...contentProps,
|
|
166
|
+
side: 'right',
|
|
167
|
+
sideOffset: 2,
|
|
168
|
+
align: 'start',
|
|
169
|
+
onCloseAutoFocus: (event: Event) => {
|
|
170
|
+
props?.onCloseAutoFocus?.(event);
|
|
171
|
+
if (!event.defaultPrevented && hasInteractedOutsideRef.current) {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
}
|
|
174
|
+
hasInteractedOutsideRef.current = false;
|
|
175
|
+
},
|
|
176
|
+
onInteractOutside: (event: any) => {
|
|
177
|
+
props?.onInteractOutside?.(event);
|
|
178
|
+
if (!event.defaultPrevented && !context.modal) hasInteractedOutsideRef.current = true;
|
|
179
|
+
},
|
|
180
|
+
style: {
|
|
181
|
+
...props?.style,
|
|
182
|
+
// re-namespace exposed content custom properties
|
|
183
|
+
'--radix-context-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
|
|
184
|
+
'--radix-context-menu-content-available-width': 'var(--radix-popper-available-width)',
|
|
185
|
+
'--radix-context-menu-content-available-height': 'var(--radix-popper-available-height)',
|
|
186
|
+
'--radix-context-menu-trigger-width': 'var(--radix-popper-anchor-width)',
|
|
187
|
+
'--radix-context-menu-trigger-height': 'var(--radix-popper-anchor-height)',
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function Group(props: any): any {
|
|
193
|
+
const slot = S('ContextMenu.Group');
|
|
194
|
+
const { __scopeContextMenu, ...groupProps } = props ?? {};
|
|
195
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
196
|
+
return createElement(MenuPrimitive.Group, { ...menuScope, ...groupProps });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function Label(props: any): any {
|
|
200
|
+
const slot = S('ContextMenu.Label');
|
|
201
|
+
const { __scopeContextMenu, ...labelProps } = props ?? {};
|
|
202
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
203
|
+
return createElement(MenuPrimitive.Label, { ...menuScope, ...labelProps });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function Item(props: any): any {
|
|
207
|
+
const slot = S('ContextMenu.Item');
|
|
208
|
+
const { __scopeContextMenu, ...itemProps } = props ?? {};
|
|
209
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
210
|
+
return createElement(MenuPrimitive.Item, { ...menuScope, ...itemProps });
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function CheckboxItem(props: any): any {
|
|
214
|
+
const slot = S('ContextMenu.CheckboxItem');
|
|
215
|
+
const { __scopeContextMenu, ...checkboxItemProps } = props ?? {};
|
|
216
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
217
|
+
return createElement(MenuPrimitive.CheckboxItem, { ...menuScope, ...checkboxItemProps });
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function RadioGroup(props: any): any {
|
|
221
|
+
const slot = S('ContextMenu.RadioGroup');
|
|
222
|
+
const { __scopeContextMenu, ...radioGroupProps } = props ?? {};
|
|
223
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
224
|
+
return createElement(MenuPrimitive.RadioGroup, { ...menuScope, ...radioGroupProps });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function RadioItem(props: any): any {
|
|
228
|
+
const slot = S('ContextMenu.RadioItem');
|
|
229
|
+
const { __scopeContextMenu, ...radioItemProps } = props ?? {};
|
|
230
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
231
|
+
return createElement(MenuPrimitive.RadioItem, { ...menuScope, ...radioItemProps });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function ItemIndicator(props: any): any {
|
|
235
|
+
const slot = S('ContextMenu.ItemIndicator');
|
|
236
|
+
const { __scopeContextMenu, ...itemIndicatorProps } = props ?? {};
|
|
237
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
238
|
+
return createElement(MenuPrimitive.ItemIndicator, { ...menuScope, ...itemIndicatorProps });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function Separator(props: any): any {
|
|
242
|
+
const slot = S('ContextMenu.Separator');
|
|
243
|
+
const { __scopeContextMenu, ...separatorProps } = props ?? {};
|
|
244
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
245
|
+
return createElement(MenuPrimitive.Separator, { ...menuScope, ...separatorProps });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function Arrow(props: any): any {
|
|
249
|
+
const slot = S('ContextMenu.Arrow');
|
|
250
|
+
const { __scopeContextMenu, ...arrowProps } = props ?? {};
|
|
251
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
252
|
+
return createElement(MenuPrimitive.Arrow, { ...menuScope, ...arrowProps });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function Sub(props: any): any {
|
|
256
|
+
const slot = S('ContextMenu.Sub');
|
|
257
|
+
const { __scopeContextMenu, children, onOpenChange, open: openProp, defaultOpen } = props ?? {};
|
|
258
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
259
|
+
const [open, setOpen] = useControllableState<boolean>(
|
|
260
|
+
{ prop: openProp, defaultProp: defaultOpen ?? false, onChange: onOpenChange },
|
|
261
|
+
subSlot(slot, 'open'),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
return createElement(MenuPrimitive.Sub, { ...menuScope, open, onOpenChange: setOpen, children });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function SubTrigger(props: any): any {
|
|
268
|
+
const slot = S('ContextMenu.SubTrigger');
|
|
269
|
+
const { __scopeContextMenu, ...triggerItemProps } = props ?? {};
|
|
270
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
271
|
+
return createElement(MenuPrimitive.SubTrigger, { ...menuScope, ...triggerItemProps });
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export function SubContent(props: any): any {
|
|
275
|
+
const slot = S('ContextMenu.SubContent');
|
|
276
|
+
const { __scopeContextMenu, ...subContentProps } = props ?? {};
|
|
277
|
+
const menuScope = useMenuScope(__scopeContextMenu, subSlot(slot, 'menu'));
|
|
278
|
+
|
|
279
|
+
return createElement(MenuPrimitive.SubContent, {
|
|
280
|
+
...menuScope,
|
|
281
|
+
...subContentProps,
|
|
282
|
+
style: {
|
|
283
|
+
...props?.style,
|
|
284
|
+
// re-namespace exposed content custom properties
|
|
285
|
+
'--radix-context-menu-content-transform-origin': 'var(--radix-popper-transform-origin)',
|
|
286
|
+
'--radix-context-menu-content-available-width': 'var(--radix-popper-available-width)',
|
|
287
|
+
'--radix-context-menu-content-available-height': 'var(--radix-popper-available-height)',
|
|
288
|
+
'--radix-context-menu-trigger-width': 'var(--radix-popper-anchor-width)',
|
|
289
|
+
'--radix-context-menu-trigger-height': 'var(--radix-popper-anchor-height)',
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function whenTouchOrPen(handler: (event: PointerEvent) => void): (event: PointerEvent) => void {
|
|
295
|
+
return (event) => (event.pointerType !== 'mouse' ? handler(event) : undefined);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export {
|
|
299
|
+
Root as ContextMenu,
|
|
300
|
+
Trigger as ContextMenuTrigger,
|
|
301
|
+
Content as ContextMenuContent,
|
|
302
|
+
Item as ContextMenuItem,
|
|
303
|
+
};
|
package/src/Dialog.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Ported from @radix-ui/react-dialog. A modal (or non-modal) dialog: Trigger toggles the
|
|
2
|
+
// controllable `open`; Portal + Presence mount Overlay/Content into document.body;
|
|
3
|
+
// Content composes FocusScope (trap/loop + open/close autofocus) and DismissableLayer
|
|
4
|
+
// (Escape / pointer-down-outside / focus-outside → dismiss); modal content hides the rest
|
|
5
|
+
// of the app from ATs (`aria-hidden`'s hideOthers) and locks body scroll (see
|
|
6
|
+
// scroll-lock.ts for the react-remove-scroll divergence note).
|
|
7
|
+
import { Children, createElement, useCallback, useEffect, useRef } from 'octane';
|
|
8
|
+
import { hideOthers } from 'aria-hidden';
|
|
9
|
+
|
|
10
|
+
import { composeEventHandlers } from './compose-event-handlers';
|
|
11
|
+
import { useComposedRefs } from './compose-refs';
|
|
12
|
+
import { createContextScope } from './context';
|
|
13
|
+
import { DismissableLayer, useDismissableLayerSurface } from './DismissableLayer';
|
|
14
|
+
import { FocusScope } from './FocusScope';
|
|
15
|
+
import { useFocusGuards } from './focus-guards';
|
|
16
|
+
import { S, subSlot } from './internal';
|
|
17
|
+
import { Portal as PortalPrimitive } from './Portal';
|
|
18
|
+
import { Presence } from './Presence';
|
|
19
|
+
import { Primitive } from './Primitive';
|
|
20
|
+
import { useScrollLock } from './scroll-lock';
|
|
21
|
+
import { useControllableState } from './useControllableState';
|
|
22
|
+
import { useId } from './useId';
|
|
23
|
+
|
|
24
|
+
interface DialogContextValue {
|
|
25
|
+
triggerRef: { current: HTMLElement | null };
|
|
26
|
+
contentRef: { current: HTMLElement | null };
|
|
27
|
+
contentId: string;
|
|
28
|
+
titleId: string;
|
|
29
|
+
descriptionId: string;
|
|
30
|
+
open: boolean;
|
|
31
|
+
onOpenChange: (open: boolean) => void;
|
|
32
|
+
onOpenToggle: () => void;
|
|
33
|
+
modal: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const [createDialogContext, createDialogScope] = createContextScope('Dialog');
|
|
37
|
+
const [DialogProvider, useDialogContext] = createDialogContext<DialogContextValue>('Dialog');
|
|
38
|
+
const [PortalProvider, usePortalContext] = createDialogContext<{ forceMount?: boolean }>(
|
|
39
|
+
'DialogPortal',
|
|
40
|
+
{ forceMount: undefined },
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
function getState(open?: boolean): 'open' | 'closed' {
|
|
44
|
+
return open ? 'open' : 'closed';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function Root(props: any): any {
|
|
48
|
+
const slot = S('Dialog.Root');
|
|
49
|
+
const {
|
|
50
|
+
__scopeDialog,
|
|
51
|
+
children,
|
|
52
|
+
open: openProp,
|
|
53
|
+
defaultOpen,
|
|
54
|
+
onOpenChange,
|
|
55
|
+
modal = true,
|
|
56
|
+
} = props ?? {};
|
|
57
|
+
const triggerRef = useRef<HTMLElement | null>(null, subSlot(slot, 'trigger'));
|
|
58
|
+
const contentRef = useRef<HTMLElement | null>(null, subSlot(slot, 'content'));
|
|
59
|
+
const [open, setOpen] = useControllableState<boolean>(
|
|
60
|
+
{ prop: openProp, defaultProp: defaultOpen ?? false, onChange: onOpenChange },
|
|
61
|
+
subSlot(slot, 'open'),
|
|
62
|
+
);
|
|
63
|
+
return createElement(DialogProvider, {
|
|
64
|
+
scope: __scopeDialog,
|
|
65
|
+
triggerRef,
|
|
66
|
+
contentRef,
|
|
67
|
+
contentId: useId(subSlot(slot, 'contentId')),
|
|
68
|
+
titleId: useId(subSlot(slot, 'titleId')),
|
|
69
|
+
descriptionId: useId(subSlot(slot, 'descriptionId')),
|
|
70
|
+
open,
|
|
71
|
+
onOpenChange: setOpen,
|
|
72
|
+
onOpenToggle: useCallback(() => setOpen((prev) => !prev), [setOpen], subSlot(slot, 'toggle')),
|
|
73
|
+
modal,
|
|
74
|
+
children,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function Trigger(props: any): any {
|
|
79
|
+
const slot = S('Dialog.Trigger');
|
|
80
|
+
const { __scopeDialog, ref: forwardedRef, ...triggerProps } = props ?? {};
|
|
81
|
+
const context = useDialogContext('DialogTrigger', __scopeDialog);
|
|
82
|
+
const composedTriggerRef = useComposedRefs(
|
|
83
|
+
forwardedRef,
|
|
84
|
+
context.triggerRef,
|
|
85
|
+
subSlot(slot, 'refs'),
|
|
86
|
+
);
|
|
87
|
+
return createElement(Primitive.button, {
|
|
88
|
+
type: 'button',
|
|
89
|
+
'aria-haspopup': 'dialog',
|
|
90
|
+
'aria-expanded': context.open,
|
|
91
|
+
'aria-controls': context.open ? context.contentId : undefined,
|
|
92
|
+
'data-state': getState(context.open),
|
|
93
|
+
...triggerProps,
|
|
94
|
+
ref: composedTriggerRef,
|
|
95
|
+
onClick: composeEventHandlers(props?.onClick, context.onOpenToggle),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Mounts its children into `container` (default document.body) through `Presence`.
|
|
101
|
+
* Radix maps EACH child into its own `<Presence><Portal asChild>` pair. octane's
|
|
102
|
+
* children-position JSX compiles to an opaque render function, which can't be
|
|
103
|
+
* enumerated — pass children at a prop/value position (e.g.
|
|
104
|
+
* `children={[<Overlay/>, <Content/>]}`) for the faithful per-child treatment; a
|
|
105
|
+
* function child is wrapped as a single portal'd unit (one Presence, one Portal div).
|
|
106
|
+
*/
|
|
107
|
+
export function Portal(props: any): any {
|
|
108
|
+
const { __scopeDialog, forceMount, children, container } = props ?? {};
|
|
109
|
+
const context = useDialogContext('DialogPortal', __scopeDialog);
|
|
110
|
+
const wrap = (child: any): any =>
|
|
111
|
+
createElement(Presence, {
|
|
112
|
+
present: forceMount || context.open,
|
|
113
|
+
children: createElement(PortalPrimitive, { asChild: true, container, children: child }),
|
|
114
|
+
});
|
|
115
|
+
const mapped =
|
|
116
|
+
typeof children === 'function'
|
|
117
|
+
? createElement(Presence, {
|
|
118
|
+
present: forceMount || context.open,
|
|
119
|
+
children: createElement(PortalPrimitive, { container, children }),
|
|
120
|
+
})
|
|
121
|
+
: Children.map(children, wrap);
|
|
122
|
+
return createElement(PortalProvider, { scope: __scopeDialog, forceMount, children: mapped });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function Overlay(props: any): any {
|
|
126
|
+
const portalContext = usePortalContext('DialogOverlay', props?.__scopeDialog);
|
|
127
|
+
const { forceMount = portalContext.forceMount, ...overlayProps } = props ?? {};
|
|
128
|
+
const context = useDialogContext('DialogOverlay', props?.__scopeDialog);
|
|
129
|
+
return context.modal
|
|
130
|
+
? createElement(Presence, {
|
|
131
|
+
present: forceMount || context.open,
|
|
132
|
+
children: createElement(OverlayImpl, overlayProps),
|
|
133
|
+
})
|
|
134
|
+
: null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function OverlayImpl(props: any): any {
|
|
138
|
+
const slot = S('Dialog.OverlayImpl');
|
|
139
|
+
const { __scopeDialog, ref: forwardedRef, ...overlayProps } = props;
|
|
140
|
+
const context = useDialogContext('DialogOverlay', __scopeDialog);
|
|
141
|
+
// The overlay is the dialog's own dismiss affordance — pressing it dismisses even if
|
|
142
|
+
// something stops propagation (see DismissableLayer.dismissableSurfaces).
|
|
143
|
+
const registerDismissableSurface = useDismissableLayerSurface(subSlot(slot, 'surface'));
|
|
144
|
+
const composedRefs = useComposedRefs(
|
|
145
|
+
forwardedRef,
|
|
146
|
+
registerDismissableSurface,
|
|
147
|
+
subSlot(slot, 'refs'),
|
|
148
|
+
);
|
|
149
|
+
// Radix wraps the overlay in `react-remove-scroll` (as a Slot — no wrapper DOM); the
|
|
150
|
+
// octane equivalent is the useScrollLock hook (see scroll-lock.ts).
|
|
151
|
+
useScrollLock(context.open, subSlot(slot, 'lock'));
|
|
152
|
+
return createElement(Primitive.div, {
|
|
153
|
+
'data-state': getState(context.open),
|
|
154
|
+
...overlayProps,
|
|
155
|
+
ref: composedRefs,
|
|
156
|
+
// Make sure `Content` is scrollable even when it doesn't live inside `RemoveScroll`
|
|
157
|
+
// ie. when `Overlay` and `Content` are siblings.
|
|
158
|
+
style: { pointerEvents: 'auto', ...overlayProps.style },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function Content(props: any): any {
|
|
163
|
+
const portalContext = usePortalContext('DialogContent', props?.__scopeDialog);
|
|
164
|
+
const { forceMount = portalContext.forceMount, ...contentProps } = props ?? {};
|
|
165
|
+
const context = useDialogContext('DialogContent', props?.__scopeDialog);
|
|
166
|
+
return createElement(Presence, {
|
|
167
|
+
present: forceMount || context.open,
|
|
168
|
+
children: context.modal
|
|
169
|
+
? createElement(ContentModal, contentProps)
|
|
170
|
+
: createElement(ContentNonModal, contentProps),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ContentModal(props: any): any {
|
|
175
|
+
const slot = S('Dialog.ContentModal');
|
|
176
|
+
const { ref: forwardedRef, ...rest } = props;
|
|
177
|
+
const context = useDialogContext('DialogContent', props.__scopeDialog);
|
|
178
|
+
const contentRef = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
|
|
179
|
+
const composedRefs = useComposedRefs(
|
|
180
|
+
forwardedRef,
|
|
181
|
+
context.contentRef,
|
|
182
|
+
contentRef,
|
|
183
|
+
subSlot(slot, 'refs'),
|
|
184
|
+
);
|
|
185
|
+
// Hide everything else from ATs while the modal is open.
|
|
186
|
+
useEffect(
|
|
187
|
+
() => {
|
|
188
|
+
const content = contentRef.current;
|
|
189
|
+
if (content) return hideOthers(content);
|
|
190
|
+
},
|
|
191
|
+
[],
|
|
192
|
+
subSlot(slot, 'e:hide'),
|
|
193
|
+
);
|
|
194
|
+
return createElement(ContentImpl, {
|
|
195
|
+
...rest,
|
|
196
|
+
__scopeDialog: props.__scopeDialog,
|
|
197
|
+
ref: composedRefs,
|
|
198
|
+
trapFocus: context.open,
|
|
199
|
+
disableOutsidePointerEvents: context.open,
|
|
200
|
+
onCloseAutoFocus: composeEventHandlers(props.onCloseAutoFocus, (event: Event) => {
|
|
201
|
+
event.preventDefault();
|
|
202
|
+
context.triggerRef.current?.focus();
|
|
203
|
+
}),
|
|
204
|
+
onPointerDownOutside: composeEventHandlers(props.onPointerDownOutside, (event: any) => {
|
|
205
|
+
const originalEvent = event.detail.originalEvent;
|
|
206
|
+
const ctrlLeftClick = originalEvent.button === 0 && originalEvent.ctrlKey === true;
|
|
207
|
+
const isRightClick = originalEvent.button === 2 || ctrlLeftClick;
|
|
208
|
+
// Prevent dismissing when clicking the trigger — it's what opened the dialog.
|
|
209
|
+
if (isRightClick) event.preventDefault();
|
|
210
|
+
}),
|
|
211
|
+
// When focus is trapped, a `focusout` event may still happen — prevent dismissing.
|
|
212
|
+
onFocusOutside: composeEventHandlers(props.onFocusOutside, (event: Event) =>
|
|
213
|
+
event.preventDefault(),
|
|
214
|
+
),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function ContentNonModal(props: any): any {
|
|
219
|
+
const slot = S('Dialog.ContentNonModal');
|
|
220
|
+
const context = useDialogContext('DialogContent', props.__scopeDialog);
|
|
221
|
+
const hasInteractedOutsideRef = useRef(false, subSlot(slot, 'interacted'));
|
|
222
|
+
const hasPointerDownOutsideRef = useRef(false, subSlot(slot, 'pointer'));
|
|
223
|
+
return createElement(ContentImpl, {
|
|
224
|
+
...props,
|
|
225
|
+
trapFocus: false,
|
|
226
|
+
disableOutsidePointerEvents: false,
|
|
227
|
+
onCloseAutoFocus: (event: Event) => {
|
|
228
|
+
props.onCloseAutoFocus?.(event);
|
|
229
|
+
if (!event.defaultPrevented) {
|
|
230
|
+
if (!hasInteractedOutsideRef.current) context.triggerRef.current?.focus();
|
|
231
|
+
// Always prevent auto-focus because we either focus manually or want user agent focus.
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
}
|
|
234
|
+
hasInteractedOutsideRef.current = false;
|
|
235
|
+
hasPointerDownOutsideRef.current = false;
|
|
236
|
+
},
|
|
237
|
+
onInteractOutside: (event: any) => {
|
|
238
|
+
props.onInteractOutside?.(event);
|
|
239
|
+
if (!event.defaultPrevented) {
|
|
240
|
+
hasInteractedOutsideRef.current = true;
|
|
241
|
+
if (event.detail.originalEvent.type === 'pointerdown') {
|
|
242
|
+
hasPointerDownOutsideRef.current = true;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// Prevent dismissing when clicking the trigger — it's what opened the dialog.
|
|
246
|
+
const target = event.target as HTMLElement;
|
|
247
|
+
const targetIsTrigger = context.triggerRef.current?.contains(target);
|
|
248
|
+
if (targetIsTrigger) event.preventDefault();
|
|
249
|
+
// On Safari, if the trigger is inside a container with tabIndex={0}, clicking
|
|
250
|
+
// the trigger would blur the open dialog (focusin outside) — prevent that too.
|
|
251
|
+
if (event.detail.originalEvent.type === 'focusin' && hasPointerDownOutsideRef.current) {
|
|
252
|
+
event.preventDefault();
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function ContentImpl(props: any): any {
|
|
259
|
+
const slot = S('Dialog.ContentImpl');
|
|
260
|
+
const { __scopeDialog, trapFocus, onOpenAutoFocus, onCloseAutoFocus, ...contentProps } = props;
|
|
261
|
+
const context = useDialogContext('DialogContent', __scopeDialog);
|
|
262
|
+
// Make sure the whole tree has focus guards as our `Dialog` will be the last element
|
|
263
|
+
// in the DOM (because of the `Portal`).
|
|
264
|
+
useFocusGuards(subSlot(slot, 'guards'));
|
|
265
|
+
// FocusScope renders `asChild` (Radix parity): its tabIndex/onKeyDown/ref merge onto
|
|
266
|
+
// the DismissableLayer's element via Slot — no wrapper div in the DOM.
|
|
267
|
+
return createElement(FocusScope, {
|
|
268
|
+
asChild: true,
|
|
269
|
+
loop: true,
|
|
270
|
+
trapped: trapFocus,
|
|
271
|
+
onMountAutoFocus: onOpenAutoFocus,
|
|
272
|
+
onUnmountAutoFocus: onCloseAutoFocus,
|
|
273
|
+
children: createElement(DismissableLayer, {
|
|
274
|
+
role: 'dialog',
|
|
275
|
+
id: context.contentId,
|
|
276
|
+
'aria-describedby': context.descriptionId,
|
|
277
|
+
'aria-labelledby': context.titleId,
|
|
278
|
+
'data-state': getState(context.open),
|
|
279
|
+
...contentProps,
|
|
280
|
+
deferPointerDownOutside: true,
|
|
281
|
+
onDismiss: () => context.onOpenChange(false),
|
|
282
|
+
}),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function Title(props: any): any {
|
|
287
|
+
const { __scopeDialog, ...titleProps } = props ?? {};
|
|
288
|
+
const context = useDialogContext('DialogTitle', __scopeDialog);
|
|
289
|
+
return createElement(Primitive.h2, { id: context.titleId, ...titleProps });
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function Description(props: any): any {
|
|
293
|
+
const { __scopeDialog, ...descriptionProps } = props ?? {};
|
|
294
|
+
const context = useDialogContext('DialogDescription', __scopeDialog);
|
|
295
|
+
return createElement(Primitive.p, { id: context.descriptionId, ...descriptionProps });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function Close(props: any): any {
|
|
299
|
+
const { __scopeDialog, ...closeProps } = props ?? {};
|
|
300
|
+
const context = useDialogContext('DialogClose', __scopeDialog);
|
|
301
|
+
return createElement(Primitive.button, {
|
|
302
|
+
type: 'button',
|
|
303
|
+
...closeProps,
|
|
304
|
+
onClick: composeEventHandlers(props?.onClick, () => context.onOpenChange(false)),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export { Root as Dialog };
|