@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,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
|
+
};
|