@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
@@ -0,0 +1,1236 @@
1
+ // Ported from @radix-ui/react-navigation-menu (source:
2
+ // .radix-primitives/packages/react/navigation-menu/src/navigation-menu.tsx). A site
3
+ // navigation menu rendered INLINE — a `<nav>` with a list of triggers, per-item Content
4
+ // (composing DismissableLayer + a per-content FocusGroup), an optional shared Viewport
5
+ // that hosts the active content (content is proxied into it via ViewportContentMounter),
6
+ // and an Indicator portal'd into the list's indicator track. Features: delayed open on
7
+ // pointer move (`delayDuration`) with a skip-delay window (`skipDelayDuration`), a fixed
8
+ // 150ms close-intent timer, direction-aware `data-motion` attributes (`from-start`/
9
+ // `from-end`/`to-start`/`to-end`) computed from item order, arrow-key focus navigation
10
+ // between triggers/links (FocusGroup collections), tab-order proxying between trigger and
11
+ // content via a VisuallyHidden focus proxy, and `--radix-navigation-menu-viewport-
12
+ // width/height` CSS vars measured from the active content.
13
+ //
14
+ // octane adaptations (established across the other ports):
15
+ // - No forwardRef: `ref: forwardedRef` is destructured from props and composed with
16
+ // `useComposedRefs`.
17
+ // - Fragments (`<>…</>`) → keyed arrays (see Trigger; conditional entries are dropped
18
+ // rather than `null`-padded since every element is keyed).
19
+ // - `ReactDOM.createPortal` (Indicator → indicator track) → octane's
20
+ // `createPortal`-as-a-value.
21
+ // - Events are NATIVE delegated DOM events: `whenMouse` reads the native
22
+ // `PointerEvent.pointerType`, the focus proxy's `onFocus` reads the native
23
+ // `FocusEvent.relatedTarget`, and `event.currentTarget` is the delegated target.
24
+ // - Explicit hook slots (S/subSlot) — plain-`.ts` components skip the compiler's
25
+ // auto-slotting pass.
26
+ // - `useResizeObserver` gets the `typeof ResizeObserver === 'undefined'` guard (like
27
+ // use-size.ts) for jsdom; there the initial observation a real ResizeObserver would
28
+ // deliver on `observe()` is delivered on a 0-timeout instead (deferred like the rAF
29
+ // path), so the indicator/viewport still receive their first measurement.
30
+ //
31
+ // - React's IMPLICIT same-element bailout is now native in octane (childSlot's
32
+ // `oldProps === newProps` bail + lazy per-context consumer refresh), so the
33
+ // provider passes user children straight through — the old `MemoChildren`
34
+ // memo() shim AND the `onViewportContentChange` shallow-equal convergence
35
+ // bail are gone (the register cascade no longer oscillates once identity-
36
+ // stable subtrees bail like React's). One residual adaptation remains: the
37
+ // source's standalone `ViewportContentMounter` is inlined into Content (see
38
+ // the note there). See docs/react-parity-migration-plan.md.
39
+ // - `useResizeObserver` uses `useEffectEvent` (insertion-effect sync) instead of the
40
+ // source's `useCallbackRef` (passive-effect sync): octane's passives are post-paint,
41
+ // so a layout effect (or a timer it schedules) could observe a one-render-stale
42
+ // closure; useEffectEvent is always current before layout effects run (and matches
43
+ // the direction modern Radix is migrating anyway).
44
+ // - The FocusGroup collection is created under the name `NavigationMenuFocusGroup`
45
+ // (the source reuses `NavigationMenu` for both collections, which makes the two
46
+ // collections' scope keys collide when a `createNavigationMenuScope` scope is used;
47
+ // distinct names keep them separate — identical behavior in the unscoped case).
48
+ // - `useControllableState`'s dev-only `caller` option is not ported (repo policy:
49
+ // functional outcomes only).
50
+ import {
51
+ createElement,
52
+ createPortal,
53
+ useCallback,
54
+ useEffect,
55
+ useLayoutEffect,
56
+ useMemo,
57
+ useRef,
58
+ useState,
59
+ } from 'octane';
60
+
61
+ import { createCollection } from './collection';
62
+ import { composeEventHandlers } from './compose-event-handlers';
63
+ import { useComposedRefs } from './compose-refs';
64
+ import { createContextScope } from './context';
65
+ import { useDirection } from './direction';
66
+ import { DismissableLayer } from './DismissableLayer';
67
+ import { S, subSlot } from './internal';
68
+ import { useEffectEvent } from './use-effect-event';
69
+ import { Presence } from './Presence';
70
+ import { dispatchDiscreteCustomEvent, Primitive } from './Primitive';
71
+ import { useCallbackRef } from './use-callback-ref';
72
+ import { usePrevious } from './use-previous';
73
+ import { useControllableState } from './useControllableState';
74
+ import { useId } from './useId';
75
+ import { VisuallyHidden } from './VisuallyHidden';
76
+
77
+ type Orientation = 'vertical' | 'horizontal';
78
+ type Direction = 'ltr' | 'rtl';
79
+
80
+ /* -------------------------------------------------------------------------------------------------
81
+ * NavigationMenu
82
+ * -----------------------------------------------------------------------------------------------*/
83
+
84
+ const NAVIGATION_MENU_NAME = 'NavigationMenu';
85
+
86
+ const [Collection, useCollection, createCollectionScope] = createCollection(NAVIGATION_MENU_NAME);
87
+
88
+ const [FocusGroupCollection, useFocusGroupCollection, createFocusGroupCollectionScope] =
89
+ createCollection(NAVIGATION_MENU_NAME + 'FocusGroup');
90
+
91
+ const [createNavigationMenuContext, createNavigationMenuScope] = createContextScope(
92
+ NAVIGATION_MENU_NAME,
93
+ [createCollectionScope, createFocusGroupCollectionScope],
94
+ );
95
+ export { createNavigationMenuScope };
96
+
97
+ type ContentData = { ref?: any } & Record<string, any>;
98
+
99
+ interface NavigationMenuContextValue {
100
+ isRootMenu: boolean;
101
+ value: string;
102
+ previousValue: string;
103
+ baseId: string;
104
+ dir: Direction;
105
+ orientation: Orientation;
106
+ rootNavigationMenu: HTMLElement | null;
107
+ indicatorTrack: HTMLDivElement | null;
108
+ onIndicatorTrackChange(indicatorTrack: HTMLDivElement | null): void;
109
+ viewport: HTMLElement | null;
110
+ onViewportChange(viewport: HTMLElement | null): void;
111
+ onViewportContentChange(contentValue: string, contentData: ContentData): void;
112
+ onViewportContentRemove(contentValue: string): void;
113
+ onTriggerEnter(itemValue: string): void;
114
+ onTriggerLeave(): void;
115
+ onContentEnter(): void;
116
+ onContentLeave(): void;
117
+ onItemSelect(itemValue: string): void;
118
+ onItemDismiss(): void;
119
+ }
120
+
121
+ const [NavigationMenuProviderImpl, useNavigationMenuContext] =
122
+ createNavigationMenuContext<NavigationMenuContextValue>(NAVIGATION_MENU_NAME);
123
+
124
+ const [ViewportContentProvider, useViewportContentContext] = createNavigationMenuContext<{
125
+ items: Map<string, ContentData>;
126
+ }>(NAVIGATION_MENU_NAME);
127
+
128
+ export function Root(props: any): any {
129
+ const slot = S('NavigationMenu.Root');
130
+ const {
131
+ __scopeNavigationMenu,
132
+ value: valueProp,
133
+ onValueChange,
134
+ defaultValue,
135
+ delayDuration = 200,
136
+ skipDelayDuration = 300,
137
+ orientation = 'horizontal',
138
+ dir,
139
+ ref: forwardedRef,
140
+ ...navigationMenuProps
141
+ } = props ?? {};
142
+ const [navigationMenu, setNavigationMenu] = useState<HTMLElement | null>(
143
+ null,
144
+ subSlot(slot, 'nav'),
145
+ );
146
+ const composedRef = useComposedRefs(forwardedRef, setNavigationMenu, subSlot(slot, 'refs'));
147
+ const direction = useDirection(dir);
148
+ const openTimerRef = useRef(0, subSlot(slot, 'openTimer'));
149
+ const closeTimerRef = useRef(0, subSlot(slot, 'closeTimer'));
150
+ const skipDelayTimerRef = useRef(0, subSlot(slot, 'skipTimer'));
151
+ const [isOpenDelayed, setIsOpenDelayed] = useState(true, subSlot(slot, 'delayed'));
152
+ const [value, setValue] = useControllableState<string>(
153
+ {
154
+ prop: valueProp,
155
+ onChange: (value: string) => {
156
+ const isOpen = value !== '';
157
+ const hasSkipDelayDuration = skipDelayDuration > 0;
158
+
159
+ if (isOpen) {
160
+ window.clearTimeout(skipDelayTimerRef.current);
161
+ if (hasSkipDelayDuration) setIsOpenDelayed(false);
162
+ } else {
163
+ window.clearTimeout(skipDelayTimerRef.current);
164
+ skipDelayTimerRef.current = window.setTimeout(
165
+ () => setIsOpenDelayed(true),
166
+ skipDelayDuration,
167
+ );
168
+ }
169
+
170
+ onValueChange?.(value);
171
+ },
172
+ defaultProp: defaultValue ?? '',
173
+ },
174
+ subSlot(slot, 'value'),
175
+ );
176
+
177
+ const startCloseTimer = useCallback(
178
+ () => {
179
+ window.clearTimeout(closeTimerRef.current);
180
+ closeTimerRef.current = window.setTimeout(() => setValue(''), 150);
181
+ },
182
+ [setValue],
183
+ subSlot(slot, 'closeTimerCb'),
184
+ );
185
+
186
+ const handleOpen = useCallback(
187
+ (itemValue: string) => {
188
+ window.clearTimeout(closeTimerRef.current);
189
+ setValue(itemValue);
190
+ },
191
+ [setValue],
192
+ subSlot(slot, 'open'),
193
+ );
194
+
195
+ const handleDelayedOpen = useCallback(
196
+ (itemValue: string) => {
197
+ const isOpenItem = value === itemValue;
198
+ if (isOpenItem) {
199
+ // If the item is already open (e.g. we're transitioning from the content to the
200
+ // trigger) then we want to clear the close timer immediately.
201
+ window.clearTimeout(closeTimerRef.current);
202
+ } else {
203
+ openTimerRef.current = window.setTimeout(() => {
204
+ window.clearTimeout(closeTimerRef.current);
205
+ setValue(itemValue);
206
+ }, delayDuration);
207
+ }
208
+ },
209
+ [value, setValue, delayDuration],
210
+ subSlot(slot, 'delayedOpen'),
211
+ );
212
+
213
+ useEffect(
214
+ () => {
215
+ return () => {
216
+ window.clearTimeout(openTimerRef.current);
217
+ window.clearTimeout(closeTimerRef.current);
218
+ window.clearTimeout(skipDelayTimerRef.current);
219
+ };
220
+ },
221
+ [],
222
+ subSlot(slot, 'e:timers'),
223
+ );
224
+
225
+ return createElement(NavigationMenuProvider, {
226
+ scope: __scopeNavigationMenu,
227
+ isRootMenu: true,
228
+ value,
229
+ dir: direction,
230
+ orientation,
231
+ rootNavigationMenu: navigationMenu,
232
+ onTriggerEnter: (itemValue: string) => {
233
+ window.clearTimeout(openTimerRef.current);
234
+ if (isOpenDelayed) handleDelayedOpen(itemValue);
235
+ else handleOpen(itemValue);
236
+ },
237
+ onTriggerLeave: () => {
238
+ window.clearTimeout(openTimerRef.current);
239
+ startCloseTimer();
240
+ },
241
+ onContentEnter: () => window.clearTimeout(closeTimerRef.current),
242
+ onContentLeave: startCloseTimer,
243
+ onItemSelect: (itemValue: string) => {
244
+ setValue((prevValue) => (prevValue === itemValue ? '' : itemValue));
245
+ },
246
+ onItemDismiss: () => setValue(''),
247
+ children: createElement(Primitive.nav, {
248
+ 'aria-label': 'Main',
249
+ 'data-orientation': orientation,
250
+ dir: direction,
251
+ ...navigationMenuProps,
252
+ ref: composedRef,
253
+ }),
254
+ });
255
+ }
256
+
257
+ /* -------------------------------------------------------------------------------------------------
258
+ * NavigationMenuSub
259
+ * -----------------------------------------------------------------------------------------------*/
260
+
261
+ const SUB_NAME = 'NavigationMenuSub';
262
+
263
+ export function Sub(props: any): any {
264
+ const slot = S('NavigationMenu.Sub');
265
+ const {
266
+ __scopeNavigationMenu,
267
+ value: valueProp,
268
+ onValueChange,
269
+ defaultValue,
270
+ orientation = 'horizontal',
271
+ ref: forwardedRef,
272
+ ...subProps
273
+ } = props ?? {};
274
+ const context = useNavigationMenuContext(SUB_NAME, __scopeNavigationMenu);
275
+ const [value, setValue] = useControllableState<string>(
276
+ {
277
+ prop: valueProp,
278
+ onChange: onValueChange,
279
+ defaultProp: defaultValue ?? '',
280
+ },
281
+ subSlot(slot, 'value'),
282
+ );
283
+
284
+ return createElement(NavigationMenuProvider, {
285
+ scope: __scopeNavigationMenu,
286
+ isRootMenu: false,
287
+ value,
288
+ dir: context.dir,
289
+ orientation,
290
+ rootNavigationMenu: context.rootNavigationMenu,
291
+ onTriggerEnter: (itemValue: string) => setValue(itemValue),
292
+ onItemSelect: (itemValue: string) => setValue(itemValue),
293
+ onItemDismiss: () => setValue(''),
294
+ children: createElement(Primitive.div, {
295
+ 'data-orientation': orientation,
296
+ ...subProps,
297
+ ref: forwardedRef,
298
+ }),
299
+ });
300
+ }
301
+
302
+ /* -----------------------------------------------------------------------------------------------*/
303
+
304
+ function NavigationMenuProvider(props: any): any {
305
+ const slot = S('NavigationMenu.Provider');
306
+ const {
307
+ scope,
308
+ isRootMenu,
309
+ rootNavigationMenu,
310
+ dir,
311
+ orientation,
312
+ children,
313
+ value,
314
+ onItemSelect,
315
+ onItemDismiss,
316
+ onTriggerEnter,
317
+ onTriggerLeave,
318
+ onContentEnter,
319
+ onContentLeave,
320
+ } = props;
321
+ const [viewport, setViewport] = useState<HTMLElement | null>(null, subSlot(slot, 'viewport'));
322
+ const [viewportContent, setViewportContent] = useState<Map<string, ContentData>>(
323
+ new Map(),
324
+ subSlot(slot, 'content'),
325
+ );
326
+ const [indicatorTrack, setIndicatorTrack] = useState<HTMLDivElement | null>(
327
+ null,
328
+ subSlot(slot, 'track'),
329
+ );
330
+
331
+ return createElement(NavigationMenuProviderImpl, {
332
+ scope,
333
+ isRootMenu,
334
+ rootNavigationMenu,
335
+ value,
336
+ previousValue: usePrevious(value, subSlot(slot, 'prev')),
337
+ baseId: useId(subSlot(slot, 'id')),
338
+ dir,
339
+ orientation,
340
+ viewport,
341
+ onViewportChange: setViewport,
342
+ indicatorTrack,
343
+ onIndicatorTrackChange: setIndicatorTrack,
344
+ onTriggerEnter: useCallbackRef(onTriggerEnter, subSlot(slot, 'triggerEnter')),
345
+ onTriggerLeave: useCallbackRef(onTriggerLeave, subSlot(slot, 'triggerLeave')),
346
+ onContentEnter: useCallbackRef(onContentEnter, subSlot(slot, 'contentEnter')),
347
+ onContentLeave: useCallbackRef(onContentLeave, subSlot(slot, 'contentLeave')),
348
+ onItemSelect: useCallbackRef(onItemSelect, subSlot(slot, 'itemSelect')),
349
+ onItemDismiss: useCallbackRef(onItemDismiss, subSlot(slot, 'itemDismiss')),
350
+ onViewportContentChange: useCallback(
351
+ (contentValue: string, contentData: ContentData) => {
352
+ setViewportContent((prevContent) => {
353
+ prevContent.set(contentValue, contentData);
354
+ return new Map(prevContent);
355
+ });
356
+ },
357
+ [],
358
+ subSlot(slot, 'vcChange'),
359
+ ),
360
+ onViewportContentRemove: useCallback(
361
+ (contentValue: string) => {
362
+ setViewportContent((prevContent) => {
363
+ if (!prevContent.has(contentValue)) return prevContent;
364
+ prevContent.delete(contentValue);
365
+ return new Map(prevContent);
366
+ });
367
+ },
368
+ [],
369
+ subSlot(slot, 'vcRemove'),
370
+ ),
371
+ children: createElement(Collection.Provider, {
372
+ scope,
373
+ children: createElement(ViewportContentProvider, {
374
+ scope,
375
+ items: viewportContent,
376
+ // Identity-stable user children ride octane's implicit same-element
377
+ // bailout (React beginWork parity): provider-state re-renders skip
378
+ // them while consumers of the CHANGED context refresh lazily.
379
+ children,
380
+ }),
381
+ }),
382
+ });
383
+ }
384
+
385
+ /* -------------------------------------------------------------------------------------------------
386
+ * NavigationMenuList
387
+ * -----------------------------------------------------------------------------------------------*/
388
+
389
+ const LIST_NAME = 'NavigationMenuList';
390
+
391
+ export function List(props: any): any {
392
+ const { __scopeNavigationMenu, ref: forwardedRef, ...listProps } = props ?? {};
393
+ const context = useNavigationMenuContext(LIST_NAME, __scopeNavigationMenu);
394
+
395
+ const list = createElement(Primitive.ul, {
396
+ 'data-orientation': context.orientation,
397
+ ...listProps,
398
+ ref: forwardedRef,
399
+ });
400
+
401
+ return createElement(Primitive.div, {
402
+ style: { position: 'relative' },
403
+ ref: context.onIndicatorTrackChange,
404
+ children: createElement(Collection.Slot, {
405
+ scope: __scopeNavigationMenu,
406
+ children: context.isRootMenu
407
+ ? createElement(FocusGroup, { asChild: true, children: list })
408
+ : list,
409
+ }),
410
+ });
411
+ }
412
+
413
+ /* -------------------------------------------------------------------------------------------------
414
+ * NavigationMenuItem
415
+ * -----------------------------------------------------------------------------------------------*/
416
+
417
+ const ITEM_NAME = 'NavigationMenuItem';
418
+
419
+ interface NavigationMenuItemContextValue {
420
+ value: string;
421
+ triggerRef: { current: HTMLElement | null };
422
+ contentRef: { current: HTMLElement | null };
423
+ focusProxyRef: { current: HTMLElement | null };
424
+ wasEscapeCloseRef: { current: boolean };
425
+ onEntryKeyDown(): void;
426
+ onFocusProxyEnter(side: 'start' | 'end'): void;
427
+ onRootContentClose(): void;
428
+ onContentFocusOutside(): void;
429
+ }
430
+
431
+ const [NavigationMenuItemContextProvider, useNavigationMenuItemContext] =
432
+ createNavigationMenuContext<NavigationMenuItemContextValue>(ITEM_NAME);
433
+
434
+ export function Item(props: any): any {
435
+ const slot = S('NavigationMenu.Item');
436
+ const { __scopeNavigationMenu, value: valueProp, ref: forwardedRef, ...itemProps } = props ?? {};
437
+ const autoValue = useId(subSlot(slot, 'id'));
438
+ // We need to provide an initial deterministic value as `useId` will return
439
+ // empty string on the first render and we don't want to match our internal "closed" value.
440
+ const value = valueProp || autoValue || 'LEGACY_REACT_AUTO_VALUE';
441
+ const contentRef = useRef<HTMLElement | null>(null, subSlot(slot, 'content'));
442
+ const triggerRef = useRef<HTMLElement | null>(null, subSlot(slot, 'trigger'));
443
+ const focusProxyRef = useRef<HTMLElement | null>(null, subSlot(slot, 'proxy'));
444
+ const restoreContentTabOrderRef = useRef<() => void>(() => {}, subSlot(slot, 'restore'));
445
+ const wasEscapeCloseRef = useRef(false, subSlot(slot, 'escape'));
446
+
447
+ const handleContentEntry = useCallback(
448
+ (side = 'start') => {
449
+ if (contentRef.current) {
450
+ restoreContentTabOrderRef.current();
451
+ const candidates = getTabbableCandidates(contentRef.current);
452
+ if (candidates.length) focusFirst(side === 'start' ? candidates : candidates.reverse());
453
+ }
454
+ },
455
+ [],
456
+ subSlot(slot, 'entry'),
457
+ );
458
+
459
+ const handleContentExit = useCallback(
460
+ () => {
461
+ if (contentRef.current) {
462
+ const candidates = getTabbableCandidates(contentRef.current);
463
+ if (candidates.length) restoreContentTabOrderRef.current = removeFromTabOrder(candidates);
464
+ }
465
+ },
466
+ [],
467
+ subSlot(slot, 'exit'),
468
+ );
469
+
470
+ return createElement(NavigationMenuItemContextProvider, {
471
+ scope: __scopeNavigationMenu,
472
+ value,
473
+ triggerRef,
474
+ contentRef,
475
+ focusProxyRef,
476
+ wasEscapeCloseRef,
477
+ onEntryKeyDown: handleContentEntry,
478
+ onFocusProxyEnter: handleContentEntry,
479
+ onRootContentClose: handleContentExit,
480
+ onContentFocusOutside: handleContentExit,
481
+ children: createElement(Primitive.li, { ...itemProps, ref: forwardedRef }),
482
+ });
483
+ }
484
+
485
+ /* -------------------------------------------------------------------------------------------------
486
+ * NavigationMenuTrigger
487
+ * -----------------------------------------------------------------------------------------------*/
488
+
489
+ const TRIGGER_NAME = 'NavigationMenuTrigger';
490
+
491
+ export function Trigger(props: any): any {
492
+ const slot = S('NavigationMenu.Trigger');
493
+ const { __scopeNavigationMenu, disabled, ref: forwardedRef, ...triggerProps } = props ?? {};
494
+ const context = useNavigationMenuContext(TRIGGER_NAME, props?.__scopeNavigationMenu);
495
+ const itemContext = useNavigationMenuItemContext(TRIGGER_NAME, props?.__scopeNavigationMenu);
496
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
497
+ const composedRefs = useComposedRefs(
498
+ ref,
499
+ itemContext.triggerRef,
500
+ forwardedRef,
501
+ subSlot(slot, 'refs'),
502
+ );
503
+ const triggerId = makeTriggerId(context.baseId, itemContext.value);
504
+ const contentId = makeContentId(context.baseId, itemContext.value);
505
+ const hasPointerMoveOpenedRef = useRef(false, subSlot(slot, 'moveOpened'));
506
+ const wasClickCloseRef = useRef(false, subSlot(slot, 'clickClose'));
507
+ const open = itemContext.value === context.value;
508
+
509
+ // Source is a fragment; octane: keyed array.
510
+ return [
511
+ createElement(Collection.ItemSlot, {
512
+ key: 'trigger',
513
+ scope: __scopeNavigationMenu,
514
+ value: itemContext.value,
515
+ children: createElement(FocusGroupItem, {
516
+ asChild: true,
517
+ children: createElement(Primitive.button, {
518
+ id: triggerId,
519
+ disabled,
520
+ 'data-disabled': disabled ? '' : undefined,
521
+ 'data-state': getOpenState(open),
522
+ 'aria-expanded': open,
523
+ 'aria-controls': open ? contentId : undefined,
524
+ ...triggerProps,
525
+ ref: composedRefs,
526
+ onPointerEnter: composeEventHandlers(props?.onPointerEnter, () => {
527
+ wasClickCloseRef.current = false;
528
+ itemContext.wasEscapeCloseRef.current = false;
529
+ }),
530
+ onPointerMove: composeEventHandlers(
531
+ props?.onPointerMove,
532
+ whenMouse(() => {
533
+ if (
534
+ disabled ||
535
+ wasClickCloseRef.current ||
536
+ itemContext.wasEscapeCloseRef.current ||
537
+ hasPointerMoveOpenedRef.current
538
+ )
539
+ return;
540
+ context.onTriggerEnter(itemContext.value);
541
+ hasPointerMoveOpenedRef.current = true;
542
+ }),
543
+ ),
544
+ onPointerLeave: composeEventHandlers(
545
+ props?.onPointerLeave,
546
+ whenMouse(() => {
547
+ if (disabled) return;
548
+ context.onTriggerLeave();
549
+ hasPointerMoveOpenedRef.current = false;
550
+ }),
551
+ ),
552
+ onClick: composeEventHandlers(props?.onClick, () => {
553
+ context.onItemSelect(itemContext.value);
554
+ wasClickCloseRef.current = open;
555
+ }),
556
+ onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
557
+ const verticalEntryKey = context.dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight';
558
+ const entryKey = { horizontal: 'ArrowDown', vertical: verticalEntryKey }[
559
+ context.orientation
560
+ ];
561
+ if (open && event.key === entryKey) {
562
+ itemContext.onEntryKeyDown();
563
+ // Prevent FocusGroupItem from handling the event
564
+ event.preventDefault();
565
+ }
566
+ }),
567
+ }),
568
+ }),
569
+ }),
570
+
571
+ // Proxy tab order between trigger and content
572
+ open
573
+ ? createElement(VisuallyHidden, {
574
+ key: 'proxy',
575
+ 'aria-hidden': true,
576
+ tabIndex: 0,
577
+ ref: itemContext.focusProxyRef,
578
+ onFocus: (event: FocusEvent) => {
579
+ const content = itemContext.contentRef.current;
580
+ const prevFocusedElement = event.relatedTarget as HTMLElement | null;
581
+ const wasTriggerFocused = prevFocusedElement === ref.current;
582
+ const wasFocusFromContent = content?.contains(prevFocusedElement);
583
+
584
+ if (wasTriggerFocused || !wasFocusFromContent) {
585
+ itemContext.onFocusProxyEnter(wasTriggerFocused ? 'start' : 'end');
586
+ }
587
+ },
588
+ })
589
+ : null,
590
+
591
+ // Restructure a11y tree to make content accessible to screen reader when using the viewport
592
+ open && context.viewport
593
+ ? createElement('span', { key: 'owns', 'aria-owns': contentId })
594
+ : null,
595
+ // The conditional slots are dropped (not `null`-padded): every element is keyed, so
596
+ // octane reconciles by key and doesn't warn about unkeyed array holes.
597
+ ].filter(Boolean);
598
+ }
599
+
600
+ /* -------------------------------------------------------------------------------------------------
601
+ * NavigationMenuLink
602
+ * -----------------------------------------------------------------------------------------------*/
603
+
604
+ const LINK_SELECT = 'navigationMenu.linkSelect';
605
+
606
+ export function Link(props: any): any {
607
+ const { __scopeNavigationMenu, active, onSelect, ref: forwardedRef, ...linkProps } = props ?? {};
608
+
609
+ return createElement(FocusGroupItem, {
610
+ asChild: true,
611
+ children: createElement(Primitive.a, {
612
+ 'data-active': active ? '' : undefined,
613
+ 'aria-current': active ? 'page' : undefined,
614
+ ...linkProps,
615
+ ref: forwardedRef,
616
+ onClick: composeEventHandlers(
617
+ props?.onClick,
618
+ (event: MouseEvent) => {
619
+ const target = event.target as HTMLElement;
620
+ const linkSelectEvent = new CustomEvent(LINK_SELECT, {
621
+ bubbles: true,
622
+ cancelable: true,
623
+ });
624
+ target.addEventListener(LINK_SELECT, (event) => onSelect?.(event), { once: true });
625
+ dispatchDiscreteCustomEvent(target, linkSelectEvent);
626
+
627
+ if (!linkSelectEvent.defaultPrevented && !event.metaKey) {
628
+ const rootContentDismissEvent = new CustomEvent(ROOT_CONTENT_DISMISS, {
629
+ bubbles: true,
630
+ cancelable: true,
631
+ });
632
+ dispatchDiscreteCustomEvent(target, rootContentDismissEvent);
633
+ }
634
+ },
635
+ { checkForDefaultPrevented: false },
636
+ ),
637
+ }),
638
+ });
639
+ }
640
+
641
+ /* -------------------------------------------------------------------------------------------------
642
+ * NavigationMenuIndicator
643
+ * -----------------------------------------------------------------------------------------------*/
644
+
645
+ const INDICATOR_NAME = 'NavigationMenuIndicator';
646
+
647
+ export function Indicator(props: any): any {
648
+ const { forceMount, ...indicatorProps } = props ?? {};
649
+ const context = useNavigationMenuContext(INDICATOR_NAME, props?.__scopeNavigationMenu);
650
+ const isVisible = Boolean(context.value);
651
+
652
+ // React's ReactDOM.createPortal → octane's createPortal-as-a-value.
653
+ return context.indicatorTrack
654
+ ? createPortal(
655
+ createElement(Presence, {
656
+ present: forceMount || isVisible,
657
+ children: createElement(NavigationMenuIndicatorImpl, { ...indicatorProps }),
658
+ }),
659
+ context.indicatorTrack,
660
+ )
661
+ : null;
662
+ }
663
+
664
+ function NavigationMenuIndicatorImpl(props: any): any {
665
+ const slot = S('NavigationMenu.IndicatorImpl');
666
+ const { __scopeNavigationMenu, ref: forwardedRef, ...indicatorProps } = props ?? {};
667
+ const context = useNavigationMenuContext(INDICATOR_NAME, __scopeNavigationMenu);
668
+ const getItems = useCollection(__scopeNavigationMenu, subSlot(slot, 'items'));
669
+ const [activeTrigger, setActiveTrigger] = useState<HTMLElement | null>(
670
+ null,
671
+ subSlot(slot, 'active'),
672
+ );
673
+ const [position, setPosition] = useState<{ size: number; offset: number } | null>(
674
+ null,
675
+ subSlot(slot, 'position'),
676
+ );
677
+ const isHorizontal = context.orientation === 'horizontal';
678
+ const isVisible = Boolean(context.value);
679
+
680
+ useEffect(
681
+ () => {
682
+ const items = getItems();
683
+ const triggerNode = items.find((item: any) => item.value === context.value)?.ref.current;
684
+ if (triggerNode) setActiveTrigger(triggerNode);
685
+ },
686
+ [getItems, context.value],
687
+ subSlot(slot, 'e:active'),
688
+ );
689
+
690
+ /**
691
+ * Update position when the indicator or parent track size changes
692
+ */
693
+ const handlePositionChange = (): void => {
694
+ if (activeTrigger) {
695
+ setPosition({
696
+ size: isHorizontal ? activeTrigger.offsetWidth : activeTrigger.offsetHeight,
697
+ offset: isHorizontal ? activeTrigger.offsetLeft : activeTrigger.offsetTop,
698
+ });
699
+ }
700
+ };
701
+ useResizeObserver(activeTrigger, handlePositionChange, subSlot(slot, 'ro:trigger'));
702
+ useResizeObserver(context.indicatorTrack, handlePositionChange, subSlot(slot, 'ro:track'));
703
+
704
+ // We need to wait for the indicator position to be available before rendering to
705
+ // snap immediately into position rather than transitioning from initial
706
+ return position
707
+ ? createElement(Primitive.div, {
708
+ 'aria-hidden': true,
709
+ 'data-state': isVisible ? 'visible' : 'hidden',
710
+ 'data-orientation': context.orientation,
711
+ ...indicatorProps,
712
+ ref: forwardedRef,
713
+ style: {
714
+ position: 'absolute',
715
+ ...(isHorizontal
716
+ ? {
717
+ left: 0,
718
+ width: position.size + 'px',
719
+ transform: `translateX(${position.offset}px)`,
720
+ }
721
+ : {
722
+ top: 0,
723
+ height: position.size + 'px',
724
+ transform: `translateY(${position.offset}px)`,
725
+ }),
726
+ ...indicatorProps.style,
727
+ },
728
+ })
729
+ : null;
730
+ }
731
+
732
+ /* -------------------------------------------------------------------------------------------------
733
+ * NavigationMenuContent
734
+ * -----------------------------------------------------------------------------------------------*/
735
+
736
+ const CONTENT_NAME = 'NavigationMenuContent';
737
+
738
+ export function Content(props: any): any {
739
+ const slot = S('NavigationMenu.Content');
740
+ const { forceMount, ref: forwardedRef, ...contentProps } = props ?? {};
741
+ const context = useNavigationMenuContext(CONTENT_NAME, props?.__scopeNavigationMenu);
742
+ const itemContext = useNavigationMenuItemContext(CONTENT_NAME, props?.__scopeNavigationMenu);
743
+ const composedRefs = useComposedRefs(itemContext.contentRef, forwardedRef, subSlot(slot, 'refs'));
744
+ const open = itemContext.value === context.value;
745
+
746
+ const commonProps = {
747
+ value: itemContext.value,
748
+ triggerRef: itemContext.triggerRef,
749
+ focusProxyRef: itemContext.focusProxyRef,
750
+ wasEscapeCloseRef: itemContext.wasEscapeCloseRef,
751
+ onContentFocusOutside: itemContext.onContentFocusOutside,
752
+ onRootContentClose: itemContext.onRootContentClose,
753
+ ...contentProps,
754
+ };
755
+
756
+ // octane adaptation (documented, see file header): the source's standalone
757
+ // `ViewportContentMounter` (:808-828) is INLINED — its two layout effects run in
758
+ // Content (a stable instance) behind octane's conditional-hooks capability, keyed
759
+ // on `inViewport` so flipping out of viewport mode unregisters exactly when the
760
+ // source's mounter would unmount. The remove effect is ordered BEFORE the register
761
+ // effect so a false→true transition registers last.
762
+ const inViewport = Boolean(context.viewport);
763
+ const { onViewportContentChange, onViewportContentRemove } = context;
764
+ const contentData: ContentData = { forceMount, ...commonProps, ref: composedRefs };
765
+
766
+ useLayoutEffect(
767
+ () => {
768
+ return () => onViewportContentRemove(contentData.value);
769
+ },
770
+ [inViewport, contentData.value, onViewportContentRemove],
771
+ subSlot(slot, 'e:vcRemove'),
772
+ );
773
+
774
+ useLayoutEffect(
775
+ () => {
776
+ if (inViewport) onViewportContentChange(contentData.value, contentData);
777
+ },
778
+ [inViewport, contentData, onViewportContentChange],
779
+ subSlot(slot, 'e:vcChange'),
780
+ );
781
+
782
+ if (!inViewport) {
783
+ return createElement(Presence, {
784
+ present: forceMount || open,
785
+ children: createElement(NavigationMenuContentImpl, {
786
+ 'data-state': getOpenState(open),
787
+ ...commonProps,
788
+ ref: composedRefs,
789
+ onPointerEnter: composeEventHandlers(props?.onPointerEnter, context.onContentEnter),
790
+ onPointerLeave: composeEventHandlers(
791
+ props?.onPointerLeave,
792
+ whenMouse(context.onContentLeave),
793
+ ),
794
+ style: {
795
+ // Prevent interaction when animating out
796
+ pointerEvents: !open && context.isRootMenu ? 'none' : undefined,
797
+ ...commonProps.style,
798
+ },
799
+ }),
800
+ });
801
+ }
802
+ // Content is proxied into the viewport.
803
+ return null;
804
+ }
805
+
806
+ /* -----------------------------------------------------------------------------------------------*/
807
+
808
+ const ROOT_CONTENT_DISMISS = 'navigationMenu.rootContentDismiss';
809
+
810
+ type MotionAttribute = 'to-start' | 'to-end' | 'from-start' | 'from-end';
811
+
812
+ function NavigationMenuContentImpl(props: any): any {
813
+ const slot = S('NavigationMenu.ContentImpl');
814
+ const {
815
+ __scopeNavigationMenu,
816
+ value,
817
+ triggerRef,
818
+ focusProxyRef,
819
+ wasEscapeCloseRef,
820
+ onRootContentClose,
821
+ onContentFocusOutside,
822
+ ref: forwardedRef,
823
+ ...contentProps
824
+ } = props ?? {};
825
+ const context = useNavigationMenuContext(CONTENT_NAME, __scopeNavigationMenu);
826
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
827
+ const composedRefs = useComposedRefs(ref, forwardedRef, subSlot(slot, 'refs'));
828
+ const triggerId = makeTriggerId(context.baseId, value);
829
+ const contentId = makeContentId(context.baseId, value);
830
+ const getItems = useCollection(__scopeNavigationMenu, subSlot(slot, 'items'));
831
+ const prevMotionAttributeRef = useRef<MotionAttribute | null>(null, subSlot(slot, 'prevMotion'));
832
+
833
+ const { onItemDismiss } = context;
834
+
835
+ useEffect(
836
+ () => {
837
+ const content = ref.current;
838
+
839
+ // Bubble dismiss to the root content node and focus its trigger
840
+ if (context.isRootMenu && content) {
841
+ const handleClose = (): void => {
842
+ onItemDismiss();
843
+ onRootContentClose();
844
+ if (content.contains(document.activeElement)) triggerRef.current?.focus();
845
+ };
846
+ content.addEventListener(ROOT_CONTENT_DISMISS, handleClose);
847
+ return () => content.removeEventListener(ROOT_CONTENT_DISMISS, handleClose);
848
+ }
849
+ },
850
+ [context.isRootMenu, value, triggerRef, onItemDismiss, onRootContentClose],
851
+ subSlot(slot, 'e:dismiss'),
852
+ );
853
+
854
+ const motionAttribute = useMemo(
855
+ () => {
856
+ const items = getItems();
857
+ const values = items.map((item: any) => item.value);
858
+ if (context.dir === 'rtl') values.reverse();
859
+ const index = values.indexOf(context.value);
860
+ const prevIndex = values.indexOf(context.previousValue);
861
+ const isSelected = value === context.value;
862
+ const wasSelected = prevIndex === values.indexOf(value);
863
+
864
+ // We only want to update selected and the last selected content
865
+ // this avoids animations being interrupted outside of that range
866
+ if (!isSelected && !wasSelected) return prevMotionAttributeRef.current;
867
+
868
+ const attribute = (() => {
869
+ // Don't provide a direction on the initial open
870
+ if (index !== prevIndex) {
871
+ // If we're moving to this item from another
872
+ if (isSelected && prevIndex !== -1) return index > prevIndex ? 'from-end' : 'from-start';
873
+ // If we're leaving this item for another
874
+ if (wasSelected && index !== -1) return index > prevIndex ? 'to-start' : 'to-end';
875
+ }
876
+ // Otherwise we're entering from closed or leaving the list
877
+ // entirely and should not animate in any direction
878
+ return null;
879
+ })() as MotionAttribute | null;
880
+
881
+ prevMotionAttributeRef.current = attribute;
882
+ return attribute;
883
+ },
884
+ [context.previousValue, context.value, context.dir, getItems, value],
885
+ subSlot(slot, 'm:motion'),
886
+ );
887
+
888
+ return createElement(FocusGroup, {
889
+ asChild: true,
890
+ children: createElement(DismissableLayer, {
891
+ id: contentId,
892
+ 'aria-labelledby': triggerId,
893
+ 'data-motion': motionAttribute,
894
+ 'data-orientation': context.orientation,
895
+ ...contentProps,
896
+ ref: composedRefs,
897
+ disableOutsidePointerEvents: false,
898
+ onDismiss: () => {
899
+ const rootContentDismissEvent = new Event(ROOT_CONTENT_DISMISS, {
900
+ bubbles: true,
901
+ cancelable: true,
902
+ });
903
+ ref.current?.dispatchEvent(rootContentDismissEvent);
904
+ },
905
+ onFocusOutside: composeEventHandlers(props?.onFocusOutside, (event: any) => {
906
+ onContentFocusOutside();
907
+ const target = event.target as HTMLElement;
908
+ // Only dismiss content when focus moves outside of the menu
909
+ if (context.rootNavigationMenu?.contains(target)) event.preventDefault();
910
+ }),
911
+ onPointerDownOutside: composeEventHandlers(props?.onPointerDownOutside, (event: any) => {
912
+ const target = event.target as HTMLElement;
913
+ const isTrigger = getItems().some((item: any) => item.ref.current?.contains(target));
914
+ const isRootViewport = context.isRootMenu && context.viewport?.contains(target);
915
+ if (isTrigger || isRootViewport || !context.isRootMenu) event.preventDefault();
916
+ }),
917
+ onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
918
+ const isMetaKey = event.altKey || event.ctrlKey || event.metaKey;
919
+ const isTabKey = event.key === 'Tab' && !isMetaKey;
920
+ if (isTabKey) {
921
+ const candidates = getTabbableCandidates(event.currentTarget as HTMLElement);
922
+ const focusedElement = document.activeElement;
923
+ const index = candidates.findIndex((candidate) => candidate === focusedElement);
924
+ const isMovingBackwards = event.shiftKey;
925
+ const nextCandidates = isMovingBackwards
926
+ ? candidates.slice(0, index).reverse()
927
+ : candidates.slice(index + 1, candidates.length);
928
+
929
+ if (focusFirst(nextCandidates)) {
930
+ // prevent browser tab keydown because we've handled focus
931
+ event.preventDefault();
932
+ } else {
933
+ // If we can't focus that means we're at the edges
934
+ // so focus the proxy and let browser handle
935
+ // tab/shift+tab keypress on the proxy instead
936
+ focusProxyRef.current?.focus();
937
+ }
938
+ }
939
+ }),
940
+ onEscapeKeyDown: composeEventHandlers(props?.onEscapeKeyDown, (_event: Event) => {
941
+ // prevent the dropdown from reopening
942
+ // after the escape key has been pressed
943
+ wasEscapeCloseRef.current = true;
944
+ }),
945
+ }),
946
+ });
947
+ }
948
+
949
+ /* -------------------------------------------------------------------------------------------------
950
+ * NavigationMenuViewport
951
+ * -----------------------------------------------------------------------------------------------*/
952
+
953
+ const VIEWPORT_NAME = 'NavigationMenuViewport';
954
+
955
+ export function Viewport(props: any): any {
956
+ const { forceMount, ...viewportProps } = props ?? {};
957
+ const context = useNavigationMenuContext(VIEWPORT_NAME, props?.__scopeNavigationMenu);
958
+ const open = Boolean(context.value);
959
+
960
+ return createElement(Presence, {
961
+ present: forceMount || open,
962
+ children: createElement(NavigationMenuViewportImpl, { ...viewportProps }),
963
+ });
964
+ }
965
+
966
+ /* -----------------------------------------------------------------------------------------------*/
967
+
968
+ function NavigationMenuViewportImpl(props: any): any {
969
+ const slot = S('NavigationMenu.ViewportImpl');
970
+ const { __scopeNavigationMenu, children, ref: forwardedRef, ...viewportImplProps } = props ?? {};
971
+ const context = useNavigationMenuContext(VIEWPORT_NAME, __scopeNavigationMenu);
972
+ const composedRefs = useComposedRefs(
973
+ forwardedRef,
974
+ context.onViewportChange,
975
+ subSlot(slot, 'refs'),
976
+ );
977
+ const viewportContentContext = useViewportContentContext(
978
+ CONTENT_NAME,
979
+ props?.__scopeNavigationMenu,
980
+ );
981
+ const [size, setSize] = useState<{ width: number; height: number } | null>(
982
+ null,
983
+ subSlot(slot, 'size'),
984
+ );
985
+ const [content, setContent] = useState<HTMLElement | null>(null, subSlot(slot, 'content'));
986
+ const viewportWidth = size ? size?.width + 'px' : undefined;
987
+ const viewportHeight = size ? size?.height + 'px' : undefined;
988
+ const open = Boolean(context.value);
989
+ // We persist the last active content value as the viewport may be animating out
990
+ // and we want the content to remain mounted for the lifecycle of the viewport.
991
+ const activeContentValue = open ? context.value : context.previousValue;
992
+
993
+ /**
994
+ * Update viewport size to match the active content node.
995
+ * We prefer offset dimensions over `getBoundingClientRect` as the latter respects CSS transform.
996
+ * For example, if content animates in from `scale(0.5)` the dimensions would be anything
997
+ * from `0.5` to `1` of the intended size.
998
+ */
999
+ const handleSizeChange = (): void => {
1000
+ if (content) setSize({ width: content.offsetWidth, height: content.offsetHeight });
1001
+ };
1002
+ useResizeObserver(content, handleSizeChange, subSlot(slot, 'ro:content'));
1003
+
1004
+ return createElement(Primitive.div, {
1005
+ 'data-state': getOpenState(open),
1006
+ 'data-orientation': context.orientation,
1007
+ ...viewportImplProps,
1008
+ ref: composedRefs,
1009
+ style: {
1010
+ // Prevent interaction when animating out
1011
+ pointerEvents: !open && context.isRootMenu ? 'none' : undefined,
1012
+ '--radix-navigation-menu-viewport-width': viewportWidth,
1013
+ '--radix-navigation-menu-viewport-height': viewportHeight,
1014
+ ...viewportImplProps.style,
1015
+ },
1016
+ onPointerEnter: composeEventHandlers(props?.onPointerEnter, context.onContentEnter),
1017
+ onPointerLeave: composeEventHandlers(props?.onPointerLeave, whenMouse(context.onContentLeave)),
1018
+ children: Array.from(viewportContentContext.items).map(
1019
+ ([value, { ref, forceMount, ...props }]) => {
1020
+ const isActive = activeContentValue === value;
1021
+ return createElement(Presence, {
1022
+ key: value,
1023
+ present: forceMount || isActive,
1024
+ children: createElement(NavigationMenuViewportItem, {
1025
+ ...props,
1026
+ contentRef: ref,
1027
+ isActive,
1028
+ onActiveContentChange: setContent,
1029
+ }),
1030
+ });
1031
+ },
1032
+ ),
1033
+ });
1034
+ }
1035
+
1036
+ /* -----------------------------------------------------------------------------------------------*/
1037
+
1038
+ function NavigationMenuViewportItem(props: any): any {
1039
+ const slot = S('NavigationMenu.ViewportItem');
1040
+ const { contentRef, isActive, onActiveContentChange, ...itemProps } = props ?? {};
1041
+ const handleContentChange = useCallback(
1042
+ (node: HTMLElement | null) => {
1043
+ // We only want to update the stored node when another is available
1044
+ // as we need to smoothly transition between them.
1045
+ if (isActive && node) {
1046
+ onActiveContentChange(node);
1047
+ }
1048
+ },
1049
+ [isActive, onActiveContentChange],
1050
+ subSlot(slot, 'change'),
1051
+ );
1052
+ const composedRefs = useComposedRefs(contentRef, handleContentChange, subSlot(slot, 'refs'));
1053
+ return createElement(NavigationMenuContentImpl, { ...itemProps, ref: composedRefs });
1054
+ }
1055
+
1056
+ /* -----------------------------------------------------------------------------------------------*/
1057
+
1058
+ const FOCUS_GROUP_NAME = 'FocusGroup';
1059
+
1060
+ function FocusGroup(props: any): any {
1061
+ const { __scopeNavigationMenu, ...groupProps } = props ?? {};
1062
+ const context = useNavigationMenuContext(FOCUS_GROUP_NAME, __scopeNavigationMenu);
1063
+
1064
+ return createElement(FocusGroupCollection.Provider, {
1065
+ scope: __scopeNavigationMenu,
1066
+ children: createElement(FocusGroupCollection.Slot, {
1067
+ scope: __scopeNavigationMenu,
1068
+ children: createElement(Primitive.div, { dir: context.dir, ...groupProps }),
1069
+ }),
1070
+ });
1071
+ }
1072
+
1073
+ /* -----------------------------------------------------------------------------------------------*/
1074
+
1075
+ const ARROW_KEYS = ['ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown'];
1076
+ const FOCUS_GROUP_ITEM_NAME = 'FocusGroupItem';
1077
+
1078
+ function FocusGroupItem(props: any): any {
1079
+ const slot = S('NavigationMenu.FocusGroupItem');
1080
+ const { __scopeNavigationMenu, ...groupProps } = props ?? {};
1081
+ const getItems = useFocusGroupCollection(__scopeNavigationMenu, subSlot(slot, 'items'));
1082
+ const context = useNavigationMenuContext(FOCUS_GROUP_ITEM_NAME, __scopeNavigationMenu);
1083
+
1084
+ return createElement(FocusGroupCollection.ItemSlot, {
1085
+ scope: __scopeNavigationMenu,
1086
+ children: createElement(Primitive.button, {
1087
+ ...groupProps,
1088
+ onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
1089
+ const isFocusNavigationKey = ['Home', 'End', ...ARROW_KEYS].includes(event.key);
1090
+ if (isFocusNavigationKey) {
1091
+ let candidateNodes = getItems().map((item: any) => item.ref.current!);
1092
+ const prevItemKey = context.dir === 'rtl' ? 'ArrowRight' : 'ArrowLeft';
1093
+ const prevKeys = [prevItemKey, 'ArrowUp', 'End'];
1094
+ if (prevKeys.includes(event.key)) candidateNodes.reverse();
1095
+ if (ARROW_KEYS.includes(event.key)) {
1096
+ const currentIndex = candidateNodes.indexOf(event.currentTarget as HTMLElement);
1097
+ candidateNodes = candidateNodes.slice(currentIndex + 1);
1098
+ }
1099
+ /**
1100
+ * Imperative focus during keydown is risky so we prevent React's batching updates
1101
+ * to avoid potential bugs. See: https://github.com/facebook/react/issues/20332
1102
+ */
1103
+ setTimeout(() => focusFirst(candidateNodes));
1104
+
1105
+ // Prevent page scroll while navigating
1106
+ event.preventDefault();
1107
+ }
1108
+ }),
1109
+ }),
1110
+ });
1111
+ }
1112
+
1113
+ /**
1114
+ * Returns a list of potential tabbable candidates.
1115
+ *
1116
+ * NOTE: This is only a close approximation. For example it doesn't take into account cases like when
1117
+ * elements are not visible. This cannot be worked out easily by just reading a property, but rather
1118
+ * necessitate runtime knowledge (computed styles, etc). We deal with these cases separately.
1119
+ *
1120
+ * See: https://developer.mozilla.org/en-US/docs/Web/API/TreeWalker
1121
+ * Credit: https://github.com/discord/focus-layers/blob/master/src/util/wrapFocus.tsx#L1
1122
+ */
1123
+ function getTabbableCandidates(container: HTMLElement): HTMLElement[] {
1124
+ const nodes: HTMLElement[] = [];
1125
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
1126
+ acceptNode: (node: any) => {
1127
+ const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
1128
+ if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
1129
+ // `.tabIndex` is not the same as the `tabindex` attribute. It works on the
1130
+ // runtime's understanding of tabbability, so this automatically accounts
1131
+ // for any kind of element that could be tabbed to.
1132
+ return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
1133
+ },
1134
+ });
1135
+ while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
1136
+ // we do not take into account the order of nodes with positive `tabIndex` as it
1137
+ // hinders accessibility to have tab order different from visual order.
1138
+ return nodes;
1139
+ }
1140
+
1141
+ function focusFirst(candidates: HTMLElement[]): boolean {
1142
+ const previouslyFocusedElement = document.activeElement;
1143
+ return candidates.some((candidate) => {
1144
+ // if focus is already where we want to go, we don't want to keep going through the candidates
1145
+ if (candidate === previouslyFocusedElement) return true;
1146
+ candidate.focus();
1147
+ return document.activeElement !== previouslyFocusedElement;
1148
+ });
1149
+ }
1150
+
1151
+ function removeFromTabOrder(candidates: HTMLElement[]): () => void {
1152
+ candidates.forEach((candidate) => {
1153
+ candidate.dataset.tabindex = candidate.getAttribute('tabindex') || '';
1154
+ candidate.setAttribute('tabindex', '-1');
1155
+ });
1156
+ return () => {
1157
+ candidates.forEach((candidate) => {
1158
+ const prevTabIndex = candidate.dataset.tabindex as string;
1159
+ candidate.setAttribute('tabindex', prevTabIndex);
1160
+ });
1161
+ };
1162
+ }
1163
+
1164
+ function useResizeObserver(
1165
+ element: HTMLElement | null,
1166
+ onResize: () => void,
1167
+ slot: symbol | undefined,
1168
+ ): void {
1169
+ // The source's `useCallbackRef(onResize)` syncs from a PASSIVE effect; octane's
1170
+ // passives are post-paint, so the layout effect below (or a timer it schedules)
1171
+ // could observe a one-render-stale closure. useEffectEvent syncs in an INSERTION
1172
+ // effect — always current before layout effects run (see the file header).
1173
+ const handleResize = useEffectEvent(onResize, subSlot(slot, 'cb'));
1174
+ useLayoutEffect(
1175
+ () => {
1176
+ let rAF = 0;
1177
+ if (element) {
1178
+ // jsdom guard (like use-size.ts): no ResizeObserver — still deliver the initial
1179
+ // observation a real ResizeObserver would fire on `observe()`. Deferred (like
1180
+ // the rAF below) so `handleResize` reads the post-commit callback.
1181
+ if (typeof ResizeObserver === 'undefined') {
1182
+ const timer = window.setTimeout(handleResize, 0);
1183
+ return () => window.clearTimeout(timer);
1184
+ }
1185
+ /**
1186
+ * Resize Observer will throw an often benign error that says `ResizeObserver loop
1187
+ * completed with undelivered notifications`. This means that ResizeObserver was not
1188
+ * able to deliver all observations within a single animation frame, so we use
1189
+ * `requestAnimationFrame` to ensure we don't deliver unnecessary observations.
1190
+ * Further reading: https://github.com/WICG/resize-observer/issues/38
1191
+ */
1192
+ const resizeObserver = new ResizeObserver(() => {
1193
+ cancelAnimationFrame(rAF);
1194
+ rAF = window.requestAnimationFrame(handleResize);
1195
+ });
1196
+ resizeObserver.observe(element);
1197
+ return () => {
1198
+ window.cancelAnimationFrame(rAF);
1199
+ resizeObserver.unobserve(element);
1200
+ };
1201
+ }
1202
+ },
1203
+ [element, handleResize],
1204
+ subSlot(slot, 'e'),
1205
+ );
1206
+ }
1207
+
1208
+ function getOpenState(open: boolean): 'open' | 'closed' {
1209
+ return open ? 'open' : 'closed';
1210
+ }
1211
+
1212
+ function makeTriggerId(baseId: string, value: string): string {
1213
+ return `${baseId}-trigger-${value}`;
1214
+ }
1215
+
1216
+ function makeContentId(baseId: string, value: string): string {
1217
+ return `${baseId}-content-${value}`;
1218
+ }
1219
+
1220
+ function whenMouse(handler: (event: PointerEvent) => void): (event: PointerEvent) => void {
1221
+ return (event) => (event.pointerType === 'mouse' ? handler(event) : undefined);
1222
+ }
1223
+
1224
+ /* -----------------------------------------------------------------------------------------------*/
1225
+
1226
+ export {
1227
+ Root as NavigationMenu,
1228
+ Sub as NavigationMenuSub,
1229
+ List as NavigationMenuList,
1230
+ Item as NavigationMenuItem,
1231
+ Trigger as NavigationMenuTrigger,
1232
+ Link as NavigationMenuLink,
1233
+ Indicator as NavigationMenuIndicator,
1234
+ Content as NavigationMenuContent,
1235
+ Viewport as NavigationMenuViewport,
1236
+ };