@tamagui/menu 3.0.0-beta.924.1 → 3.0.0

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 (36) hide show
  1. package/dist/cjs/Menu.cjs +1 -0
  2. package/dist/cjs/Menu.native.cjs +1 -0
  3. package/dist/cjs/Menu.native.js +1 -0
  4. package/dist/cjs/Menu.native.js.map +1 -1
  5. package/dist/cjs/createNonNativeMenu.cjs +185 -13
  6. package/dist/cjs/createNonNativeMenu.native.cjs +229 -22
  7. package/dist/cjs/createNonNativeMenu.native.js +229 -22
  8. package/dist/cjs/createNonNativeMenu.native.js.map +1 -1
  9. package/dist/esm/Menu.mjs +1 -0
  10. package/dist/esm/Menu.mjs.map +1 -1
  11. package/dist/esm/Menu.native.js +1 -0
  12. package/dist/esm/Menu.native.js.map +1 -1
  13. package/dist/esm/createNonNativeMenu.mjs +185 -13
  14. package/dist/esm/createNonNativeMenu.mjs.map +1 -1
  15. package/dist/esm/createNonNativeMenu.native.js +229 -22
  16. package/dist/esm/createNonNativeMenu.native.js.map +1 -1
  17. package/dist/jsx/Menu.mjs +1 -0
  18. package/dist/jsx/Menu.mjs.map +1 -1
  19. package/dist/jsx/Menu.native.cjs +1 -0
  20. package/dist/jsx/Menu.native.js +1 -0
  21. package/dist/jsx/Menu.native.js.map +1 -1
  22. package/dist/jsx/createNonNativeMenu.mjs +185 -13
  23. package/dist/jsx/createNonNativeMenu.mjs.map +1 -1
  24. package/dist/jsx/createNonNativeMenu.native.cjs +229 -22
  25. package/dist/jsx/createNonNativeMenu.native.js +229 -22
  26. package/dist/jsx/createNonNativeMenu.native.js.map +1 -1
  27. package/package.json +14 -12
  28. package/src/Menu.tsx +1 -0
  29. package/src/createNonNativeMenu.tsx +273 -18
  30. package/src/index.tsx +2 -0
  31. package/types/Menu.d.ts +31 -14
  32. package/types/Menu.d.ts.map +1 -1
  33. package/types/createNonNativeMenu.d.ts +33 -33
  34. package/types/createNonNativeMenu.d.ts.map +1 -1
  35. package/types/index.d.ts +32 -14
  36. package/types/index.d.ts.map +1 -1
@@ -49,6 +49,120 @@ type ScopedProps<P> = P & { scope?: string }
49
49
 
50
50
  type MenuTriggerStateSetter = React.Dispatch<React.SetStateAction<boolean>>
51
51
 
52
+ type TriggerGroupEntry = {
53
+ id: string
54
+ ref: React.RefObject<TamaguiElement | null>
55
+ openRef: React.RefObject<boolean>
56
+ disabled: boolean
57
+ activate(): void
58
+ focusContent(): void
59
+ onOpenChange(open: boolean): void
60
+ }
61
+
62
+ type TriggerGroupContextValue = {
63
+ dir: Direction
64
+ tabStopId: string | null
65
+ register(entry: TriggerGroupEntry): () => void
66
+ activate(entry: TriggerGroupEntry): void
67
+ isOpen(): boolean
68
+ isActive(openRef: React.RefObject<boolean>): boolean
69
+ contains(target: EventTarget | null): boolean
70
+ move(id: string, key: string, open: boolean): void
71
+ onFocus(id: string): void
72
+ }
73
+
74
+ type MenuTriggerGroupProps = ViewProps & { dir?: Direction }
75
+
76
+ const TriggerGroupContext = React.createContext<TriggerGroupContextValue | null>(null)
77
+
78
+ const MenuTriggerGroup = createStyledHOC(
79
+ View,
80
+ ({ children, dir = 'ltr', ...props }: MenuTriggerGroupProps, forwardedRef) => {
81
+ const entries = React.useRef(new Map<string, TriggerGroupEntry>())
82
+ const active = React.useRef<TriggerGroupEntry | null>(null)
83
+ const [tabStopId, setTabStopId] = React.useState<string | null>(null)
84
+ const register = useEvent((entry: TriggerGroupEntry) => {
85
+ entries.current.set(entry.id, entry)
86
+ if (!entry.disabled) setTabStopId((id) => id ?? entry.id)
87
+ return () => {
88
+ entries.current.delete(entry.id)
89
+ if (active.current === entry) active.current = null
90
+ setTabStopId((id) =>
91
+ id === entry.id
92
+ ? ([...entries.current.values()].find((item) => !item.disabled)?.id ?? null)
93
+ : id
94
+ )
95
+ }
96
+ })
97
+ const activate = useEvent((entry: TriggerGroupEntry) => {
98
+ const previous = active.current
99
+ active.current = entry
100
+ setTabStopId(entry.id)
101
+ if (previous && previous.openRef !== entry.openRef && previous.openRef.current) {
102
+ previous.onOpenChange(false)
103
+ }
104
+ entry.activate()
105
+ })
106
+ const isOpen = useEvent(() => Boolean(active.current?.openRef.current))
107
+ const isActive = useEvent(
108
+ (openRef: React.RefObject<boolean>) => active.current?.openRef === openRef
109
+ )
110
+ const contains = useEvent((target: EventTarget | null) =>
111
+ [...entries.current.values()].some((entry) =>
112
+ (entry.ref.current as HTMLElement | null)?.contains(target as Node)
113
+ )
114
+ )
115
+ const move = useEvent((id: string, key: string, open: boolean) => {
116
+ const ordered = [...entries.current.values()]
117
+ .filter((entry) => !entry.disabled)
118
+ .sort((a, b) => {
119
+ const first = a.ref.current as HTMLElement | null
120
+ const second = b.ref.current as HTMLElement | null
121
+ return first && second && first.compareDocumentPosition(second) & 2 ? 1 : -1
122
+ })
123
+ const index = ordered.findIndex((entry) => entry.id === id)
124
+ if (index === -1) return
125
+ const step = (key === 'ArrowRight') === (dir === 'ltr') ? 1 : -1
126
+ const next = ordered[(index + step + ordered.length) % ordered.length]
127
+ next.ref.current?.focus?.()
128
+ if (open) {
129
+ activate(next)
130
+ next.onOpenChange(true)
131
+ requestAnimationFrame(() => next.focusContent())
132
+ }
133
+ })
134
+ const onFocus = useEvent((id: string) => setTabStopId(id))
135
+ const value = React.useMemo(
136
+ () => ({
137
+ dir,
138
+ tabStopId,
139
+ register,
140
+ activate,
141
+ isOpen,
142
+ isActive,
143
+ contains,
144
+ move,
145
+ onFocus,
146
+ }),
147
+ [dir, tabStopId, register, activate, isOpen, isActive, contains, move, onFocus]
148
+ )
149
+ return (
150
+ <TriggerGroupContext.Provider value={value}>
151
+ <View
152
+ flexDirection="row"
153
+ role="menubar"
154
+ {...props}
155
+ {...(isWeb && { dir })}
156
+ ref={forwardedRef}
157
+ >
158
+ {children}
159
+ </View>
160
+ </TriggerGroupContext.Provider>
161
+ )
162
+ }
163
+ )
164
+ MenuTriggerGroup.displayName = 'MenuTriggerGroup'
165
+
52
166
  type MenuContextValue = {
53
167
  triggerId: string
54
168
  triggerRef: React.RefObject<TamaguiElement | null>
@@ -58,13 +172,26 @@ type MenuContextValue = {
58
172
  onOpenToggle(): void
59
173
  modal: boolean
60
174
  setActiveTrigger(id: string | null): void
61
- registerTrigger(id: string, setOpen: MenuTriggerStateSetter): void
175
+ registerTrigger(
176
+ id: string,
177
+ setOpen: MenuTriggerStateSetter,
178
+ group: TriggerGroupContextValue | null
179
+ ): void
62
180
  unregisterTrigger(id: string): void
181
+ activeTriggerIdRef: React.RefObject<string | null>
182
+ activeGroupRef: React.RefObject<TriggerGroupContextValue | null>
63
183
  }
64
184
 
65
185
  function useMenuTriggerSetup(open: boolean) {
66
186
  const triggerStateSettersRef = React.useRef(new Map<string, MenuTriggerStateSetter>())
67
187
  const activeTriggerIdRef = React.useRef<string | null>(null)
188
+ const triggerGroupsRef = React.useRef(
189
+ new Map<string, TriggerGroupContextValue | null>()
190
+ )
191
+ const activeGroupRef = React.useRef<TriggerGroupContextValue | null>(null)
192
+ const [activeGroup, setActiveGroup] = React.useState<TriggerGroupContextValue | null>(
193
+ null
194
+ )
68
195
 
69
196
  const setActiveTrigger = useEvent((id: string | null) => {
70
197
  const prevId = activeTriggerIdRef.current
@@ -73,20 +200,32 @@ function useMenuTriggerSetup(open: boolean) {
73
200
  triggerStateSettersRef.current.get(prevId)?.(false)
74
201
  }
75
202
  activeTriggerIdRef.current = id
203
+ activeGroupRef.current = id ? (triggerGroupsRef.current.get(id) ?? null) : null
204
+ setActiveGroup(activeGroupRef.current)
76
205
  if (id && open) {
77
206
  triggerStateSettersRef.current.get(id)?.(true)
78
207
  }
79
208
  })
80
209
 
81
- const registerTrigger = useEvent((id: string, setOpenState: MenuTriggerStateSetter) => {
82
- triggerStateSettersRef.current.set(id, setOpenState)
83
- setOpenState(activeTriggerIdRef.current === id && open)
84
- })
210
+ const registerTrigger = useEvent(
211
+ (
212
+ id: string,
213
+ setOpenState: MenuTriggerStateSetter,
214
+ group: TriggerGroupContextValue | null
215
+ ) => {
216
+ triggerGroupsRef.current.set(id, group)
217
+ triggerStateSettersRef.current.set(id, setOpenState)
218
+ setOpenState(activeTriggerIdRef.current === id && open)
219
+ }
220
+ )
85
221
 
86
222
  const unregisterTrigger = useEvent((id: string) => {
87
223
  triggerStateSettersRef.current.delete(id)
224
+ triggerGroupsRef.current.delete(id)
88
225
  if (activeTriggerIdRef.current === id) {
89
226
  activeTriggerIdRef.current = null
227
+ activeGroupRef.current = null
228
+ setActiveGroup(null)
90
229
  }
91
230
  })
92
231
 
@@ -101,7 +240,14 @@ function useMenuTriggerSetup(open: boolean) {
101
240
  }
102
241
  }, [open, setActiveTrigger])
103
242
 
104
- return { setActiveTrigger, registerTrigger, unregisterTrigger }
243
+ return {
244
+ setActiveTrigger,
245
+ registerTrigger,
246
+ unregisterTrigger,
247
+ activeTriggerIdRef,
248
+ activeGroupRef,
249
+ activeGroup,
250
+ }
105
251
  }
106
252
 
107
253
  interface MenuProps extends BaseMenuTypes.MenuProps {
@@ -225,6 +371,7 @@ export function createNonNativeMenu() {
225
371
  modal = true,
226
372
  ...rest
227
373
  } = props
374
+ const outerGroup = React.useContext(TriggerGroupContext)
228
375
  const triggerRef = React.useRef<TamaguiElement>(null)
229
376
  const [open = false, setOpen] = useControllableState({
230
377
  prop: openProp,
@@ -233,8 +380,15 @@ export function createNonNativeMenu() {
233
380
  })
234
381
  const openRef = React.useRef(open)
235
382
  openRef.current = open
236
- const { setActiveTrigger, registerTrigger, unregisterTrigger } =
237
- useMenuTriggerSetup(open)
383
+ const {
384
+ setActiveTrigger,
385
+ registerTrigger,
386
+ unregisterTrigger,
387
+ activeTriggerIdRef,
388
+ activeGroupRef,
389
+ activeGroup,
390
+ } = useMenuTriggerSetup(open)
391
+ const effectiveModal = outerGroup || activeGroup ? false : modal
238
392
 
239
393
  return (
240
394
  <MenuProvider
@@ -251,17 +405,19 @@ export function createNonNativeMenu() {
251
405
  () => setOpen((prevOpen) => !prevOpen),
252
406
  [setOpen]
253
407
  )}
254
- modal={modal}
408
+ modal={effectiveModal}
255
409
  setActiveTrigger={setActiveTrigger}
256
410
  registerTrigger={registerTrigger}
257
411
  unregisterTrigger={unregisterTrigger}
412
+ activeTriggerIdRef={activeTriggerIdRef}
413
+ activeGroupRef={activeGroupRef}
258
414
  >
259
415
  <Menu
260
416
  scope={scope || DROPDOWN_MENU_CONTEXT}
261
417
  open={open}
262
418
  onOpenChange={setOpen}
263
- dir={dir}
264
- modal={modal}
419
+ dir={dir ?? outerGroup?.dir}
420
+ modal={effectiveModal}
265
421
  {...rest}
266
422
  >
267
423
  {children}
@@ -293,6 +449,7 @@ export function createNonNativeMenu() {
293
449
  ...triggerProps
294
450
  } = props
295
451
  const context = useMenuContext(scope)
452
+ const group = React.useContext(TriggerGroupContext)
296
453
  const popperCtx = usePopperContextSlow(scope || DROPDOWN_MENU_CONTEXT)
297
454
  const Comp = asChild ? Slot : View
298
455
  const isTouchDevice = useIsTouchDevice()
@@ -305,9 +462,9 @@ export function createNonNativeMenu() {
305
462
  // extract stable refs so re-registration doesn't happen when context object changes
306
463
  const { registerTrigger, unregisterTrigger } = context
307
464
  React.useEffect(() => {
308
- registerTrigger(triggerId, setTriggerOpen)
465
+ registerTrigger(triggerId, setTriggerOpen, group)
309
466
  return () => unregisterTrigger(triggerId)
310
- }, [registerTrigger, unregisterTrigger, triggerId])
467
+ }, [registerTrigger, unregisterTrigger, triggerId, group?.register])
311
468
 
312
469
  // activate this trigger: set popper reference and update shared triggerRef for close-auto-focus
313
470
  const activateSelf = React.useCallback(() => {
@@ -323,6 +480,40 @@ export function createNonNativeMenu() {
323
480
  }
324
481
  }, [context, triggerId, popperCtx])
325
482
 
483
+ const activate = useEvent(activateSelf)
484
+ const focusContent = useEvent(() => {
485
+ document
486
+ .getElementById(context.contentId)
487
+ ?.querySelector<HTMLElement>('[role^="menuitem"]:not([data-disabled])')
488
+ ?.focus()
489
+ })
490
+ const entry = React.useRef<TriggerGroupEntry>(null!)
491
+ if (!entry.current) {
492
+ entry.current = {
493
+ id: triggerId,
494
+ ref: triggerElRef,
495
+ openRef: context.openRef,
496
+ disabled,
497
+ activate,
498
+ focusContent,
499
+ onOpenChange: context.onOpenChange,
500
+ }
501
+ }
502
+ entry.current.disabled = disabled
503
+ entry.current.onOpenChange = context.onOpenChange
504
+ const registerGroup = group?.register
505
+ React.useEffect(() => registerGroup?.(entry.current), [registerGroup])
506
+ const openSelf = () => {
507
+ if (group) group.activate(entry.current)
508
+ else activateSelf()
509
+ }
510
+
511
+ const shouldRejectHover = () =>
512
+ disabled ||
513
+ (context.openRef.current &&
514
+ context.activeTriggerIdRef.current !== triggerId &&
515
+ !group?.isActive(context.openRef))
516
+
326
517
  // Use onClick for touch devices to avoid race condition with Dismissable
327
518
  // Use onPointerDown for mouse for faster feedback
328
519
  const pressEvent = isWeb ? (isTouchDevice ? 'onClick' : 'onPointerDown') : 'onPress'
@@ -334,7 +525,8 @@ export function createNonNativeMenu() {
334
525
  scope={scope || DROPDOWN_MENU_CONTEXT}
335
526
  >
336
527
  <Comp
337
- role="button"
528
+ role={group ? 'menuitem' : 'button'}
529
+ tabIndex={group ? (group.tabStopId === triggerId ? 0 : -1) : undefined}
338
530
  id={context.triggerId}
339
531
  aria-haspopup="menu"
340
532
  aria-expanded={triggerOpen}
@@ -342,13 +534,42 @@ export function createNonNativeMenu() {
342
534
  data-state={triggerOpen ? 'open' : 'closed'}
343
535
  data-disabled={disabled ? '' : undefined}
344
536
  aria-disabled={disabled || undefined}
345
- ref={composeRefs(forwardedRef, context.triggerRef, triggerElRef)}
537
+ ref={composeRefs(
538
+ forwardedRef,
539
+ group ? undefined : context.triggerRef,
540
+ triggerElRef
541
+ )}
346
542
  // caller props come first: the composed handlers below already call
347
543
  // the caller's own handler, so spreading them after would let a
348
544
  // caller's onPointerDown/onClick/onPress or onKeyDown replace the
349
545
  // one that opens the menu. the press prop stays read off `props`
350
546
  // because which of the three opens the menu is decided at runtime.
351
547
  {...triggerProps}
548
+ onFocus={composeEventHandlers(props.onFocus, () => {
549
+ if (!disabled) group?.onFocus(triggerId)
550
+ })}
551
+ onPointerEnter={(event) => {
552
+ // popper checks this child handler before changing its reference.
553
+ if (shouldRejectHover()) {
554
+ event.preventDefault()
555
+ return
556
+ }
557
+ props.onPointerEnter?.(event)
558
+ }}
559
+ onMouseEnter={(event) => {
560
+ // reject before caller handlers can change the shared descriptor.
561
+ if (shouldRejectHover()) {
562
+ event.preventDefault()
563
+ return
564
+ }
565
+ composeEventHandlers(props.onMouseEnter, () => {
566
+ if (group?.isOpen() && !triggerOpen) {
567
+ triggerElRef.current?.focus?.()
568
+ openSelf()
569
+ context.onOpenChange(true)
570
+ }
571
+ })?.(event)
572
+ }}
352
573
  {...{
353
574
  [pressEvent]: composeEventHandlers(
354
575
  //@ts-ignore
@@ -367,7 +588,7 @@ export function createNonNativeMenu() {
367
588
  if (context.openRef.current) {
368
589
  context.setActiveTrigger(null)
369
590
  } else {
370
- activateSelf()
591
+ openSelf()
371
592
  }
372
593
  context.onOpenToggle()
373
594
  // prevent trigger focusing when opening
@@ -380,16 +601,21 @@ export function createNonNativeMenu() {
380
601
  {...(isWeb && {
381
602
  onKeyDown: composeEventHandlers(onKeyDown, (event) => {
382
603
  if (disabled) return
604
+ if (group && ['ArrowLeft', 'ArrowRight'].includes(event.key)) {
605
+ group.move(triggerId, event.key, group.isOpen())
606
+ event.preventDefault()
607
+ return
608
+ }
383
609
  if (['Enter', ' '].includes(event.key)) {
384
610
  if (context.openRef.current) {
385
611
  context.setActiveTrigger(null)
386
612
  } else {
387
- activateSelf()
613
+ openSelf()
388
614
  }
389
615
  context.onOpenToggle()
390
616
  }
391
617
  if (event.key === 'ArrowDown') {
392
- activateSelf()
618
+ openSelf()
393
619
  context.onOpenChange(true)
394
620
  }
395
621
  // prevent keydown from scrolling window / first focused item to execute
@@ -453,7 +679,30 @@ export function createNonNativeMenu() {
453
679
  scope={scope || DROPDOWN_MENU_CONTEXT}
454
680
  {...contentProps}
455
681
  ref={forwardedRef}
682
+ onKeyDown={composeEventHandlers(props.onKeyDown, (event) => {
683
+ const group = context.activeGroupRef.current
684
+ if (!group || !['ArrowLeft', 'ArrowRight'].includes(event.key)) return
685
+ // submenu handlers own their open/close keys, including portal bubbling
686
+ const target = event.target as HTMLElement
687
+ if (target.closest('[data-tamagui-menu-content]') !== event.currentTarget)
688
+ return
689
+ if (
690
+ target.closest('[aria-haspopup="menu"]') &&
691
+ event.key === (group.dir === 'rtl' ? 'ArrowLeft' : 'ArrowRight')
692
+ )
693
+ return
694
+ const id = context.activeTriggerIdRef.current
695
+ if (id) {
696
+ group.move(id, event.key, true)
697
+ event.preventDefault()
698
+ }
699
+ })}
456
700
  onCloseAutoFocus={composeEventHandlers(props.onCloseAutoFocus, (event) => {
701
+ const group = context.activeGroupRef.current
702
+ if (group && !group.isActive(context.openRef)) {
703
+ event.cancel()
704
+ return
705
+ }
457
706
  if (!hasInteractedOutsideRef.current) {
458
707
  // delay to let React render new components and run their autoFocus effects
459
708
  requestAnimationFrame(() => {
@@ -468,6 +717,10 @@ export function createNonNativeMenu() {
468
717
  event.cancel()
469
718
  })}
470
719
  onInteractOutside={composeEventHandlers(props.onInteractOutside, (event) => {
720
+ if (context.activeGroupRef.current?.contains(event.event?.target ?? null)) {
721
+ event.cancel()
722
+ return
723
+ }
471
724
  if (event.interaction !== 'pointer' || !event.event) return
472
725
  const originalEvent = event.event
473
726
  const ctrlLeftClick =
@@ -603,6 +856,7 @@ export function createNonNativeMenu() {
603
856
  return withStaticProperties(MenuComp, {
604
857
  Root: MenuComp,
605
858
  Trigger: MenuTrigger,
859
+ TriggerGroup: MenuTriggerGroup,
606
860
  Portal: MenuPortal,
607
861
  Content: MenuContent,
608
862
  Group,
@@ -642,4 +896,5 @@ export type {
642
896
  MenuSubProps,
643
897
  MenuSubTriggerProps,
644
898
  MenuTriggerProps,
899
+ MenuTriggerGroupProps,
645
900
  }
package/src/index.tsx CHANGED
@@ -3,3 +3,5 @@ import '@tamagui/polyfill-dev'
3
3
  import { createMenu } from './Menu'
4
4
 
5
5
  export const Menu = createMenu()
6
+
7
+ export type { MenuTriggerGroupProps } from './createNonNativeMenu'