Skip to content

Commit 9090053

Browse files
TylerJDevpksjce
andauthored
ActionBar: Add ActionBar.Menu component (#7065)
Co-authored-by: Pavithra Kodmad <[email protected]>
1 parent 7160709 commit 9090053

File tree

6 files changed

+288
-6
lines changed

6 files changed

+288
-6
lines changed

.changeset/blue-maps-reply.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@primer/react': minor
3+
---
4+
5+
ActionBar: Add `ActionBar.Menu` subcomponent

packages/react/src/ActionBar/ActionBar.docs.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,35 @@
100100
"defaultValue": ""
101101
}
102102
]
103+
},
104+
{
105+
"name": "ActionBar.Menu",
106+
"props": [
107+
{
108+
"name": "aria-label",
109+
"type": "string",
110+
"required": true,
111+
"description": "Accessible label for the menu button."
112+
},
113+
{
114+
"name": "icon",
115+
"type": "Component",
116+
"required": true,
117+
"description": "Icon for the menu button."
118+
},
119+
{
120+
"name": "items",
121+
"type": "ActionBarMenuItemProps[]",
122+
"required": true,
123+
"description": "Array of menu items to render in the menu. Each item can be an action, group, or divider."
124+
},
125+
{
126+
"name": "overflowIcon",
127+
"type": "Component | 'none'",
128+
"required": false,
129+
"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."
130+
}
131+
]
103132
}
104133
]
105134
}

packages/react/src/ActionBar/ActionBar.examples.stories.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,16 @@ import {
1717
TasklistIcon,
1818
ReplyIcon,
1919
ThreeBarsIcon,
20+
TrashIcon,
21+
KebabHorizontalIcon,
22+
NoteIcon,
2023
} from '@primer/octicons-react'
2124
import {Button, Avatar, ActionMenu, IconButton, ActionList, Textarea} from '..'
2225
import {Dialog} from '../deprecated/DialogV1'
2326
import {Divider} from '../deprecated/ActionList/Divider'
2427
import mockData from '../experimental/SelectPanel2/mock-story-data'
2528
import classes from './ActionBar.examples.stories.module.css'
29+
import type {ActionBarMenuItemProps} from './ActionBar'
2630

2731
export default {
2832
title: 'Experimental/Components/ActionBar/Examples',
@@ -312,3 +316,69 @@ export const MultipleActionBars = () => {
312316
</div>
313317
)
314318
}
319+
320+
const ActionMenuExample = () => {
321+
return (
322+
<ActionBar.Menu
323+
aria-label="File options"
324+
icon={NoteIcon}
325+
items={[
326+
{label: 'Download', onClick: () => alert('Download clicked')},
327+
{label: 'Jump to line', onClick: () => alert('Jump to line clicked')},
328+
{label: 'Find in file', onClick: () => alert('Find in file clicked')},
329+
{label: 'Copy path', onClick: () => alert('Copy path clicked')},
330+
{label: 'Copy permalink', onClick: () => alert('Copy permalink clicked')},
331+
{type: 'divider'},
332+
{
333+
label: 'Delete file',
334+
onClick: () => alert('Delete file clicked'),
335+
leadingVisual: TrashIcon,
336+
variant: 'danger',
337+
},
338+
]}
339+
/>
340+
)
341+
}
342+
343+
const menuHeadings: ActionBarMenuItemProps = {
344+
label: 'Headings',
345+
items: [
346+
{label: 'Heading 1', onClick: () => alert('Heading 1 clicked'), trailingVisual: '⌘ 1'},
347+
{label: 'Heading 2', onClick: () => alert('Heading 2 clicked'), trailingVisual: '⌘ 2'},
348+
{label: 'Heading 3', onClick: () => alert('Heading 3 clicked'), trailingVisual: '⌘ 3'},
349+
{label: 'Heading 4', onClick: () => alert('Heading 4 clicked'), trailingVisual: '⌘ 4'},
350+
{label: 'Heading 5', onClick: () => alert('Heading 5 clicked'), trailingVisual: '⌘ 5'},
351+
{label: 'Heading 6', onClick: () => alert('Heading 6 clicked'), trailingVisual: '⌘ 6'},
352+
{type: 'divider'},
353+
{label: 'Remove heading', onClick: () => alert('Remove heading clicked'), disabled: true},
354+
],
355+
}
356+
357+
export const WithMenus = () => (
358+
<ActionBar aria-label="Toolbar">
359+
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic"></ActionBar.IconButton>
360+
<ActionBar.IconButton icon={CodeIcon} aria-label="Code"></ActionBar.IconButton>
361+
<ActionBar.IconButton icon={LinkIcon} aria-label="Link"></ActionBar.IconButton>
362+
<ActionBar.Divider />
363+
<ActionBar.IconButton icon={FileAddedIcon} aria-label="File Added"></ActionBar.IconButton>
364+
<ActionBar.IconButton icon={SearchIcon} aria-label="Search"></ActionBar.IconButton>
365+
366+
<ActionBar.IconButton disabled icon={FileAddedIcon} aria-label="File Added"></ActionBar.IconButton>
367+
<ActionBar.IconButton disabled icon={SearchIcon} aria-label="Search"></ActionBar.IconButton>
368+
<ActionBar.IconButton disabled icon={QuoteIcon} aria-label="Insert Quote"></ActionBar.IconButton>
369+
<ActionBar.IconButton icon={ListUnorderedIcon} aria-label="Unordered List"></ActionBar.IconButton>
370+
<ActionBar.IconButton icon={ListOrderedIcon} aria-label="Ordered List"></ActionBar.IconButton>
371+
<ActionMenuExample />
372+
<ActionBar.IconButton icon={TasklistIcon} aria-label="Task List"></ActionBar.IconButton>
373+
<ActionBar.Menu
374+
aria-label="Formatting"
375+
icon={KebabHorizontalIcon}
376+
overflowIcon="none"
377+
items={[
378+
{label: 'Bold', onClick: () => alert('Bold clicked')},
379+
{label: 'Underline', onClick: () => alert('Underline clicked')},
380+
menuHeadings,
381+
]}
382+
/>
383+
</ActionBar>
384+
)

packages/react/src/ActionBar/ActionBar.tsx

Lines changed: 179 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type {RefObject, MouseEventHandler} from 'react'
22
import React, {useState, useCallback, useRef, forwardRef, useId} from 'react'
33
import {KebabHorizontalIcon} from '@primer/octicons-react'
4-
import {ActionList} from '../ActionList'
4+
import {ActionList, type ActionListItemProps} from '../ActionList'
55
import useIsomorphicLayoutEffect from '../utils/useIsomorphicLayoutEffect'
66
import {useOnEscapePress} from '../hooks/useOnEscapePress'
77
import 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

101107
export 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+
103160
const MORE_BTN_WIDTH = 32
104161

105162
const 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+
126232
const 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+
458633
export const VerticalDivider = () => {
459634
const ref = useRef<HTMLDivElement>(null)
460635
const id = useId()

packages/react/src/ActionBar/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup} from './ActionBar'
2-
export type {ActionBarProps} from './ActionBar'
1+
import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup, ActionBarMenu} from './ActionBar'
2+
export type {ActionBarProps, ActionBarMenuProps, ActionBarMenuItemProps} from './ActionBar'
33

44
const ActionBar = Object.assign(Bar, {
55
IconButton: ActionBarIconButton,
66
Divider: VerticalDivider,
77
Group: ActionBarGroup,
8+
Menu: ActionBarMenu,
89
})
910

1011
export default ActionBar

packages/react/src/__tests__/__snapshots__/exports.test.ts.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,8 @@ exports[`@primer/react/deprecated > should not update exports without a semver c
267267
exports[`@primer/react/experimental > should not update exports without a semver change 1`] = `
268268
[
269269
"ActionBar",
270+
"type ActionBarMenuItemProps",
271+
"type ActionBarMenuProps",
270272
"type ActionBarProps",
271273
"Announce",
272274
"type AnnounceProps",

0 commit comments

Comments
 (0)