Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
85ccd4d
Add component
TylerJDev Oct 10, 2025
2b1871d
Clean up function
TylerJDev Oct 10, 2025
f8e21be
Add comment
TylerJDev Oct 10, 2025
dbcc75b
Update story
TylerJDev Oct 10, 2025
b2bff28
Fix lint issue
TylerJDev Oct 10, 2025
20dd785
Update docs, tests
TylerJDev Oct 13, 2025
29f7f97
Add changeset
TylerJDev Oct 13, 2025
8eed237
Merge branch 'main' into tylerjdev/actionbar-groups
pksjce Oct 15, 2025
7538ee4
Remove menu functionality from groups
TylerJDev Oct 21, 2025
87cb0c3
Remove `label` usage
TylerJDev Oct 21, 2025
cbd06b7
Remove `label` from stories
TylerJDev Oct 21, 2025
4bbe643
Remove `label` from tests
TylerJDev Oct 21, 2025
8035d0a
Remove comment
TylerJDev Oct 21, 2025
f1bf903
Address feedback
TylerJDev Oct 23, 2025
b0a816e
Add vrt test
TylerJDev Oct 23, 2025
77076a3
Format
TylerJDev Oct 23, 2025
a8d5a47
Add `ActionBar.Menu`
TylerJDev Oct 23, 2025
f9f9440
Adjust based on context usage
TylerJDev Oct 27, 2025
792313f
Merge branch 'main' into tylerjdev/actionbar-menus
TylerJDev Oct 27, 2025
8dfa896
Fix merge
TylerJDev Oct 27, 2025
0cbdf84
Update API
TylerJDev Nov 3, 2025
72efd12
Some clean up
TylerJDev Nov 3, 2025
d1b8851
Add nested menus
TylerJDev Nov 4, 2025
9fd0f1b
Add changeset
TylerJDev Nov 4, 2025
365edfa
Merge branch 'main' into tylerjdev/actionbar-menus
TylerJDev Nov 4, 2025
9a4e164
Add leading, trailing visuals
TylerJDev Nov 4, 2025
d2917b9
Merge branch 'main' into tylerjdev/actionbar-menus
pksjce Nov 5, 2025
f6ccc36
Add docs
TylerJDev Nov 5, 2025
7f50934
Address feedback
TylerJDev Nov 5, 2025
3286ca4
Update test snapshots
TylerJDev Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blue-maps-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@primer/react': minor
---

ActionBar: Add `ActionBar.Menu` subcomponent
29 changes: 29 additions & 0 deletions packages/react/src/ActionBar/ActionBar.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,35 @@
"defaultValue": ""
}
]
},
{
"name": "ActionBar.Menu",
"props": [
{
"name": "aria-label",
"type": "string",
"required": true,
"description": "Accessible label for the menu button."
},
{
"name": "icon",
"type": "Component",
"required": true,
"description": "Icon for the menu button."
},
{
"name": "items",
"type": "ActionBarMenuItemProps[]",
"required": true,
"description": "Array of menu items to render in the menu. Each item can be an action, group, or divider."
},
{
"name": "overflowIcon",
"type": "Component | 'none'",
"required": false,
"description": "Icon displayed when the menu item is within the overflow menu. If 'none' is provided, no icon will be shown in the overflow menu."
}
]
}
]
}
70 changes: 70 additions & 0 deletions packages/react/src/ActionBar/ActionBar.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,16 @@ import {
TasklistIcon,
ReplyIcon,
ThreeBarsIcon,
TrashIcon,
KebabHorizontalIcon,
NoteIcon,
} from '@primer/octicons-react'
import {Button, Avatar, ActionMenu, IconButton, ActionList, Textarea} from '..'
import {Dialog} from '../deprecated/DialogV1'
import {Divider} from '../deprecated/ActionList/Divider'
import mockData from '../experimental/SelectPanel2/mock-story-data'
import classes from './ActionBar.examples.stories.module.css'
import type {ActionBarMenuItemProps} from './ActionBar'

export default {
title: 'Experimental/Components/ActionBar/Examples',
Expand Down Expand Up @@ -312,3 +316,69 @@ export const MultipleActionBars = () => {
</div>
)
}

const ActionMenuExample = () => {
return (
<ActionBar.Menu
aria-label="File options"
icon={NoteIcon}
items={[
{label: 'Download', onClick: () => alert('Download clicked')},
{label: 'Jump to line', onClick: () => alert('Jump to line clicked')},
{label: 'Find in file', onClick: () => alert('Find in file clicked')},
{label: 'Copy path', onClick: () => alert('Copy path clicked')},
{label: 'Copy permalink', onClick: () => alert('Copy permalink clicked')},
{type: 'divider'},
{
label: 'Delete file',
onClick: () => alert('Delete file clicked'),
leadingVisual: TrashIcon,
variant: 'danger',
},
]}
/>
)
}

const menuHeadings: ActionBarMenuItemProps = {
label: 'Headings',
items: [
{label: 'Heading 1', onClick: () => alert('Heading 1 clicked'), trailingVisual: '⌘ 1'},
{label: 'Heading 2', onClick: () => alert('Heading 2 clicked'), trailingVisual: '⌘ 2'},
{label: 'Heading 3', onClick: () => alert('Heading 3 clicked'), trailingVisual: '⌘ 3'},
{label: 'Heading 4', onClick: () => alert('Heading 4 clicked'), trailingVisual: '⌘ 4'},
{label: 'Heading 5', onClick: () => alert('Heading 5 clicked'), trailingVisual: '⌘ 5'},
{label: 'Heading 6', onClick: () => alert('Heading 6 clicked'), trailingVisual: '⌘ 6'},
{type: 'divider'},
{label: 'Remove heading', onClick: () => alert('Remove heading clicked'), disabled: true},
],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice that the nesting works!

}

export const WithMenus = () => (
<ActionBar aria-label="Toolbar">
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic"></ActionBar.IconButton>
<ActionBar.IconButton icon={CodeIcon} aria-label="Code"></ActionBar.IconButton>
<ActionBar.IconButton icon={LinkIcon} aria-label="Link"></ActionBar.IconButton>
<ActionBar.Divider />
<ActionBar.IconButton icon={FileAddedIcon} aria-label="File Added"></ActionBar.IconButton>
<ActionBar.IconButton icon={SearchIcon} aria-label="Search"></ActionBar.IconButton>

<ActionBar.IconButton disabled icon={FileAddedIcon} aria-label="File Added"></ActionBar.IconButton>
<ActionBar.IconButton disabled icon={SearchIcon} aria-label="Search"></ActionBar.IconButton>
<ActionBar.IconButton disabled icon={QuoteIcon} aria-label="Insert Quote"></ActionBar.IconButton>
<ActionBar.IconButton icon={ListUnorderedIcon} aria-label="Unordered List"></ActionBar.IconButton>
<ActionBar.IconButton icon={ListOrderedIcon} aria-label="Ordered List"></ActionBar.IconButton>
<ActionMenuExample />
<ActionBar.IconButton icon={TasklistIcon} aria-label="Task List"></ActionBar.IconButton>
<ActionBar.Menu
aria-label="Formatting"
icon={KebabHorizontalIcon}
overflowIcon="none"
items={[
{label: 'Bold', onClick: () => alert('Bold clicked')},
{label: 'Underline', onClick: () => alert('Underline clicked')},
menuHeadings,
]}
/>
</ActionBar>
)
183 changes: 179 additions & 4 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -28,8 +28,14 @@ type ChildProps =
width: number
groupId?: string
}
| {type: 'divider'; width: number}
| {type: 'group'; width: number}
| {type: 'divider' | 'group'; width: number}
| {
type: 'menu'
width: number
label: string
icon: ActionBarIconButtonProps['icon'] | 'none'
items: ActionBarMenuProps['items']
}

/**
* Registry of descendants to render in the list or menu. To preserve insertion order across updates, children are
Expand Down Expand Up @@ -100,6 +106,57 @@ export type ActionBarProps = {

export type ActionBarIconButtonProps = {disabled?: boolean} & IconButtonProps

export type ActionBarMenuItemProps =
| ({
/**
* Type of menu item to be rendered in the menu (action | group).
* Defaults to 'action' if not specified.
*/
type?: 'action'
/**
* Whether the menu item is disabled.
* All interactions will be prevented if true.
*/
disabled?: boolean
/**
* Leading visual rendered for the menu item.
*/
leadingVisual?: ActionBarIconButtonProps['icon']
/**
* Trailing visual rendered for the menu item.
*/
trailingVisual?: ActionBarIconButtonProps['icon'] | string
/**
* Label for the menu item.
*/
label: string
/**
* Callback fired when the menu item is selected.
*/
onClick?: ActionListItemProps['onSelect']
/**
* Nested menu items to render within a submenu.
* If provided, the menu item will render a submenu.
*/
items?: ActionBarMenuItemProps[]
} & Pick<ActionListItemProps, 'variant'>)
| {
type: 'divider'
}

export type ActionBarMenuProps = {
/** Accessible label for the menu button */
'aria-label': string
/** Icon for the menu button */
icon: ActionBarIconButtonProps['icon']
items: ActionBarMenuItemProps[]
/**
* Icon displayed when the menu item is overflowing.
* If 'none' is provided, no icon will be shown in the overflow menu.
*/
overflowIcon?: ActionBarIconButtonProps['icon'] | 'none'
} & IconButtonProps

const MORE_BTN_WIDTH = 32

const calculatePossibleItems = (
Expand All @@ -123,6 +180,55 @@ const calculatePossibleItems = (
return breakpoint
}

const renderMenuItem = (item: ActionBarMenuItemProps, index: number): React.ReactNode => {
if (item.type === 'divider') {
return <ActionList.Divider key={index} />
}

const {label, onClick, disabled, trailingVisual: TrailingIcon, leadingVisual: LeadingIcon, items, variant} = item

if (items && items.length > 0) {
return (
<ActionMenu key={label}>
<ActionMenu.Anchor>
<ActionList.Item disabled={disabled} variant={variant}>
{LeadingIcon ? (
<ActionList.LeadingVisual>
<LeadingIcon />
</ActionList.LeadingVisual>
) : null}
{label}
{TrailingIcon ? (
<ActionList.TrailingVisual>
{typeof TrailingIcon === 'string' ? <span>{TrailingIcon}</span> : <TrailingIcon />}
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>{items.map((subItem, subIndex) => renderMenuItem(subItem, subIndex))}</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
)
}

return (
<ActionList.Item key={label} onSelect={onClick} disabled={disabled} variant={variant}>
{LeadingIcon ? (
<ActionList.LeadingVisual>
<LeadingIcon />
</ActionList.LeadingVisual>
) : null}
{label}
{TrailingIcon ? (
<ActionList.TrailingVisual>
{typeof TrailingIcon === 'string' ? <span>{TrailingIcon}</span> : <TrailingIcon />}
</ActionList.TrailingVisual>
) : null}
</ActionList.Item>
)
}

const getMenuItems = (
navWidth: number,
moreMenuWidth: number,
Expand Down Expand Up @@ -320,6 +426,29 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
)
}

if (menuItem.type === 'menu') {
const menuItems = menuItem.items
const {icon: Icon, label} = menuItem

return (
<ActionMenu key={id}>
<ActionMenu.Anchor>
<ActionList.Item>
{Icon !== 'none' ? (
<ActionList.LeadingVisual>
<Icon />
</ActionList.LeadingVisual>
) : null}
{label}
</ActionList.Item>
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>{menuItems.map((item, index) => renderMenuItem(item, index))}</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
)
}

// Use the memoized map instead of filtering each time
const groupedMenuItems = groupedItems.get(id) || []

Expand Down Expand Up @@ -430,7 +559,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<number>()

Expand All @@ -455,6 +584,52 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f
)
})

export const ActionBarMenu = forwardRef(
({'aria-label': ariaLabel, icon, overflowIcon, items, ...props}: ActionBarMenuProps, forwardedRef) => {
const backupRef = useRef<HTMLButtonElement>(null)
const ref = (forwardedRef ?? backupRef) as RefObject<HTMLButtonElement>
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
const widthRef = useRef<number>()

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: overflowIcon ? overflowIcon : icon,
items,
})

return () => {
unregisterChild(id)
}
}, [registerChild, unregisterChild, ariaLabel, overflowIcon, icon, items])

if (!isVisibleChild(id)) return null

return (
<ActionMenu anchorRef={ref} open={menuOpen} onOpenChange={setMenuOpen}>
<ActionMenu.Anchor>
<IconButton variant="invisible" aria-label={ariaLabel} icon={icon} {...props} />
</ActionMenu.Anchor>
<ActionMenu.Overlay>
<ActionList>{items.map((item, index) => renderMenuItem(item, index))}</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
)
},
)

export const VerticalDivider = () => {
const ref = useRef<HTMLDivElement>(null)
const id = useId()
Expand Down
5 changes: 3 additions & 2 deletions packages/react/src/ActionBar/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup} from './ActionBar'
export type {ActionBarProps} from './ActionBar'
import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup, ActionBarMenu} from './ActionBar'
export type {ActionBarProps, ActionBarMenuProps, ActionBarMenuItemProps} from './ActionBar'

const ActionBar = Object.assign(Bar, {
IconButton: ActionBarIconButton,
Divider: VerticalDivider,
Group: ActionBarGroup,
Menu: ActionBarMenu,
})

export default ActionBar
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ exports[`@primer/react/deprecated > should not update exports without a semver c
exports[`@primer/react/experimental > should not update exports without a semver change 1`] = `
[
"ActionBar",
"type ActionBarMenuItemProps",
"type ActionBarMenuProps",
"type ActionBarProps",
"Announce",
"type AnnounceProps",
Expand Down
Loading