Skip to content

Commit 0cbdf84

Browse files
committed
Update API
1 parent 8dfa896 commit 0cbdf84

File tree

3 files changed

+125
-139
lines changed

3 files changed

+125
-139
lines changed

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

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react'
1+
import React, {type RefObject} from 'react'
22
import type {Meta} from '@storybook/react-vite'
33
import ActionBar from '.'
44
import Text from '../Text'
@@ -19,6 +19,8 @@ import {
1919
ThreeBarsIcon,
2020
TrashIcon,
2121
KebabHorizontalIcon,
22+
PeopleIcon,
23+
GearIcon,
2224
} from '@primer/octicons-react'
2325
import {Button, Avatar, ActionMenu, IconButton, ActionList, Textarea} from '..'
2426
import {Dialog} from '../deprecated/DialogV1'
@@ -315,19 +317,56 @@ export const MultipleActionBars = () => {
315317
)
316318
}
317319

320+
const MultiSelect = React.forwardRef((props, ref) => {
321+
type Option = {name: string; selected: boolean}
322+
323+
const [options, setOptions] = React.useState<Option[]>([
324+
{name: 'Show code folding buttons', selected: true},
325+
{name: 'Wrap lines', selected: false},
326+
{name: 'Center content', selected: false},
327+
])
328+
329+
const toggle = (name: string) => {
330+
setOptions(
331+
options.map(option => {
332+
if (option.name === name) option.selected = !option.selected
333+
return option
334+
}),
335+
)
336+
}
337+
338+
return (
339+
<ActionMenu anchorRef={ref as RefObject<HTMLButtonElement>}>
340+
<ActionMenu.Anchor>
341+
<IconButton variant="invisible" aria-label="Formatting" icon={GearIcon} />
342+
</ActionMenu.Anchor>
343+
<ActionMenu.Overlay width="auto">
344+
<ActionList selectionVariant="multiple">
345+
{options.map(options => (
346+
<ActionList.Item key={options.name} selected={options.selected} onSelect={() => toggle(options.name)}>
347+
{options.name}
348+
</ActionList.Item>
349+
))}
350+
</ActionList>
351+
</ActionMenu.Overlay>
352+
</ActionMenu>
353+
)
354+
})
355+
318356
const ActionMenuExample = () => {
319357
return (
320-
<ActionBar.Menu aria-label="Open menu" icon={KebabHorizontalIcon}>
321-
<ActionBar.MenuItem onClick={() => alert('Workflows clicked')} label="Download" />
322-
<ActionBar.Divider />
323-
<ActionBar.MenuItem onClick={() => alert('Workflows clicked')} label="Jump to line" />
324-
<ActionBar.MenuItem onClick={() => alert('Workflows clicked')} label="Find in file" />
325-
<ActionBar.Divider />
326-
<ActionBar.MenuItem onClick={() => alert('Workflows clicked')} label="Copy path" />
327-
<ActionBar.MenuItem onClick={() => alert('Workflows clicked')} label="Copy permalink" />
328-
<ActionBar.Divider />
329-
<ActionBar.MenuItem onClick={() => alert('Delete file')} variant="danger" label="Delete file" icon={TrashIcon} />
330-
</ActionBar.Menu>
358+
<ActionBar.Menu
359+
aria-label="Open menu"
360+
icon={KebabHorizontalIcon}
361+
items={[
362+
{label: 'Download', onClick: () => alert('Download clicked')},
363+
{label: 'Jump to line', onClick: () => alert('Jump to line clicked')},
364+
{label: 'Find in file', onClick: () => alert('Find in file clicked')},
365+
{label: 'Copy path', onClick: () => alert('Copy path clicked')},
366+
{label: 'Copy permalink', onClick: () => alert('Copy permalink clicked')},
367+
{label: 'Delete file', onClick: () => alert('Delete file clicked'), icon: TrashIcon, variant: 'danger'},
368+
]}
369+
/>
331370
)
332371
}
333372

@@ -339,12 +378,17 @@ export const WithMenus = () => (
339378
<ActionBar.Divider />
340379
<ActionBar.IconButton icon={FileAddedIcon} aria-label="File Added"></ActionBar.IconButton>
341380
<ActionBar.IconButton icon={SearchIcon} aria-label="Search"></ActionBar.IconButton>
342-
<ActionBar.Menu aria-label="More Actions" icon={ThreeBarsIcon}>
343-
<ActionBar.MenuItem label="Edit" icon={PencilIcon} />
344-
<ActionBar.MenuItem label="Delete" icon={TrashIcon} variant="danger" />
345-
</ActionBar.Menu>
381+
<ActionBar.Menu
382+
aria-label="More Actions"
383+
icon={ThreeBarsIcon}
384+
items={[
385+
{label: 'Bold', onClick: () => alert('Bold clicked')},
386+
{label: 'Underline', onClick: () => alert('Underline clicked')},
387+
]}
388+
/>
346389
<ActionBar.IconButton disabled icon={FileAddedIcon} aria-label="File Added"></ActionBar.IconButton>
347390
<ActionBar.IconButton disabled icon={SearchIcon} aria-label="Search"></ActionBar.IconButton>
391+
<ActionBar.Menu aria-label="Test Menu" icon={PeopleIcon} renderMenu={MultiSelect} />
348392
<ActionBar.IconButton disabled icon={QuoteIcon} aria-label="Insert Quote"></ActionBar.IconButton>
349393
<ActionBar.IconButton icon={ListUnorderedIcon} aria-label="Unordered List"></ActionBar.IconButton>
350394
<ActionBar.IconButton icon={ListOrderedIcon} aria-label="Ordered List"></ActionBar.IconButton>

packages/react/src/ActionBar/ActionBar.tsx

Lines changed: 64 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,14 @@ type ChildProps =
2828
width: number
2929
groupId?: string
3030
}
31+
| {type: 'divider' | 'group'; width: number}
3132
| {
32-
type: 'menuItem'
33+
type: 'menu'
34+
width: number
3335
label: string
34-
disabled: boolean
35-
icon?: ActionBarIconButtonProps['icon']
36-
onClick: MouseEventHandler
37-
menuId?: string
36+
icon: ActionBarIconButtonProps['icon']
37+
items: ActionBarMenuProps['items']
3838
}
39-
| {type: 'divider' | 'group'; width: number}
40-
| {type: 'menu'; width: number; label: string; icon: ActionBarIconButtonProps['icon']}
4139

4240
/**
4341
* Registry of descendants to render in the list or menu. To preserve insertion order across updates, children are
@@ -271,18 +269,15 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
271269

272270
const groupedItems = React.useMemo(() => {
273271
const groupedItemsMap = new Map<string, Array<[string, ChildProps]>>()
274-
const menuItems = new Map<string, ChildProps>()
275272

276273
for (const [key, childProps] of childRegistry) {
277274
if (childProps?.type === 'action' && childProps.groupId) {
278275
const existingGroup = groupedItemsMap.get(childProps.groupId) || []
279276
existingGroup.push([key, childProps])
280277
groupedItemsMap.set(childProps.groupId, existingGroup)
281-
} else if (childProps?.type === 'menuItem') {
282-
menuItems.set(key, childProps)
283278
}
284279
}
285-
return {groupedItems: groupedItemsMap, menuItems}
280+
return groupedItemsMap
286281
}, [childRegistry])
287282

288283
return (
@@ -331,11 +326,8 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
331326
)
332327
}
333328

334-
// Use the memoized map instead of filtering each time
335-
const groupedMenuItems = groupedItems.groupedItems.get(id) || []
336-
337-
if (menuItem.type === 'menu') {
338-
const menuItems = Array.from(groupedItems.menuItems)
329+
if (menuItem.type === 'menu' && menuItem.items) {
330+
const menuItems = menuItem.items
339331
const {icon: Icon, label} = menuItem
340332

341333
return (
@@ -350,15 +342,20 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
350342
</ActionMenu.Anchor>
351343
<ActionMenu.Overlay>
352344
<ActionList>
353-
{menuItems.map(([key, childProps]) => (
354-
<ActionList.Item key={key}>{childProps.label}</ActionList.Item>
345+
{menuItems.map(({label, onClick, disabled, variant}) => (
346+
<ActionList.Item key={label} onSelect={onClick} disabled={disabled} variant={variant}>
347+
{label}
348+
</ActionList.Item>
355349
))}
356350
</ActionList>
357351
</ActionMenu.Overlay>
358352
</ActionMenu>
359353
)
360354
}
361355

356+
// Use the memoized map instead of filtering each time
357+
const groupedMenuItems = groupedItems.get(id) || []
358+
362359
// If we ever add additional types, this condition will be necessary
363360
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
364361
if (menuItem.type === 'group') {
@@ -408,7 +405,6 @@ export const ActionBarIconButton = forwardRef(
408405

409406
const {size, registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext)
410407
const {groupId} = React.useContext(ActionBarGroupContext)
411-
const {menuId} = React.useContext(ActionBarMenuContext)
412408

413409
// Storing the width in a ref ensures we don't forget about it when not visible
414410
const widthRef = useRef<number>()
@@ -419,7 +415,7 @@ export const ActionBarIconButton = forwardRef(
419415
if (!widthRef.current) return
420416

421417
registerChild(id, {
422-
type: menuId ? 'menuItem' : 'action',
418+
type: 'action',
423419
label: props['aria-label'] ?? '',
424420
icon: props.icon,
425421
disabled: !!disabled,
@@ -492,11 +488,19 @@ export const ActionBarGroup = forwardRef(({children}: React.PropsWithChildren, f
492488
)
493489
})
494490

491+
type ActionBarMenuItem = {
492+
disabled?: boolean
493+
icon?: ActionBarIconButtonProps['icon']
494+
label: string
495+
onClick?: ActionListItemProps['onSelect']
496+
} & Pick<ActionListItemProps, 'variant'>
497+
495498
type ActionBarMenuProps = {
496499
/** Accessible label for the menu button */
497-
'aria-label': string
500+
'aria-label': string // TODO: Change to label
498501
/** Icon for the menu button */
499502
icon: ActionBarIconButtonProps['icon']
503+
items?: ActionBarMenuItem[]
500504
}
501505

502506
const ActionBarMenuContext = React.createContext<{
@@ -505,107 +509,53 @@ const ActionBarMenuContext = React.createContext<{
505509
label: string
506510
}>({menuId: '', menuVisible: false, label: ''})
507511

508-
export const ActionBarMenu = forwardRef(
509-
({'aria-label': ariaLabel, icon, children}: React.PropsWithChildren<ActionBarMenuProps>, forwardedRef) => {
510-
const backupRef = useRef<HTMLButtonElement>(null)
511-
const ref = (forwardedRef ?? backupRef) as RefObject<HTMLButtonElement>
512-
const id = useId()
513-
const {registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext)
514-
515-
const [menuOpen, setMenuOpen] = useState(false)
516-
517-
// Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible
518-
// 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
519-
const widthRef = useRef<number>()
520-
521-
useIsomorphicLayoutEffect(() => {
522-
const width = ref.current?.getBoundingClientRect().width
523-
if (width) widthRef.current = width
524-
525-
if (!widthRef.current) return
526-
527-
registerChild(id, {type: 'menu', width: widthRef.current, label: ariaLabel, icon})
528-
529-
return () => {
530-
unregisterChild(id)
531-
}
532-
}, [registerChild, unregisterChild])
533-
534-
if (!isVisibleChild(id))
535-
return (
536-
<ActionBarMenuContext.Provider value={{menuId: id, menuVisible: isVisibleChild(id), label: ariaLabel}}>
537-
{children}
538-
</ActionBarMenuContext.Provider>
539-
)
540-
541-
return (
542-
<ActionBarMenuContext.Provider value={{menuId: id, menuVisible: isVisibleChild(id), label: ariaLabel}}>
543-
<ActionMenu anchorRef={ref} open={menuOpen} onOpenChange={setMenuOpen}>
544-
<ActionMenu.Anchor>
545-
<IconButton variant="invisible" aria-label={ariaLabel} icon={icon} />
546-
</ActionMenu.Anchor>
547-
<ActionMenu.Overlay>
548-
<ActionList className={styles.Menu}>{children}</ActionList>
549-
</ActionMenu.Overlay>
550-
</ActionMenu>
551-
</ActionBarMenuContext.Provider>
552-
)
553-
},
554-
)
555-
556-
type ActionBarMenuItemProps = {
557-
disabled?: boolean
558-
icon?: ActionBarIconButtonProps['icon']
559-
label: string
560-
} & ActionListItemProps
561-
562-
export const ActionBarMenuItem = forwardRef(
563-
(
564-
{disabled, children, icon: Icon, label, ...props}: React.PropsWithChildren<ActionBarMenuItemProps>,
565-
forwardedRef,
566-
) => {
567-
const backupRef = useRef<HTMLLIElement>(null)
568-
const ref = (forwardedRef ?? backupRef) as RefObject<HTMLLIElement>
569-
useRefObjectAsForwardedRef(forwardedRef, ref)
570-
const id = useId()
512+
export const ActionBarMenu = forwardRef(({'aria-label': ariaLabel, icon, items}: ActionBarMenuProps, forwardedRef) => {
513+
const backupRef = useRef<HTMLButtonElement>(null)
514+
const ref = (forwardedRef ?? backupRef) as RefObject<HTMLButtonElement>
515+
const id = useId()
516+
const {registerChild, unregisterChild, isVisibleChild} = React.useContext(ActionBarContext)
571517

572-
const {menuVisible} = React.useContext(ActionBarMenuContext)
573-
const {registerChild, unregisterChild} = React.useContext(ActionBarContext)
518+
const [menuOpen, setMenuOpen] = useState(false)
574519

575-
// TODO: We need to support an assortment of ActionList.Item props like variant, etc.
576-
// We do not want to reinvent the wheel, so it should be simplistic to pass those props through
520+
// Like IconButton, we store the width in a ref to ensure that we don't forget about it when not visible
521+
// 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
522+
const widthRef = useRef<number>()
577523

578-
useIsomorphicLayoutEffect(() => {
579-
if (menuVisible) return
524+
useIsomorphicLayoutEffect(() => {
525+
const width = ref.current?.getBoundingClientRect().width
526+
if (width) widthRef.current = width
580527

581-
registerChild(id, {
582-
type: 'menuItem',
583-
label,
584-
icon: Icon,
585-
disabled: !!disabled,
586-
onClick: props.onClick as MouseEventHandler,
587-
})
528+
if (!widthRef.current) return
588529

589-
return () => {
590-
unregisterChild(id)
591-
}
592-
}, [registerChild, unregisterChild])
530+
registerChild(id, {type: 'menu', width: widthRef.current, label: ariaLabel, icon, items})
593531

594-
if (!menuVisible) {
595-
// We return null here as there is no need to render anything when the menu is not visible
596-
// We instead register the item in the ActionBar context for the ActionBar to render it appropriately in the overflow menu
597-
return null
532+
return () => {
533+
unregisterChild(id)
598534
}
535+
}, [registerChild, unregisterChild])
599536

600-
return (
601-
<ActionList.Item aria-disabled={disabled} ref={ref} data-testid={id}>
602-
<ActionList.LeadingVisual>{Icon ? <Icon /> : null}</ActionList.LeadingVisual>
603-
{label}
604-
{children}
605-
</ActionList.Item>
606-
)
607-
},
608-
)
537+
if (!isVisibleChild(id)) return null
538+
539+
return (
540+
<ActionBarMenuContext.Provider value={{menuId: id, menuVisible: isVisibleChild(id), label: ariaLabel}}>
541+
<ActionMenu anchorRef={ref} open={menuOpen} onOpenChange={setMenuOpen}>
542+
<ActionMenu.Anchor>
543+
<IconButton variant="invisible" aria-label={ariaLabel} icon={icon} />
544+
</ActionMenu.Anchor>
545+
<ActionMenu.Overlay>
546+
<ActionList className={styles.Menu}>
547+
{items?.map(({label, onClick, disabled, icon: MenuIcon, variant}) => (
548+
<ActionList.Item key={label} onSelect={onClick} disabled={disabled} variant={variant}>
549+
{MenuIcon && <ActionList.LeadingVisual>{<MenuIcon />}</ActionList.LeadingVisual>}
550+
{label}
551+
</ActionList.Item>
552+
))}
553+
</ActionList>
554+
</ActionMenu.Overlay>
555+
</ActionMenu>
556+
</ActionBarMenuContext.Provider>
557+
)
558+
})
609559

610560
export const VerticalDivider = () => {
611561
const ref = useRef<HTMLDivElement>(null)

packages/react/src/ActionBar/index.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,11 @@
1-
import {
2-
ActionBar as Bar,
3-
ActionBarIconButton,
4-
VerticalDivider,
5-
ActionBarGroup,
6-
ActionBarMenu,
7-
ActionBarMenuItem,
8-
} from './ActionBar'
1+
import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup, ActionBarMenu} from './ActionBar'
92
export type {ActionBarProps} from './ActionBar'
103

114
const ActionBar = Object.assign(Bar, {
125
IconButton: ActionBarIconButton,
136
Divider: VerticalDivider,
147
Group: ActionBarGroup,
158
Menu: ActionBarMenu,
16-
MenuItem: ActionBarMenuItem,
179
})
1810

1911
export default ActionBar

0 commit comments

Comments
 (0)