@octanejs/radix 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +45 -0
  4. package/src/AccessibleIcon.ts +26 -0
  5. package/src/Accordion.ts +282 -0
  6. package/src/AlertDialog.ts +110 -0
  7. package/src/Arrow.ts +21 -0
  8. package/src/AspectRatio.ts +34 -0
  9. package/src/Avatar.ts +152 -0
  10. package/src/Checkbox.ts +325 -0
  11. package/src/Collapsible.ts +170 -0
  12. package/src/ContextMenu.ts +303 -0
  13. package/src/Dialog.ts +308 -0
  14. package/src/DismissableLayer.ts +413 -0
  15. package/src/DropdownMenu.ts +275 -0
  16. package/src/FocusScope.ts +266 -0
  17. package/src/Form.ts +621 -0
  18. package/src/HoverCard.ts +318 -0
  19. package/src/Label.ts +25 -0
  20. package/src/Menu.ts +1057 -0
  21. package/src/Menubar.ts +572 -0
  22. package/src/NavigationMenu.ts +1236 -0
  23. package/src/OneTimePasswordField.ts +977 -0
  24. package/src/PasswordToggleField.ts +495 -0
  25. package/src/Popover.ts +354 -0
  26. package/src/Popper.ts +401 -0
  27. package/src/Portal.ts +19 -0
  28. package/src/Presence.ts +225 -0
  29. package/src/Primitive.ts +53 -0
  30. package/src/Progress.ts +92 -0
  31. package/src/Radio.ts +262 -0
  32. package/src/RadioGroup.ts +212 -0
  33. package/src/RovingFocusGroup.ts +259 -0
  34. package/src/ScrollArea.ts +966 -0
  35. package/src/Select.ts +1901 -0
  36. package/src/Separator.ts +36 -0
  37. package/src/Slider.ts +745 -0
  38. package/src/Slot.ts +105 -0
  39. package/src/Switch.ts +278 -0
  40. package/src/Tabs.ts +177 -0
  41. package/src/Toast.ts +923 -0
  42. package/src/Toggle.ts +31 -0
  43. package/src/ToggleGroup.ts +157 -0
  44. package/src/Toolbar.ts +130 -0
  45. package/src/Tooltip.ts +669 -0
  46. package/src/VisuallyHidden.ts +29 -0
  47. package/src/collection.ts +97 -0
  48. package/src/compose-event-handlers.ts +15 -0
  49. package/src/compose-refs.ts +53 -0
  50. package/src/context.ts +109 -0
  51. package/src/direction.ts +19 -0
  52. package/src/focus-guards.ts +56 -0
  53. package/src/index.ts +54 -0
  54. package/src/internal.ts +42 -0
  55. package/src/scroll-lock.ts +57 -0
  56. package/src/use-callback-ref.ts +28 -0
  57. package/src/use-effect-event.ts +36 -0
  58. package/src/use-is-hydrated.ts +23 -0
  59. package/src/use-previous.ts +28 -0
  60. package/src/use-size.ts +57 -0
  61. package/src/useControllableState.ts +78 -0
  62. package/src/useId.ts +15 -0
package/src/Menu.ts ADDED
@@ -0,0 +1,1057 @@
1
+ // Ported from @radix-ui/react-menu (source:
2
+ // .radix-primitives/packages/react/menu/src/menu.tsx). The shared menu primitive that
3
+ // DropdownMenu/ContextMenu/Menubar build on: a popper-positioned `role=menu` content
4
+ // composing FocusScope + DismissableLayer + RovingFocusGroup over a Collection of items,
5
+ // with typeahead, checkbox/radio items + indicators, and pointer-grace-area submenus
6
+ // (Sub/SubTrigger/SubContent). Radix's `RemoveScroll` wrapper is replaced by the
7
+ // useScrollLock hook (see scroll-lock.ts); everything else is ported 1:1.
8
+ import { createElement, useCallback, useEffect, useRef, useState } from 'octane';
9
+ import { hideOthers } from 'aria-hidden';
10
+
11
+ import { createCollection } from './collection';
12
+ import { composeEventHandlers } from './compose-event-handlers';
13
+ import { useComposedRefs } from './compose-refs';
14
+ import { createContextScope } from './context';
15
+ import { useDirection } from './direction';
16
+ import { DismissableLayer } from './DismissableLayer';
17
+ import { FocusScope } from './FocusScope';
18
+ import { useFocusGuards } from './focus-guards';
19
+ import { S, subSlot } from './internal';
20
+ import * as PopperPrimitive from './Popper';
21
+ import { createPopperScope } from './Popper';
22
+ import { Portal as PortalPrimitive } from './Portal';
23
+ import { Presence } from './Presence';
24
+ import { dispatchDiscreteCustomEvent, Primitive } from './Primitive';
25
+ import * as RovingFocusGroup from './RovingFocusGroup';
26
+ import { createRovingFocusGroupScope } from './RovingFocusGroup';
27
+ import { useScrollLock } from './scroll-lock';
28
+ import { useCallbackRef } from './use-callback-ref';
29
+ import { useId } from './useId';
30
+
31
+ type Direction = 'ltr' | 'rtl';
32
+
33
+ const SELECTION_KEYS = ['Enter', ' '];
34
+ const FIRST_KEYS = ['ArrowDown', 'PageUp', 'Home'];
35
+ const LAST_KEYS = ['ArrowUp', 'PageDown', 'End'];
36
+ const FIRST_LAST_KEYS = [...FIRST_KEYS, ...LAST_KEYS];
37
+ const SUB_OPEN_KEYS: Record<Direction, string[]> = {
38
+ ltr: [...SELECTION_KEYS, 'ArrowRight'],
39
+ rtl: [...SELECTION_KEYS, 'ArrowLeft'],
40
+ };
41
+ const SUB_CLOSE_KEYS: Record<Direction, string[]> = {
42
+ ltr: ['ArrowLeft'],
43
+ rtl: ['ArrowRight'],
44
+ };
45
+
46
+ const MENU_NAME = 'Menu';
47
+
48
+ const [Collection, useCollection, createCollectionScope] = createCollection(MENU_NAME);
49
+
50
+ const [createMenuContext, createMenuScope] = createContextScope(MENU_NAME, [
51
+ createCollectionScope,
52
+ createPopperScope,
53
+ createRovingFocusGroupScope,
54
+ ]);
55
+ export { createMenuScope };
56
+ const usePopperScope = createPopperScope();
57
+ const useRovingFocusGroupScope = createRovingFocusGroupScope();
58
+
59
+ interface MenuContextValue {
60
+ open: boolean;
61
+ onOpenChange(open: boolean): void;
62
+ content: HTMLElement | null;
63
+ onContentChange(content: HTMLElement | null): void;
64
+ }
65
+
66
+ const [MenuProvider, useMenuContext] = createMenuContext<MenuContextValue>(MENU_NAME);
67
+
68
+ interface MenuRootContextValue {
69
+ onClose(): void;
70
+ isUsingKeyboardRef: { current: boolean };
71
+ dir: Direction;
72
+ modal: boolean;
73
+ }
74
+
75
+ const [MenuRootProvider, useMenuRootContext] = createMenuContext<MenuRootContextValue>(MENU_NAME);
76
+
77
+ export function Root(props: any): any {
78
+ const slot = S('Menu.Root');
79
+ const { __scopeMenu, open = false, children, dir, onOpenChange, modal = true } = props ?? {};
80
+ const popperScope = usePopperScope(__scopeMenu, subSlot(slot, 'popper'));
81
+ const [content, setContent] = useState<HTMLElement | null>(null, subSlot(slot, 'content'));
82
+ const isUsingKeyboardRef = useRef(false, subSlot(slot, 'keyboard'));
83
+ const handleOpenChange = useCallbackRef(onOpenChange, subSlot(slot, 'openChange'));
84
+ const direction = useDirection(dir);
85
+
86
+ useEffect(
87
+ () => {
88
+ // Capture phase ensures we set the boolean before any side effects execute
89
+ // in response to the key or pointer event as they might depend on this value.
90
+ const handleKeyDown = (): void => {
91
+ isUsingKeyboardRef.current = true;
92
+ document.addEventListener('pointerdown', handlePointer, { capture: true, once: true });
93
+ document.addEventListener('pointermove', handlePointer, { capture: true, once: true });
94
+ };
95
+ const handlePointer = (): boolean => (isUsingKeyboardRef.current = false);
96
+ document.addEventListener('keydown', handleKeyDown, { capture: true });
97
+ return () => {
98
+ document.removeEventListener('keydown', handleKeyDown, { capture: true });
99
+ document.removeEventListener('pointerdown', handlePointer, { capture: true });
100
+ document.removeEventListener('pointermove', handlePointer, { capture: true });
101
+ };
102
+ },
103
+ [],
104
+ subSlot(slot, 'e:keyboard'),
105
+ );
106
+
107
+ // Close the menu (and any open submenus) when the window loses focus, e.g. when
108
+ // switching to another browser tab or application (radix#3257).
109
+ useEffect(
110
+ () => {
111
+ if (!open) return;
112
+ const handleBlur = (): void => handleOpenChange(false);
113
+ window.addEventListener('blur', handleBlur);
114
+ return () => window.removeEventListener('blur', handleBlur);
115
+ },
116
+ [open, handleOpenChange],
117
+ subSlot(slot, 'e:blur'),
118
+ );
119
+
120
+ return createElement(PopperPrimitive.Root, {
121
+ ...popperScope,
122
+ children: createElement(MenuProvider, {
123
+ scope: __scopeMenu,
124
+ open,
125
+ onOpenChange: handleOpenChange,
126
+ content,
127
+ onContentChange: setContent,
128
+ children: createElement(MenuRootProvider, {
129
+ scope: __scopeMenu,
130
+ onClose: useCallback(
131
+ () => handleOpenChange(false),
132
+ [handleOpenChange],
133
+ subSlot(slot, 'close'),
134
+ ),
135
+ isUsingKeyboardRef,
136
+ dir: direction,
137
+ modal,
138
+ children,
139
+ }),
140
+ }),
141
+ });
142
+ }
143
+
144
+ export function Anchor(props: any): any {
145
+ const slot = S('Menu.Anchor');
146
+ const { __scopeMenu, ...anchorProps } = props ?? {};
147
+ const popperScope = usePopperScope(__scopeMenu, subSlot(slot, 'popper'));
148
+ return createElement(PopperPrimitive.Anchor, { ...popperScope, ...anchorProps });
149
+ }
150
+
151
+ const [PortalProvider, usePortalContext] = createMenuContext<{ forceMount?: boolean }>(
152
+ 'MenuPortal',
153
+ { forceMount: undefined },
154
+ );
155
+
156
+ /**
157
+ * Mounts its children into `container` (default document.body) through `Presence`.
158
+ * octane children convention: pass children at a prop/value position
159
+ * (`children={[<Content/>]}`); a function child is portal'd as a single unit.
160
+ */
161
+ export function Portal(props: any): any {
162
+ const { __scopeMenu, forceMount, children, container } = props ?? {};
163
+ const context = useMenuContext('MenuPortal', __scopeMenu);
164
+ return createElement(PortalProvider, {
165
+ scope: __scopeMenu,
166
+ forceMount,
167
+ children: createElement(Presence, {
168
+ present: forceMount || context.open,
169
+ children: createElement(PortalPrimitive, {
170
+ asChild: typeof children !== 'function',
171
+ container,
172
+ children,
173
+ }),
174
+ }),
175
+ });
176
+ }
177
+
178
+ const CONTENT_NAME = 'MenuContent';
179
+
180
+ interface MenuContentContextValue {
181
+ onItemEnter(event: PointerEvent): void;
182
+ onItemLeave(event: PointerEvent): void;
183
+ onTriggerLeave(event: PointerEvent): void;
184
+ searchRef: { current: string };
185
+ pointerGraceTimerRef: { current: number };
186
+ onPointerGraceIntentChange(intent: GraceIntent | null): void;
187
+ }
188
+ const [MenuContentProvider, useMenuContentContext] =
189
+ createMenuContext<MenuContentContextValue>(CONTENT_NAME);
190
+
191
+ export function Content(props: any): any {
192
+ const portalContext = usePortalContext(CONTENT_NAME, props?.__scopeMenu);
193
+ const { forceMount = portalContext.forceMount, ...contentProps } = props ?? {};
194
+ const context = useMenuContext(CONTENT_NAME, props?.__scopeMenu);
195
+ const rootContext = useMenuRootContext(CONTENT_NAME, props?.__scopeMenu);
196
+
197
+ return createElement(Collection.Provider, {
198
+ scope: props?.__scopeMenu,
199
+ children: createElement(Presence, {
200
+ present: forceMount || context.open,
201
+ children: createElement(Collection.Slot, {
202
+ scope: props?.__scopeMenu,
203
+ children: rootContext.modal
204
+ ? createElement(MenuRootContentModal, contentProps)
205
+ : createElement(MenuRootContentNonModal, contentProps),
206
+ }),
207
+ }),
208
+ });
209
+ }
210
+
211
+ function MenuRootContentModal(props: any): any {
212
+ const slot = S('Menu.RootContentModal');
213
+ const { ref: forwardedRef, ...rest } = props;
214
+ const context = useMenuContext(CONTENT_NAME, props.__scopeMenu);
215
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
216
+ const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
217
+
218
+ // Hide everything from ARIA except the `MenuContent`.
219
+ useEffect(
220
+ () => {
221
+ const content = ref.current;
222
+ if (content) return hideOthers(content);
223
+ },
224
+ [],
225
+ subSlot(slot, 'e:hide'),
226
+ );
227
+
228
+ return createElement(MenuContentImpl, {
229
+ ...rest,
230
+ __scopeMenu: props.__scopeMenu,
231
+ ref: composedRefs,
232
+ // we make sure we're not trapping once it's been closed
233
+ // (closed !== unmounted when animating out)
234
+ trapFocus: context.open,
235
+ // make sure to only disable pointer events when open
236
+ // this avoids blocking interactions while animating out
237
+ disableOutsidePointerEvents: context.open,
238
+ disableOutsideScroll: true,
239
+ // When focus is trapped, a `focusout` event may still happen.
240
+ // We make sure we don't trigger our `onDismiss` in such case.
241
+ onFocusOutside: composeEventHandlers(
242
+ props.onFocusOutside,
243
+ (event: Event) => event.preventDefault(),
244
+ { checkForDefaultPrevented: false },
245
+ ),
246
+ onDismiss: () => context.onOpenChange(false),
247
+ });
248
+ }
249
+
250
+ function MenuRootContentNonModal(props: any): any {
251
+ const context = useMenuContext(CONTENT_NAME, props.__scopeMenu);
252
+ return createElement(MenuContentImpl, {
253
+ ...props,
254
+ trapFocus: false,
255
+ disableOutsidePointerEvents: false,
256
+ disableOutsideScroll: false,
257
+ onDismiss: () => context.onOpenChange(false),
258
+ });
259
+ }
260
+
261
+ function MenuContentImpl(props: any): any {
262
+ const slot = S('Menu.ContentImpl');
263
+ const {
264
+ __scopeMenu,
265
+ ref: forwardedRef,
266
+ loop = false,
267
+ trapFocus,
268
+ onOpenAutoFocus,
269
+ onCloseAutoFocus,
270
+ disableOutsidePointerEvents,
271
+ onEntryFocus,
272
+ onEscapeKeyDown,
273
+ onPointerDownOutside,
274
+ onFocusOutside,
275
+ onInteractOutside,
276
+ onDismiss,
277
+ disableOutsideScroll,
278
+ ...contentProps
279
+ } = props;
280
+ const context = useMenuContext(CONTENT_NAME, __scopeMenu);
281
+ const rootContext = useMenuRootContext(CONTENT_NAME, __scopeMenu);
282
+ const popperScope = usePopperScope(__scopeMenu, subSlot(slot, 'popper'));
283
+ const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeMenu, subSlot(slot, 'rfs'));
284
+ const getItems = useCollection(__scopeMenu, subSlot(slot, 'items'));
285
+ const [currentItemId, setCurrentItemId] = useState<string | null>(null, subSlot(slot, 'item'));
286
+ const contentRef = useRef<HTMLElement | null>(null, subSlot(slot, 'content'));
287
+ const composedRefs = useComposedRefs(
288
+ forwardedRef,
289
+ contentRef,
290
+ context.onContentChange,
291
+ subSlot(slot, 'refs'),
292
+ );
293
+ const timerRef = useRef(0, subSlot(slot, 'timer'));
294
+ const searchRef = useRef('', subSlot(slot, 'search'));
295
+ const pointerGraceTimerRef = useRef(0, subSlot(slot, 'graceTimer'));
296
+ const pointerGraceIntentRef = useRef<GraceIntent | null>(null, subSlot(slot, 'graceIntent'));
297
+ const pointerDirRef = useRef<Side>('right', subSlot(slot, 'pointerDir'));
298
+ const lastPointerXRef = useRef(0, subSlot(slot, 'lastX'));
299
+
300
+ // Radix wraps in `react-remove-scroll` when disableOutsideScroll; the octane
301
+ // equivalent is the useScrollLock hook (see scroll-lock.ts).
302
+ useScrollLock(!!disableOutsideScroll, subSlot(slot, 'lock'));
303
+
304
+ const handleTypeaheadSearch = (key: string): void => {
305
+ const search = searchRef.current + key;
306
+ const items = getItems().filter((item: any) => !item.disabled);
307
+ const currentItem = document.activeElement;
308
+ const currentMatch = items.find((item: any) => item.ref.current === currentItem)?.textValue;
309
+ const values = items.map((item: any) => item.textValue);
310
+ const nextMatch = getNextMatch(values, search, currentMatch);
311
+ const newItem = items.find((item: any) => item.textValue === nextMatch)?.ref.current;
312
+
313
+ // Reset `searchRef` 1 second after it was last updated
314
+ (function updateSearch(value: string) {
315
+ searchRef.current = value;
316
+ window.clearTimeout(timerRef.current);
317
+ if (value !== '') timerRef.current = window.setTimeout(() => updateSearch(''), 1000);
318
+ })(search);
319
+
320
+ if (newItem) {
321
+ // Imperative focus during keydown is risky so we defer it (React #20332).
322
+ setTimeout(() => (newItem as HTMLElement).focus());
323
+ }
324
+ };
325
+
326
+ useEffect(
327
+ () => {
328
+ return () => window.clearTimeout(timerRef.current);
329
+ },
330
+ [],
331
+ subSlot(slot, 'e:timer'),
332
+ );
333
+
334
+ // Make sure the whole tree has focus guards as our `MenuContent` may be
335
+ // the last element in the DOM (because of the `Portal`)
336
+ useFocusGuards(subSlot(slot, 'guards'));
337
+
338
+ const isPointerMovingToSubmenu = useCallback(
339
+ (event: PointerEvent) => {
340
+ const isMovingTowards = pointerDirRef.current === pointerGraceIntentRef.current?.side;
341
+ return isMovingTowards && isPointerInGraceArea(event, pointerGraceIntentRef.current?.area);
342
+ },
343
+ [],
344
+ subSlot(slot, 'movingToSub'),
345
+ );
346
+
347
+ return createElement(MenuContentProvider, {
348
+ scope: __scopeMenu,
349
+ searchRef,
350
+ onItemEnter: useCallback(
351
+ (event: PointerEvent) => {
352
+ if (isPointerMovingToSubmenu(event)) event.preventDefault();
353
+ },
354
+ [isPointerMovingToSubmenu],
355
+ subSlot(slot, 'itemEnter'),
356
+ ),
357
+ onItemLeave: useCallback(
358
+ (event: PointerEvent) => {
359
+ if (isPointerMovingToSubmenu(event)) return;
360
+ contentRef.current?.focus();
361
+ setCurrentItemId(null);
362
+ },
363
+ [isPointerMovingToSubmenu],
364
+ subSlot(slot, 'itemLeave'),
365
+ ),
366
+ onTriggerLeave: useCallback(
367
+ (event: PointerEvent) => {
368
+ if (isPointerMovingToSubmenu(event)) event.preventDefault();
369
+ },
370
+ [isPointerMovingToSubmenu],
371
+ subSlot(slot, 'triggerLeave'),
372
+ ),
373
+ pointerGraceTimerRef,
374
+ onPointerGraceIntentChange: useCallback(
375
+ (intent: GraceIntent | null) => {
376
+ pointerGraceIntentRef.current = intent;
377
+ },
378
+ [],
379
+ subSlot(slot, 'graceChange'),
380
+ ),
381
+ children: createElement(FocusScope, {
382
+ asChild: true,
383
+ trapped: trapFocus,
384
+ onMountAutoFocus: composeEventHandlers(onOpenAutoFocus, (event: Event) => {
385
+ // when opening, explicitly focus the content area only and leave
386
+ // `onEntryFocus` in control of focusing first item
387
+ event.preventDefault();
388
+ contentRef.current?.focus({ preventScroll: true } as FocusOptions);
389
+ }),
390
+ onUnmountAutoFocus: onCloseAutoFocus,
391
+ children: createElement(DismissableLayer, {
392
+ asChild: true,
393
+ disableOutsidePointerEvents,
394
+ onEscapeKeyDown,
395
+ onPointerDownOutside,
396
+ onFocusOutside,
397
+ onInteractOutside,
398
+ onDismiss,
399
+ children: createElement(RovingFocusGroup.Root, {
400
+ asChild: true,
401
+ ...rovingFocusGroupScope,
402
+ dir: rootContext.dir,
403
+ orientation: 'vertical',
404
+ loop,
405
+ currentTabStopId: currentItemId,
406
+ onCurrentTabStopIdChange: setCurrentItemId,
407
+ onEntryFocus: composeEventHandlers(onEntryFocus, (event: Event) => {
408
+ // only focus first item when using keyboard
409
+ if (!rootContext.isUsingKeyboardRef.current) event.preventDefault();
410
+ }),
411
+ preventScrollOnEntryFocus: true,
412
+ children: createElement(PopperPrimitive.Content, {
413
+ role: 'menu',
414
+ 'aria-orientation': 'vertical',
415
+ 'data-state': getOpenState(context.open),
416
+ 'data-radix-menu-content': '',
417
+ dir: rootContext.dir,
418
+ ...popperScope,
419
+ ...contentProps,
420
+ ref: composedRefs,
421
+ style: { outline: 'none', ...contentProps.style },
422
+ onKeyDown: composeEventHandlers(contentProps.onKeyDown, (event: KeyboardEvent) => {
423
+ // submenu key events bubble through portals. We only care about keys in this menu.
424
+ const target = event.target as HTMLElement;
425
+ const isKeyDownInside =
426
+ target.closest('[data-radix-menu-content]') === event.currentTarget;
427
+ const isModifierKey = event.ctrlKey || event.altKey || event.metaKey;
428
+ const isCharacterKey = event.key.length === 1;
429
+ if (isKeyDownInside) {
430
+ // menus should not be navigated using tab key so we prevent it
431
+ if (event.key === 'Tab') event.preventDefault();
432
+ if (!isModifierKey && isCharacterKey) handleTypeaheadSearch(event.key);
433
+ }
434
+ // focus first/last item based on key pressed
435
+ const content = contentRef.current;
436
+ if (event.target !== content) return;
437
+ if (!FIRST_LAST_KEYS.includes(event.key)) return;
438
+ event.preventDefault();
439
+ const items = getItems().filter((item: any) => !item.disabled);
440
+ const candidateNodes = items.map((item: any) => item.ref.current!);
441
+ if (LAST_KEYS.includes(event.key)) candidateNodes.reverse();
442
+ focusFirst(candidateNodes);
443
+ }),
444
+ onBlur: composeEventHandlers(props.onBlur, (event: FocusEvent) => {
445
+ // clear search buffer when leaving the menu
446
+ if (!(event.currentTarget as HTMLElement).contains(event.target as Node)) {
447
+ window.clearTimeout(timerRef.current);
448
+ searchRef.current = '';
449
+ }
450
+ }),
451
+ onPointerMove: composeEventHandlers(
452
+ props.onPointerMove,
453
+ whenMouse((event: PointerEvent) => {
454
+ const target = event.target as HTMLElement;
455
+ const pointerXHasChanged = lastPointerXRef.current !== event.clientX;
456
+
457
+ // We don't use `event.movementX` for this check because Safari will
458
+ // always return `0` on a pointer event.
459
+ if ((event.currentTarget as HTMLElement).contains(target) && pointerXHasChanged) {
460
+ const newDir = event.clientX > lastPointerXRef.current ? 'right' : 'left';
461
+ pointerDirRef.current = newDir;
462
+ lastPointerXRef.current = event.clientX;
463
+ }
464
+ }),
465
+ ),
466
+ }),
467
+ }),
468
+ }),
469
+ }),
470
+ });
471
+ }
472
+
473
+ export function Group(props: any): any {
474
+ const { __scopeMenu, ...groupProps } = props ?? {};
475
+ return createElement(Primitive.div, { role: 'group', ...groupProps });
476
+ }
477
+
478
+ export function Label(props: any): any {
479
+ const { __scopeMenu, ...labelProps } = props ?? {};
480
+ return createElement(Primitive.div, { ...labelProps });
481
+ }
482
+
483
+ const ITEM_NAME = 'MenuItem';
484
+ const ITEM_SELECT = 'menu.itemSelect';
485
+
486
+ export function Item(props: any): any {
487
+ const slot = S('Menu.Item');
488
+ const { disabled = false, onSelect, ref: forwardedRef, ...itemProps } = props ?? {};
489
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
490
+ const rootContext = useMenuRootContext(ITEM_NAME, props?.__scopeMenu);
491
+ const contentContext = useMenuContentContext(ITEM_NAME, props?.__scopeMenu);
492
+ const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
493
+ const isPointerDownRef = useRef(false, subSlot(slot, 'pointerDown'));
494
+
495
+ const handleSelect = (): void => {
496
+ const menuItem = ref.current;
497
+ if (!disabled && menuItem) {
498
+ const itemSelectEvent = new CustomEvent(ITEM_SELECT, { bubbles: true, cancelable: true });
499
+ menuItem.addEventListener(ITEM_SELECT, (event) => onSelect?.(event), { once: true });
500
+ dispatchDiscreteCustomEvent(menuItem, itemSelectEvent);
501
+ if (itemSelectEvent.defaultPrevented) {
502
+ isPointerDownRef.current = false;
503
+ } else {
504
+ rootContext.onClose();
505
+ }
506
+ }
507
+ };
508
+
509
+ return createElement(MenuItemImpl, {
510
+ ...itemProps,
511
+ __scopeMenu: props?.__scopeMenu,
512
+ ref: composedRefs,
513
+ disabled,
514
+ onClick: composeEventHandlers(props?.onClick, handleSelect),
515
+ onPointerDown: (event: PointerEvent) => {
516
+ props?.onPointerDown?.(event);
517
+ isPointerDownRef.current = true;
518
+ },
519
+ onPointerUp: composeEventHandlers(props?.onPointerUp, (event: PointerEvent) => {
520
+ // Pointer down can move to a different menu item which should activate it on pointer up.
521
+ // We dispatch a click for selection to allow composition with click based triggers and to
522
+ // prevent Firefox from getting stuck in text selection mode when the menu closes.
523
+ if (!isPointerDownRef.current) (event.currentTarget as HTMLElement)?.click();
524
+ }),
525
+ onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
526
+ const isTypingAhead = contentContext.searchRef.current !== '';
527
+ if (disabled || (isTypingAhead && event.key === ' ')) return;
528
+ if (SELECTION_KEYS.includes(event.key)) {
529
+ (event.currentTarget as HTMLElement).click();
530
+ // We prevent default browser behaviour for selection keys as they should trigger
531
+ // a selection only:
532
+ // - prevents space from scrolling the page.
533
+ // - if keydown causes focus to move, prevents keydown from firing on the new target.
534
+ event.preventDefault();
535
+ }
536
+ }),
537
+ });
538
+ }
539
+
540
+ function MenuItemImpl(props: any): any {
541
+ const slot = S('Menu.ItemImpl');
542
+ const { __scopeMenu, disabled = false, textValue, ref: forwardedRef, ...itemProps } = props;
543
+ const contentContext = useMenuContentContext(ITEM_NAME, __scopeMenu);
544
+ const rovingFocusGroupScope = useRovingFocusGroupScope(__scopeMenu, subSlot(slot, 'rfs'));
545
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
546
+ const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
547
+ const [isFocused, setIsFocused] = useState(false, subSlot(slot, 'focused'));
548
+
549
+ // get the item's `.textContent` as default strategy for typeahead `textValue`
550
+ const [textContent, setTextContent] = useState('', subSlot(slot, 'text'));
551
+ useEffect(
552
+ () => {
553
+ const menuItem = ref.current;
554
+ if (menuItem) {
555
+ setTextContent((menuItem.textContent ?? '').trim());
556
+ }
557
+ },
558
+ [itemProps.children],
559
+ subSlot(slot, 'e:text'),
560
+ );
561
+
562
+ return createElement(Collection.ItemSlot, {
563
+ scope: __scopeMenu,
564
+ disabled,
565
+ textValue: textValue ?? textContent,
566
+ children: createElement(RovingFocusGroup.Item, {
567
+ asChild: true,
568
+ ...rovingFocusGroupScope,
569
+ focusable: !disabled,
570
+ children: createElement(Primitive.div, {
571
+ role: 'menuitem',
572
+ 'data-highlighted': isFocused ? '' : undefined,
573
+ 'aria-disabled': disabled || undefined,
574
+ 'data-disabled': disabled ? '' : undefined,
575
+ ...itemProps,
576
+ ref: composedRefs,
577
+ /**
578
+ * We focus items on `pointerMove` to achieve the following:
579
+ *
580
+ * - Mouse over an item (it focuses)
581
+ * - Leave mouse where it is and use keyboard to focus a different item
582
+ * - Wiggle mouse without it leaving previously focused item
583
+ * - Previously focused item should re-focus
584
+ *
585
+ * If we used `mouseOver`/`mouseEnter` it would not re-focus when the mouse
586
+ * wiggles. This is to match native menu implementation.
587
+ */
588
+ onPointerMove: composeEventHandlers(
589
+ props.onPointerMove,
590
+ whenMouse((event: PointerEvent) => {
591
+ if (disabled) {
592
+ contentContext.onItemLeave(event);
593
+ } else {
594
+ contentContext.onItemEnter(event);
595
+ if (!event.defaultPrevented) {
596
+ const item = event.currentTarget as HTMLElement;
597
+ item.focus({ preventScroll: true } as FocusOptions);
598
+ }
599
+ }
600
+ }),
601
+ ),
602
+ onPointerLeave: composeEventHandlers(
603
+ props.onPointerLeave,
604
+ whenMouse((event: PointerEvent) => contentContext.onItemLeave(event)),
605
+ ),
606
+ onFocus: composeEventHandlers(props.onFocus, () => setIsFocused(true)),
607
+ onBlur: composeEventHandlers(props.onBlur, () => setIsFocused(false)),
608
+ }),
609
+ }),
610
+ });
611
+ }
612
+
613
+ type CheckedState = boolean | 'indeterminate';
614
+
615
+ export function CheckboxItem(props: any): any {
616
+ const { checked = false, onCheckedChange, ...checkboxItemProps } = props ?? {};
617
+ return createElement(ItemIndicatorProvider, {
618
+ scope: props?.__scopeMenu,
619
+ checked,
620
+ children: createElement(Item, {
621
+ role: 'menuitemcheckbox',
622
+ 'aria-checked': isIndeterminate(checked) ? 'mixed' : checked,
623
+ ...checkboxItemProps,
624
+ 'data-state': getCheckedState(checked),
625
+ onSelect: composeEventHandlers(
626
+ checkboxItemProps.onSelect,
627
+ () => onCheckedChange?.(isIndeterminate(checked) ? true : !checked),
628
+ { checkForDefaultPrevented: false },
629
+ ),
630
+ }),
631
+ });
632
+ }
633
+
634
+ const [RadioGroupProvider, useRadioGroupContext] = createMenuContext<{
635
+ value?: string;
636
+ onValueChange?: (value: string) => void;
637
+ }>('MenuRadioGroup', { value: undefined, onValueChange: () => {} });
638
+
639
+ export function RadioGroup(props: any): any {
640
+ const slot = S('Menu.RadioGroup');
641
+ const { value, onValueChange, ...groupProps } = props ?? {};
642
+ const handleValueChange = useCallbackRef(onValueChange, subSlot(slot, 'change'));
643
+ return createElement(RadioGroupProvider, {
644
+ scope: props?.__scopeMenu,
645
+ value,
646
+ onValueChange: handleValueChange,
647
+ children: createElement(Group, groupProps),
648
+ });
649
+ }
650
+
651
+ export function RadioItem(props: any): any {
652
+ const { value, ...radioItemProps } = props ?? {};
653
+ const context = useRadioGroupContext('MenuRadioItem', props?.__scopeMenu);
654
+ const checked = value === context.value;
655
+ return createElement(ItemIndicatorProvider, {
656
+ scope: props?.__scopeMenu,
657
+ checked,
658
+ children: createElement(Item, {
659
+ role: 'menuitemradio',
660
+ 'aria-checked': checked,
661
+ ...radioItemProps,
662
+ 'data-state': getCheckedState(checked),
663
+ onSelect: composeEventHandlers(
664
+ radioItemProps.onSelect,
665
+ () => context.onValueChange?.(value),
666
+ { checkForDefaultPrevented: false },
667
+ ),
668
+ }),
669
+ });
670
+ }
671
+
672
+ const [ItemIndicatorProvider, useItemIndicatorContext] = createMenuContext<{
673
+ checked: CheckedState;
674
+ }>('MenuItemIndicator', { checked: false });
675
+
676
+ export function ItemIndicator(props: any): any {
677
+ const { __scopeMenu, forceMount, ...itemIndicatorProps } = props ?? {};
678
+ const indicatorContext = useItemIndicatorContext('MenuItemIndicator', __scopeMenu);
679
+ return createElement(Presence, {
680
+ present:
681
+ forceMount || isIndeterminate(indicatorContext.checked) || indicatorContext.checked === true,
682
+ children: createElement(Primitive.span, {
683
+ ...itemIndicatorProps,
684
+ 'data-state': getCheckedState(indicatorContext.checked),
685
+ }),
686
+ });
687
+ }
688
+
689
+ export function Separator(props: any): any {
690
+ const { __scopeMenu, ...separatorProps } = props ?? {};
691
+ return createElement(Primitive.div, {
692
+ role: 'separator',
693
+ 'aria-orientation': 'horizontal',
694
+ ...separatorProps,
695
+ });
696
+ }
697
+
698
+ export function Arrow(props: any): any {
699
+ const slot = S('Menu.Arrow');
700
+ const { __scopeMenu, ...arrowProps } = props ?? {};
701
+ const popperScope = usePopperScope(__scopeMenu, subSlot(slot, 'popper'));
702
+ return createElement(PopperPrimitive.Arrow, { ...popperScope, ...arrowProps });
703
+ }
704
+
705
+ const SUB_NAME = 'MenuSub';
706
+
707
+ interface MenuSubContextValue {
708
+ contentId: string;
709
+ triggerId: string;
710
+ trigger: HTMLElement | null;
711
+ onTriggerChange(trigger: HTMLElement | null): void;
712
+ }
713
+
714
+ const [MenuSubProvider, useMenuSubContext] = createMenuContext<MenuSubContextValue>(SUB_NAME);
715
+
716
+ export function Sub(props: any): any {
717
+ const slot = S('Menu.Sub');
718
+ const { __scopeMenu, children, open = false, onOpenChange } = props ?? {};
719
+ const parentMenuContext = useMenuContext(SUB_NAME, __scopeMenu);
720
+ const popperScope = usePopperScope(__scopeMenu, subSlot(slot, 'popper'));
721
+ const [trigger, setTrigger] = useState<HTMLElement | null>(null, subSlot(slot, 'trigger'));
722
+ const [content, setContent] = useState<HTMLElement | null>(null, subSlot(slot, 'content'));
723
+ const handleOpenChange = useCallbackRef(onOpenChange, subSlot(slot, 'openChange'));
724
+
725
+ // Prevent the parent menu from reopening with open submenus.
726
+ useEffect(
727
+ () => {
728
+ if (parentMenuContext.open === false) handleOpenChange(false);
729
+ return () => handleOpenChange(false);
730
+ },
731
+ [parentMenuContext.open, handleOpenChange],
732
+ subSlot(slot, 'e:parent'),
733
+ );
734
+
735
+ return createElement(PopperPrimitive.Root, {
736
+ ...popperScope,
737
+ children: createElement(MenuProvider, {
738
+ scope: __scopeMenu,
739
+ open,
740
+ onOpenChange: handleOpenChange,
741
+ content,
742
+ onContentChange: setContent,
743
+ children: createElement(MenuSubProvider, {
744
+ scope: __scopeMenu,
745
+ contentId: useId(subSlot(slot, 'contentId')),
746
+ triggerId: useId(subSlot(slot, 'triggerId')),
747
+ trigger,
748
+ onTriggerChange: setTrigger,
749
+ children,
750
+ }),
751
+ }),
752
+ });
753
+ }
754
+
755
+ const SUB_TRIGGER_NAME = 'MenuSubTrigger';
756
+
757
+ export function SubTrigger(props: any): any {
758
+ const slot = S('Menu.SubTrigger');
759
+ const context = useMenuContext(SUB_TRIGGER_NAME, props?.__scopeMenu);
760
+ const rootContext = useMenuRootContext(SUB_TRIGGER_NAME, props?.__scopeMenu);
761
+ const subContext = useMenuSubContext(SUB_TRIGGER_NAME, props?.__scopeMenu);
762
+ const contentContext = useMenuContentContext(SUB_TRIGGER_NAME, props?.__scopeMenu);
763
+ const openTimerRef = useRef<number | null>(null, subSlot(slot, 'openTimer'));
764
+ const { pointerGraceTimerRef, onPointerGraceIntentChange } = contentContext;
765
+ const scope = { __scopeMenu: props?.__scopeMenu };
766
+
767
+ const clearOpenTimer = useCallback(
768
+ () => {
769
+ if (openTimerRef.current) window.clearTimeout(openTimerRef.current);
770
+ openTimerRef.current = null;
771
+ },
772
+ [],
773
+ subSlot(slot, 'clear'),
774
+ );
775
+
776
+ useEffect(() => clearOpenTimer, [clearOpenTimer], subSlot(slot, 'e:clear'));
777
+
778
+ useEffect(
779
+ () => {
780
+ const pointerGraceTimer = pointerGraceTimerRef.current;
781
+ return () => {
782
+ window.clearTimeout(pointerGraceTimer);
783
+ onPointerGraceIntentChange(null);
784
+ };
785
+ },
786
+ [pointerGraceTimerRef, onPointerGraceIntentChange],
787
+ subSlot(slot, 'e:grace'),
788
+ );
789
+
790
+ const composedRefs = useComposedRefs(
791
+ props?.ref,
792
+ subContext.onTriggerChange,
793
+ subSlot(slot, 'refs'),
794
+ );
795
+
796
+ return createElement(Anchor, {
797
+ asChild: true,
798
+ ...scope,
799
+ children: createElement(MenuItemImpl, {
800
+ id: subContext.triggerId,
801
+ 'aria-haspopup': 'menu',
802
+ 'aria-expanded': context.open,
803
+ 'aria-controls': context.open ? subContext.contentId : undefined,
804
+ 'data-state': getOpenState(context.open),
805
+ ...props,
806
+ ref: composedRefs,
807
+ // This is redundant for mouse users but we cannot determine pointer type from
808
+ // click event and we cannot use pointerup event (see git history for reasons why)
809
+ onClick: (event: MouseEvent) => {
810
+ props?.onClick?.(event);
811
+ if (props?.disabled || event.defaultPrevented) return;
812
+ // We manually focus because iOS Safari doesn't always focus on click (e.g.
813
+ // buttons) and we rely heavily on `onFocusOutside` for submenus to close when
814
+ // switching between separate submenus.
815
+ (event.currentTarget as HTMLElement).focus();
816
+ if (!context.open) context.onOpenChange(true);
817
+ },
818
+ onPointerMove: composeEventHandlers(
819
+ props?.onPointerMove,
820
+ whenMouse((event: PointerEvent) => {
821
+ contentContext.onItemEnter(event);
822
+ if (event.defaultPrevented) return;
823
+ if (!props?.disabled && !context.open && !openTimerRef.current) {
824
+ contentContext.onPointerGraceIntentChange(null);
825
+ openTimerRef.current = window.setTimeout(() => {
826
+ context.onOpenChange(true);
827
+ clearOpenTimer();
828
+ }, 100);
829
+ }
830
+ }),
831
+ ),
832
+ onPointerLeave: composeEventHandlers(
833
+ props?.onPointerLeave,
834
+ whenMouse((event: PointerEvent) => {
835
+ clearOpenTimer();
836
+
837
+ const contentRect = context.content?.getBoundingClientRect();
838
+ if (contentRect) {
839
+ // Radix keys the grace polygon off the content's placed side.
840
+ const side = (context.content?.dataset.side as Side) ?? 'right';
841
+ const rightSide = side === 'right';
842
+ const bleed = rightSide ? -5 : +5;
843
+ const contentNearEdge = contentRect[rightSide ? 'left' : 'right'];
844
+ const contentFarEdge = contentRect[rightSide ? 'right' : 'left'];
845
+
846
+ contentContext.onPointerGraceIntentChange({
847
+ area: [
848
+ // Apply a bleed on clientX to ensure that our exit point is
849
+ // consistently within polygon bounds
850
+ { x: event.clientX + bleed, y: event.clientY },
851
+ { x: contentNearEdge, y: contentRect.top },
852
+ { x: contentFarEdge, y: contentRect.top },
853
+ { x: contentFarEdge, y: contentRect.bottom },
854
+ { x: contentNearEdge, y: contentRect.bottom },
855
+ ],
856
+ side,
857
+ });
858
+
859
+ window.clearTimeout(pointerGraceTimerRef.current);
860
+ pointerGraceTimerRef.current = window.setTimeout(
861
+ () => contentContext.onPointerGraceIntentChange(null),
862
+ 300,
863
+ );
864
+ } else {
865
+ contentContext.onTriggerLeave(event);
866
+ if (event.defaultPrevented) return;
867
+
868
+ // There's 100ms where the user may leave an item before the submenu was opened.
869
+ contentContext.onPointerGraceIntentChange(null);
870
+ }
871
+ }),
872
+ ),
873
+ onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
874
+ const isTypingAhead = contentContext.searchRef.current !== '';
875
+ if (props?.disabled || (isTypingAhead && event.key === ' ')) return;
876
+ if (SUB_OPEN_KEYS[rootContext.dir].includes(event.key)) {
877
+ context.onOpenChange(true);
878
+ // The trigger may hold focus if opened via pointer interaction
879
+ // so we ensure content is given focus again when switching to keyboard.
880
+ context.content?.focus();
881
+ // prevent window from scrolling
882
+ event.preventDefault();
883
+ }
884
+ }),
885
+ }),
886
+ });
887
+ }
888
+
889
+ const SUB_CONTENT_NAME = 'MenuSubContent';
890
+
891
+ export function SubContent(props: any): any {
892
+ const slot = S('Menu.SubContent');
893
+ const portalContext = usePortalContext(CONTENT_NAME, props?.__scopeMenu);
894
+ const {
895
+ forceMount = portalContext.forceMount,
896
+ align = 'start',
897
+ ref: forwardedRef,
898
+ ...subContentProps
899
+ } = props ?? {};
900
+ const context = useMenuContext(CONTENT_NAME, props?.__scopeMenu);
901
+ const rootContext = useMenuRootContext(CONTENT_NAME, props?.__scopeMenu);
902
+ const subContext = useMenuSubContext(SUB_CONTENT_NAME, props?.__scopeMenu);
903
+ const ref = useRef<HTMLElement | null>(null, subSlot(slot, 'ref'));
904
+ const composedRefs = useComposedRefs(forwardedRef, ref, subSlot(slot, 'refs'));
905
+ return createElement(Collection.Provider, {
906
+ scope: props?.__scopeMenu,
907
+ children: createElement(Presence, {
908
+ present: forceMount || context.open,
909
+ children: createElement(Collection.Slot, {
910
+ scope: props?.__scopeMenu,
911
+ children: createElement(MenuContentImpl, {
912
+ id: subContext.contentId,
913
+ 'aria-labelledby': subContext.triggerId,
914
+ ...subContentProps,
915
+ __scopeMenu: props?.__scopeMenu,
916
+ ref: composedRefs,
917
+ align,
918
+ side: rootContext.dir === 'rtl' ? 'left' : 'right',
919
+ disableOutsidePointerEvents: false,
920
+ disableOutsideScroll: false,
921
+ trapFocus: false,
922
+ onOpenAutoFocus: (event: Event) => {
923
+ // when opening a submenu, focus content for keyboard users only
924
+ if (rootContext.isUsingKeyboardRef.current) ref.current?.focus();
925
+ event.preventDefault();
926
+ },
927
+ // The menu might close because of focusing another menu item in the parent
928
+ // menu. We don't want it to refocus the trigger in that case so we handle
929
+ // trigger focus ourselves.
930
+ onCloseAutoFocus: (event: Event) => event.preventDefault(),
931
+ onFocusOutside: composeEventHandlers(props?.onFocusOutside, (event: any) => {
932
+ // We prevent closing when the trigger is focused to avoid triggering a
933
+ // re-open animation on pointer interaction.
934
+ if (event.target !== subContext.trigger) context.onOpenChange(false);
935
+ }),
936
+ onEscapeKeyDown: composeEventHandlers(props?.onEscapeKeyDown, (event: Event) => {
937
+ rootContext.onClose();
938
+ // ensure pressing escape in submenu doesn't escape full screen mode
939
+ event.preventDefault();
940
+ }),
941
+ onKeyDown: composeEventHandlers(props?.onKeyDown, (event: KeyboardEvent) => {
942
+ // Submenu key events bubble through portals. We only care about keys in this menu.
943
+ const isKeyDownInside = (event.currentTarget as HTMLElement).contains(
944
+ event.target as HTMLElement,
945
+ );
946
+ const isCloseKey = SUB_CLOSE_KEYS[rootContext.dir].includes(event.key);
947
+ if (isKeyDownInside && isCloseKey) {
948
+ context.onOpenChange(false);
949
+ // We focus manually because we prevented it in `onCloseAutoFocus`
950
+ subContext.trigger?.focus();
951
+ // prevent window from scrolling
952
+ event.preventDefault();
953
+ }
954
+ }),
955
+ }),
956
+ }),
957
+ }),
958
+ });
959
+ }
960
+
961
+ function getOpenState(open: boolean): 'open' | 'closed' {
962
+ return open ? 'open' : 'closed';
963
+ }
964
+
965
+ function isIndeterminate(checked?: CheckedState): checked is 'indeterminate' {
966
+ return checked === 'indeterminate';
967
+ }
968
+
969
+ function getCheckedState(checked: CheckedState): string {
970
+ return isIndeterminate(checked) ? 'indeterminate' : checked ? 'checked' : 'unchecked';
971
+ }
972
+
973
+ function focusFirst(candidates: HTMLElement[]): void {
974
+ const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement;
975
+ for (const candidate of candidates) {
976
+ // if focus is already where we want to go, we don't want to keep going through the candidates
977
+ if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return;
978
+ candidate.focus();
979
+ if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return;
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Wraps an array around itself at a given start index
985
+ * Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`
986
+ */
987
+ function wrapArray<T>(array: T[], startIndex: number): T[] {
988
+ return array.map<T>((_, index) => array[(startIndex + index) % array.length]!);
989
+ }
990
+
991
+ /**
992
+ * This is the "meat" of the typeahead matching logic. It takes in all the values,
993
+ * the search and the current match, and returns the next match (or `undefined`).
994
+ *
995
+ * We normalize the search because if a user has repeatedly pressed a character,
996
+ * we want the exact same behavior as if we only had that one character
997
+ * (ie. cycle through options starting with that character)
998
+ *
999
+ * We also reorder the values by wrapping the array around the current match.
1000
+ * This is so we always look forward from the current match, and picking the first
1001
+ * match will always be the correct one.
1002
+ *
1003
+ * Finally, if the normalized search is exactly one character, we exclude the
1004
+ * current match from the values because otherwise it would be the first to match always
1005
+ * and focus would never move. This is as opposed to the regular case, where we
1006
+ * don't want focus to move if the current match still matches.
1007
+ */
1008
+ function getNextMatch(values: string[], search: string, currentMatch?: string): string | undefined {
1009
+ const isRepeated = search.length > 1 && Array.from(search).every((char) => char === search[0]);
1010
+ const normalizedSearch = isRepeated ? search[0]! : search;
1011
+ const currentMatchIndex = currentMatch ? values.indexOf(currentMatch) : -1;
1012
+ let wrappedValues = wrapArray(values, Math.max(currentMatchIndex, 0));
1013
+ const excludeCurrentMatch = normalizedSearch.length === 1;
1014
+ if (excludeCurrentMatch) wrappedValues = wrappedValues.filter((v) => v !== currentMatch);
1015
+ const nextMatch = wrappedValues.find((value) =>
1016
+ value.toLowerCase().startsWith(normalizedSearch.toLowerCase()),
1017
+ );
1018
+ return nextMatch !== currentMatch ? nextMatch : undefined;
1019
+ }
1020
+
1021
+ type Point = { x: number; y: number };
1022
+ type Polygon = Point[];
1023
+ type Side = 'left' | 'right';
1024
+ type GraceIntent = { area: Polygon; side: Side };
1025
+
1026
+ // Determine if a point is inside of a polygon.
1027
+ // Based on https://github.com/substack/point-in-polygon
1028
+ function isPointInPolygon(point: Point, polygon: Polygon): boolean {
1029
+ const { x, y } = point;
1030
+ let inside = false;
1031
+ for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
1032
+ const ii = polygon[i]!;
1033
+ const jj = polygon[j]!;
1034
+ const xi = ii.x;
1035
+ const yi = ii.y;
1036
+ const xj = jj.x;
1037
+ const yj = jj.y;
1038
+
1039
+ // prettier-ignore
1040
+ const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
1041
+ if (intersect) inside = !inside;
1042
+ }
1043
+
1044
+ return inside;
1045
+ }
1046
+
1047
+ function isPointerInGraceArea(event: PointerEvent, area?: Polygon): boolean {
1048
+ if (!area) return false;
1049
+ const cursorPos = { x: event.clientX, y: event.clientY };
1050
+ return isPointInPolygon(cursorPos, area);
1051
+ }
1052
+
1053
+ function whenMouse(handler: (event: PointerEvent) => void): (event: PointerEvent) => void {
1054
+ return (event) => (event.pointerType === 'mouse' ? handler(event) : undefined);
1055
+ }
1056
+
1057
+ export { Root as Menu, Anchor as MenuAnchor, Content as MenuContent, Item as MenuItem };