diff --git a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx
index 0f0a3e36abe..0b62a1993fe 100644
--- a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx
+++ b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx
@@ -17,6 +17,8 @@ import {
TasklistIcon,
ReplyIcon,
ThreeBarsIcon,
+ TrashIcon,
+ KebabHorizontalIcon,
} from '@primer/octicons-react'
import {Button, Avatar, ActionMenu, IconButton, ActionList, Textarea} from '..'
import {Dialog} from '../deprecated/DialogV1'
@@ -312,3 +314,41 @@ export const MultipleActionBars = () => {
)
}
+
+const ActionMenuExample = () => {
+ return (
+
+ alert('Workflows clicked')} label="Download" />
+
+ alert('Workflows clicked')} label="Jump to line" />
+ alert('Workflows clicked')} label="Find in file" />
+
+ alert('Workflows clicked')} label="Copy path" />
+ alert('Workflows clicked')} label="Copy permalink" />
+
+ alert('Delete file')} variant="danger" label="Delete file" icon={TrashIcon} />
+
+ )
+}
+
+export const WithMenus = () => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/packages/react/src/ActionBar/ActionBar.module.css b/packages/react/src/ActionBar/ActionBar.module.css
index f4a9592ad48..9b134d89ec9 100644
--- a/packages/react/src/ActionBar/ActionBar.module.css
+++ b/packages/react/src/ActionBar/ActionBar.module.css
@@ -43,6 +43,19 @@
}
}
+.Menu {
+ .Divider {
+ &::before {
+ border: 0;
+ height: var(--borderWidth-thin, .0625rem);
+ margin-block-end: var(--base-size-8, .5rem);
+ margin-block-start: calc(var(--base-size-8, .5rem) - var(--borderWidth-thin, .0625rem));
+ padding: 0;
+ width: unset;
+ }
+ }
+}
+
.Group {
display: flex;
gap: inherit;
diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx
index 7046dc44984..5c69656cecc 100644
--- a/packages/react/src/ActionBar/ActionBar.tsx
+++ b/packages/react/src/ActionBar/ActionBar.tsx
@@ -1,7 +1,7 @@
import type {RefObject, MouseEventHandler} from 'react'
import React, {useState, useCallback, useRef, forwardRef, useId} from 'react'
import {KebabHorizontalIcon} from '@primer/octicons-react'
-import {ActionList} from '../ActionList'
+import {ActionList, type ActionListItemProps} from '../ActionList'
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
import {useOnEscapePress} from '../hooks/useOnEscapePress'
import type {ResizeObserverEntry} from '../hooks/useResizeObserver'
@@ -28,8 +28,16 @@ type ChildProps =
width: number
groupId?: string
}
- | {type: 'divider'; width: number}
- | {type: 'group'; width: number}
+ | {
+ type: 'menuItem'
+ label: string
+ disabled: boolean
+ icon?: ActionBarIconButtonProps['icon']
+ onClick: MouseEventHandler
+ menuId?: string
+ }
+ | {type: 'divider' | 'group'; width: number}
+ | {type: 'menu'; width: number; label: string; icon: ActionBarIconButtonProps['icon']}
/**
* Registry of descendants to render in the list or menu. To preserve insertion order across updates, children are
@@ -263,15 +271,18 @@ export const ActionBar: React.FC> = prop
const groupedItems = React.useMemo(() => {
const groupedItemsMap = new Map>()
+ const menuItems = new Map()
for (const [key, childProps] of childRegistry) {
if (childProps?.type === 'action' && childProps.groupId) {
const existingGroup = groupedItemsMap.get(childProps.groupId) || []
existingGroup.push([key, childProps])
groupedItemsMap.set(childProps.groupId, existingGroup)
+ } else if (childProps?.type === 'menuItem') {
+ menuItems.set(key, childProps)
}
}
- return groupedItemsMap
+ return {groupedItems: groupedItemsMap, menuItems}
}, [childRegistry])
return (
@@ -321,7 +332,32 @@ export const ActionBar: React.FC> = prop
}
// Use the memoized map instead of filtering each time
- const groupedMenuItems = groupedItems.get(id) || []
+ const groupedMenuItems = groupedItems.groupedItems.get(id) || []
+
+ if (menuItem.type === 'menu') {
+ const menuItems = Array.from(groupedItems.menuItems)
+ const {icon: Icon, label} = menuItem
+
+ return (
+
+
+
+
+
+
+ {label}
+
+
+
+
+ {menuItems.map(([key, childProps]) => (
+ {childProps.label}
+ ))}
+
+
+
+ )
+ }
// If we ever add additional types, this condition will be necessary
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
@@ -372,6 +408,7 @@ export const ActionBarIconButton = forwardRef(
const {size, registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext)
const {groupId} = React.useContext(ActionBarGroupContext)
+ const {menuId} = React.useContext(ActionBarMenuContext)
// Storing the width in a ref ensures we don't forget about it when not visible
const widthRef = useRef()
@@ -382,7 +419,7 @@ export const ActionBarIconButton = forwardRef(
if (!widthRef.current) return
registerChild(id, {
- type: 'action',
+ type: menuId ? 'menuItem' : 'action',
label: props['aria-label'] ?? '',
icon: props.icon,
disabled: !!disabled,
@@ -430,7 +467,7 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f
const id = useId()
const {registerChild, unregisterChild} = React.useContext(ActionBarContext)
- // Like IconButton, we store the width in a ref ensures we don't forget about it when not visible
+ // Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible
// 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
const widthRef = useRef()
@@ -455,6 +492,121 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f
)
})
+type ActionBarMenuProps = {
+ /** Accessible label for the menu button */
+ 'aria-label': string
+ /** Icon for the menu button */
+ icon: ActionBarIconButtonProps['icon']
+}
+
+const ActionBarMenuContext = React.createContext<{
+ menuId: string
+ menuVisible: boolean
+ label: string
+}>({menuId: '', menuVisible: false, label: ''})
+
+export const ActionBarMenu = forwardRef(
+ ({'aria-label': ariaLabel, icon, children}: React.PropsWithChildren, forwardedRef) => {
+ const backupRef = useRef(null)
+ const ref = (forwardedRef ?? backupRef) as RefObject
+ const id = useId()
+ const {registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext)
+
+ const [menuOpen, setMenuOpen] = useState(false)
+
+ // Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible
+ // 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
+ const widthRef = useRef()
+
+ useIsomorphicLayoutEffect(() => {
+ const width = ref.current?.getBoundingClientRect().width
+ if (width) widthRef.current = width
+
+ if (!widthRef.current) return
+
+ registerChild(id, {type: 'menu', width: widthRef.current, label: ariaLabel, icon})
+
+ return () => {
+ unregisterChild(id)
+ }
+ }, [registerChild, unregisterChild])
+
+ if (!isVisibleChild(id))
+ return (
+
+ {children}
+
+ )
+
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+ )
+ },
+)
+
+type ActionBarMenuItemProps = {
+ disabled?: boolean
+ icon?: ActionBarIconButtonProps['icon']
+ label: string
+} & ActionListItemProps
+
+export const ActionBarMenuItem = forwardRef(
+ (
+ {disabled, children, icon: Icon, label, ...props}: React.PropsWithChildren,
+ forwardedRef,
+ ) => {
+ const backupRef = useRef(null)
+ const ref = (forwardedRef ?? backupRef) as RefObject
+ useRefObjectAsForwardedRef(forwardedRef, ref)
+ const id = useId()
+
+ const {menuVisible} = React.useContext(ActionBarMenuContext)
+ const {registerChild, unregisterChild} = React.useContext(ActionBarContext)
+
+ // TODO: We need to support an assortment of ActionList.Item props like variant, etc.
+ // We do not want to reinvent the wheel, so it should be simplistic to pass those props through
+
+ useIsomorphicLayoutEffect(() => {
+ if (menuVisible) return
+
+ registerChild(id, {
+ type: 'menuItem',
+ label,
+ icon: Icon,
+ disabled: !!disabled,
+ onClick: props.onClick as MouseEventHandler,
+ })
+
+ return () => {
+ unregisterChild(id)
+ }
+ }, [registerChild, unregisterChild])
+
+ if (!menuVisible) {
+ // We return null here as there is no need to render anything when the menu is not visible
+ // We instead register the item in the ActionBar context for the ActionBar to render it appropriately in the overflow menu
+ return null
+ }
+
+ return (
+
+ {Icon ? : null}
+ {label}
+ {children}
+
+ )
+ },
+)
+
export const VerticalDivider = () => {
const ref = useRef(null)
const id = useId()
diff --git a/packages/react/src/ActionBar/index.ts b/packages/react/src/ActionBar/index.ts
index 68db283a292..3fa1b8c671f 100644
--- a/packages/react/src/ActionBar/index.ts
+++ b/packages/react/src/ActionBar/index.ts
@@ -1,10 +1,19 @@
-import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup} from './ActionBar'
+import {
+ ActionBar as Bar,
+ ActionBarIconButton,
+ VerticalDivider,
+ ActionBarGroup,
+ ActionBarMenu,
+ ActionBarMenuItem,
+} from './ActionBar'
export type {ActionBarProps} from './ActionBar'
const ActionBar = Object.assign(Bar, {
IconButton: ActionBarIconButton,
Divider: VerticalDivider,
Group: ActionBarGroup,
+ Menu: ActionBarMenu,
+ MenuItem: ActionBarMenuItem,
})
export default ActionBar