11import type { RefObject , MouseEventHandler } from 'react'
22import React , { useState , useCallback , useRef , forwardRef , useId } from 'react'
33import { KebabHorizontalIcon } from '@primer/octicons-react'
4- import { ActionList } from '../ActionList'
4+ import { ActionList , type ActionListItemProps } from '../ActionList'
55import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
66import { useOnEscapePress } from '../hooks/useOnEscapePress'
77import type { ResizeObserverEntry } from '../hooks/useResizeObserver'
@@ -28,8 +28,14 @@ type ChildProps =
2828 width : number
2929 groupId ?: string
3030 }
31- | { type : 'divider' ; width : number }
32- | { type : 'group' ; width : number }
31+ | { type : 'divider' | 'group' ; width : number }
32+ | {
33+ type : 'menu'
34+ width : number
35+ label : string
36+ icon : ActionBarIconButtonProps [ 'icon' ] | 'none'
37+ items : ActionBarMenuProps [ 'items' ]
38+ }
3339
3440/**
3541 * Registry of descendants to render in the list or menu. To preserve insertion order across updates, children are
@@ -100,6 +106,57 @@ export type ActionBarProps = {
100106
101107export type ActionBarIconButtonProps = { disabled ?: boolean } & IconButtonProps
102108
109+ export type ActionBarMenuItemProps =
110+ | ( {
111+ /**
112+ * Type of menu item to be rendered in the menu (action | group).
113+ * Defaults to 'action' if not specified.
114+ */
115+ type ?: 'action'
116+ /**
117+ * Whether the menu item is disabled.
118+ * All interactions will be prevented if true.
119+ */
120+ disabled ?: boolean
121+ /**
122+ * Leading visual rendered for the menu item.
123+ */
124+ leadingVisual ?: ActionBarIconButtonProps [ 'icon' ]
125+ /**
126+ * Trailing visual rendered for the menu item.
127+ */
128+ trailingVisual ?: ActionBarIconButtonProps [ 'icon' ] | string
129+ /**
130+ * Label for the menu item.
131+ */
132+ label : string
133+ /**
134+ * Callback fired when the menu item is selected.
135+ */
136+ onClick ?: ActionListItemProps [ 'onSelect' ]
137+ /**
138+ * Nested menu items to render within a submenu.
139+ * If provided, the menu item will render a submenu.
140+ */
141+ items ?: ActionBarMenuItemProps [ ]
142+ } & Pick < ActionListItemProps , 'variant' > )
143+ | {
144+ type : 'divider'
145+ }
146+
147+ export type ActionBarMenuProps = {
148+ /** Accessible label for the menu button */
149+ 'aria-label' : string
150+ /** Icon for the menu button */
151+ icon : ActionBarIconButtonProps [ 'icon' ]
152+ items : ActionBarMenuItemProps [ ]
153+ /**
154+ * Icon displayed when the menu item is overflowing.
155+ * If 'none' is provided, no icon will be shown in the overflow menu.
156+ */
157+ overflowIcon ?: ActionBarIconButtonProps [ 'icon' ] | 'none'
158+ } & IconButtonProps
159+
103160const MORE_BTN_WIDTH = 32
104161
105162const calculatePossibleItems = (
@@ -123,6 +180,55 @@ const calculatePossibleItems = (
123180 return breakpoint
124181}
125182
183+ const renderMenuItem = ( item : ActionBarMenuItemProps , index : number ) : React . ReactNode => {
184+ if ( item . type === 'divider' ) {
185+ return < ActionList . Divider key = { index } />
186+ }
187+
188+ const { label, onClick, disabled, trailingVisual : TrailingIcon , leadingVisual : LeadingIcon , items, variant} = item
189+
190+ if ( items && items . length > 0 ) {
191+ return (
192+ < ActionMenu key = { label } >
193+ < ActionMenu . Anchor >
194+ < ActionList . Item disabled = { disabled } variant = { variant } >
195+ { LeadingIcon ? (
196+ < ActionList . LeadingVisual >
197+ < LeadingIcon />
198+ </ ActionList . LeadingVisual >
199+ ) : null }
200+ { label }
201+ { TrailingIcon ? (
202+ < ActionList . TrailingVisual >
203+ { typeof TrailingIcon === 'string' ? < span > { TrailingIcon } </ span > : < TrailingIcon /> }
204+ </ ActionList . TrailingVisual >
205+ ) : null }
206+ </ ActionList . Item >
207+ </ ActionMenu . Anchor >
208+ < ActionMenu . Overlay >
209+ < ActionList > { items . map ( ( subItem , subIndex ) => renderMenuItem ( subItem , subIndex ) ) } </ ActionList >
210+ </ ActionMenu . Overlay >
211+ </ ActionMenu >
212+ )
213+ }
214+
215+ return (
216+ < ActionList . Item key = { label } onSelect = { onClick } disabled = { disabled } variant = { variant } >
217+ { LeadingIcon ? (
218+ < ActionList . LeadingVisual >
219+ < LeadingIcon />
220+ </ ActionList . LeadingVisual >
221+ ) : null }
222+ { label }
223+ { TrailingIcon ? (
224+ < ActionList . TrailingVisual >
225+ { typeof TrailingIcon === 'string' ? < span > { TrailingIcon } </ span > : < TrailingIcon /> }
226+ </ ActionList . TrailingVisual >
227+ ) : null }
228+ </ ActionList . Item >
229+ )
230+ }
231+
126232const getMenuItems = (
127233 navWidth : number ,
128234 moreMenuWidth : number ,
@@ -320,6 +426,29 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
320426 )
321427 }
322428
429+ if ( menuItem . type === 'menu' ) {
430+ const menuItems = menuItem . items
431+ const { icon : Icon , label} = menuItem
432+
433+ return (
434+ < ActionMenu key = { id } >
435+ < ActionMenu . Anchor >
436+ < ActionList . Item >
437+ { Icon !== 'none' ? (
438+ < ActionList . LeadingVisual >
439+ < Icon />
440+ </ ActionList . LeadingVisual >
441+ ) : null }
442+ { label }
443+ </ ActionList . Item >
444+ </ ActionMenu . Anchor >
445+ < ActionMenu . Overlay >
446+ < ActionList > { menuItems . map ( ( item , index ) => renderMenuItem ( item , index ) ) } </ ActionList >
447+ </ ActionMenu . Overlay >
448+ </ ActionMenu >
449+ )
450+ }
451+
323452 // Use the memoized map instead of filtering each time
324453 const groupedMenuItems = groupedItems . get ( id ) || [ ]
325454
@@ -430,7 +559,7 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f
430559 const id = useId ( )
431560 const { registerChild, unregisterChild} = React . useContext ( ActionBarContext )
432561
433- // Like IconButton, we store the width in a ref ensures we don't forget about it when not visible
562+ // Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible
434563 // If a child has a groupId, it won't be visible if the group isn't visible, so we don't need to check isVisibleChild here
435564 const widthRef = useRef < number > ( )
436565
@@ -455,6 +584,52 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f
455584 )
456585} )
457586
587+ export const ActionBarMenu = forwardRef (
588+ ( { 'aria-label' : ariaLabel , icon, overflowIcon, items, ...props } : ActionBarMenuProps , forwardedRef ) => {
589+ const backupRef = useRef < HTMLButtonElement > ( null )
590+ const ref = ( forwardedRef ?? backupRef ) as RefObject < HTMLButtonElement >
591+ const id = useId ( )
592+ const { registerChild, unregisterChild, isVisibleChild} = React . useContext ( ActionBarContext )
593+
594+ const [ menuOpen , setMenuOpen ] = useState ( false )
595+
596+ // Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible
597+ const widthRef = useRef < number > ( )
598+
599+ useIsomorphicLayoutEffect ( ( ) => {
600+ const width = ref . current ?. getBoundingClientRect ( ) . width
601+ if ( width ) widthRef . current = width
602+
603+ if ( ! widthRef . current ) return
604+
605+ registerChild ( id , {
606+ type : 'menu' ,
607+ width : widthRef . current ,
608+ label : ariaLabel ,
609+ icon : overflowIcon ? overflowIcon : icon ,
610+ items,
611+ } )
612+
613+ return ( ) => {
614+ unregisterChild ( id )
615+ }
616+ } , [ registerChild , unregisterChild , ariaLabel , overflowIcon , icon , items ] )
617+
618+ if ( ! isVisibleChild ( id ) ) return null
619+
620+ return (
621+ < ActionMenu anchorRef = { ref } open = { menuOpen } onOpenChange = { setMenuOpen } >
622+ < ActionMenu . Anchor >
623+ < IconButton variant = "invisible" aria-label = { ariaLabel } icon = { icon } { ...props } />
624+ </ ActionMenu . Anchor >
625+ < ActionMenu . Overlay >
626+ < ActionList > { items . map ( ( item , index ) => renderMenuItem ( item , index ) ) } </ ActionList >
627+ </ ActionMenu . Overlay >
628+ </ ActionMenu >
629+ )
630+ } ,
631+ )
632+
458633export const VerticalDivider = ( ) => {
459634 const ref = useRef < HTMLDivElement > ( null )
460635 const id = useId ( )
0 commit comments