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