diff --git a/.changeset/blue-maps-reply.md b/.changeset/blue-maps-reply.md new file mode 100644 index 00000000000..867d67650c6 --- /dev/null +++ b/.changeset/blue-maps-reply.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +ActionBar: Add `ActionBar.Menu` subcomponent diff --git a/packages/react/src/ActionBar/ActionBar.docs.json b/packages/react/src/ActionBar/ActionBar.docs.json index 28496ced5a9..193a73ba7b3 100644 --- a/packages/react/src/ActionBar/ActionBar.docs.json +++ b/packages/react/src/ActionBar/ActionBar.docs.json @@ -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." + } + ] } ] } diff --git a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx index 0f0a3e36abe..6ef573c6341 100644 --- a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx +++ b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx @@ -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', @@ -312,3 +316,69 @@ export const MultipleActionBars = () => { ) } + +const ActionMenuExample = () => { + return ( + 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}, + ], +} + +export const WithMenus = () => ( + + + + + + + + + + + + + + + + alert('Bold clicked')}, + {label: 'Underline', onClick: () => alert('Underline clicked')}, + menuHeadings, + ]} + /> + +) diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 7046dc44984..61353f2c351 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,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 @@ -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) + | { + 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 = ( @@ -123,6 +180,55 @@ const calculatePossibleItems = ( return breakpoint } +const renderMenuItem = (item: ActionBarMenuItemProps, index: number): React.ReactNode => { + if (item.type === 'divider') { + return + } + + const {label, onClick, disabled, trailingVisual: TrailingIcon, leadingVisual: LeadingIcon, items, variant} = item + + if (items && items.length > 0) { + return ( + + + + {LeadingIcon ? ( + + + + ) : null} + {label} + {TrailingIcon ? ( + + {typeof TrailingIcon === 'string' ? {TrailingIcon} : } + + ) : null} + + + + {items.map((subItem, subIndex) => renderMenuItem(subItem, subIndex))} + + + ) + } + + return ( + + {LeadingIcon ? ( + + + + ) : null} + {label} + {TrailingIcon ? ( + + {typeof TrailingIcon === 'string' ? {TrailingIcon} : } + + ) : null} + + ) +} + const getMenuItems = ( navWidth: number, moreMenuWidth: number, @@ -320,6 +426,29 @@ export const ActionBar: React.FC> = prop ) } + if (menuItem.type === 'menu') { + const menuItems = menuItem.items + const {icon: Icon, label} = menuItem + + return ( + + + + {Icon !== 'none' ? ( + + + + ) : null} + {label} + + + + {menuItems.map((item, index) => renderMenuItem(item, index))} + + + ) + } + // Use the memoized map instead of filtering each time const groupedMenuItems = groupedItems.get(id) || [] @@ -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() @@ -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(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 + 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: overflowIcon ? overflowIcon : icon, + items, + }) + + return () => { + unregisterChild(id) + } + }, [registerChild, unregisterChild, ariaLabel, overflowIcon, icon, items]) + + if (!isVisibleChild(id)) return null + + return ( + + + + + + {items.map((item, index) => renderMenuItem(item, index))} + + + ) + }, +) + 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..9a7e78b503b 100644 --- a/packages/react/src/ActionBar/index.ts +++ b/packages/react/src/ActionBar/index.ts @@ -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 diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index db884036c2a..d141a1f9e64 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -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",