diff --git a/.changeset/actionbar-text-button.md b/.changeset/actionbar-text-button.md new file mode 100644 index 00000000000..05d7b1bf4a0 --- /dev/null +++ b/.changeset/actionbar-text-button.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +ActionBar: Add `ActionBar.Button` for rendering text buttons that overflow into the menu, alongside the existing `ActionBar.IconButton` diff --git a/packages/react/src/ActionBar/ActionBar.docs.json b/packages/react/src/ActionBar/ActionBar.docs.json index b38d7affc1a..b1e1bc89bd0 100644 --- a/packages/react/src/ActionBar/ActionBar.docs.json +++ b/packages/react/src/ActionBar/ActionBar.docs.json @@ -87,6 +87,34 @@ "url": "/react/IconButton" } }, + { + "name": "ActionBar.Button", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "defaultValue": "", + "required": true, + "description": "The text content of the button." + }, + { + "name": "leadingVisual", + "type": "Component", + "defaultValue": "", + "description": "Provide an octicon to render before the button text. It will also be shown when the button overflows into the menu." + }, + { + "name": "disabled", + "type": "boolean", + "defaultValue": "", + "description": "Provides a disabled state for the button. The button will remain focusable, and have `aria-disabled` applied." + } + ], + "passthrough": { + "element": "Button", + "url": "/react/Button" + } + }, { "name": "ActionBar.Divider", "props": [] diff --git a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx index 29cc17e1526..fc8198eb9bf 100644 --- a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx +++ b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx @@ -72,6 +72,17 @@ export const SmallActionBar = () => ( ) +export const WithTextButtons = () => ( + + Save + Add file + Search + + Cancel + Disabled + +) + export const GapScale = () => (
diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx index 98fd2d4b777..f5537efde2e 100644 --- a/packages/react/src/ActionBar/ActionBar.test.tsx +++ b/packages/react/src/ActionBar/ActionBar.test.tsx @@ -82,6 +82,72 @@ describe('ActionBar', () => { }) }) +describe('ActionBar.Button', () => { + afterEach(() => { + vi.clearAllMocks() + }) + + it('renders a text button with its children as the accessible name', () => { + render( + + Save + , + ) + + expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument() + }) + + it('should trigger non-disabled button', () => { + const onClick = vi.fn() + render( + + Save + , + ) + + screen.getByRole('button', {name: 'Save'}).click() + + expect(onClick).toHaveBeenCalled() + }) + + it('should not trigger disabled button', () => { + const onClick = vi.fn() + render( + + + Save + + , + ) + + screen.getByRole('button', {name: 'Save'}).click() + + expect(onClick).not.toHaveBeenCalled() + }) + + it('should not trigger disabled button with spacebar or enter', async () => { + const user = userEvent.setup() + const onClick = vi.fn() + render( + + + Save + + , + ) + + const button = screen.getByRole('button', {name: 'Save'}) + + act(() => { + button.focus() + }) + + await user.keyboard('{Enter}') + + expect(onClick).not.toHaveBeenCalled() + }) +}) + describe('ActionBar Registry System', () => { it('should preserve order with deep nesting', () => { render( @@ -442,6 +508,17 @@ describe('ActionBar data-component attributes', () => { expect(iconButton).toBeInTheDocument() }) + it('renders ActionBar.Button with a text label', () => { + render( + + Save + , + ) + + const button = screen.getByRole('button', {name: 'Save'}) + expect(button).toBeInTheDocument() + }) + it('renders ActionBar.VerticalDivider with data-component attribute', () => { const {container} = render( diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx index 8fd85484606..299ff7ffb6c 100644 --- a/packages/react/src/ActionBar/ActionBar.tsx +++ b/packages/react/src/ActionBar/ActionBar.tsx @@ -3,8 +3,8 @@ import React, {useState, useCallback, useRef, forwardRef, useMemo, useSyncExtern import {KebabHorizontalIcon} from '@primer/octicons-react' import {ActionList, type ActionListItemProps} from '../ActionList' -import type {IconButtonProps} from '../Button' -import {IconButton} from '../Button' +import type {ButtonProps, IconButtonProps} from '../Button' +import {Button, IconButton} from '../Button' import {ActionMenu} from '../ActionMenu' import {useFocusZone, FocusKeys} from '../hooks/useFocusZone' import styles from './ActionBar.module.css' @@ -15,9 +15,9 @@ import {createDescendantRegistry} from '../utils/descendant-registry' type ChildProps = | { type: 'action' - label: string + label: React.ReactNode disabled: boolean - icon: ActionBarIconButtonProps['icon'] + icon?: ActionBarIconButtonProps['icon'] onClick: MouseEventHandler } | {type: 'divider' | 'group'} @@ -84,6 +84,8 @@ export type ActionBarProps = { export type ActionBarIconButtonProps = {disabled?: boolean} & IconButtonProps +export type ActionBarButtonProps = {disabled?: boolean} & ButtonProps + export type ActionBarMenuItemProps = | ({ /** @@ -260,15 +262,17 @@ export const ActionBar: React.FC> = ({ const {onClick, icon: Icon, label, disabled} = menuItem return ( { typeof onClick === 'function' && onClick(event as React.MouseEvent) }} disabled={disabled} > - - - + {Icon ? ( + + + + ) : null} {label} ) @@ -390,6 +394,51 @@ export const ActionBarIconButton = forwardRef( }, ) +export const ActionBarButton = forwardRef(({disabled, onClick, ...props}: ActionBarButtonProps, forwardedRef) => { + const ref = useRef(null) + const mergedRef = useMergedRefs(forwardedRef, ref) + + const {size} = React.useContext(ActionBarContext) + + const {children, leadingVisual} = props + + const {dataOverflowingAttr} = useActionBarItem( + ref, + useMemo( + (): ChildProps => ({ + type: 'action', + label: children, + // Only forward the leading visual to the overflow menu when it is a component + // that can be rendered as an icon (e.g. an octicon), matching ActionBar.IconButton. + icon: typeof leadingVisual === 'function' ? (leadingVisual as ActionBarIconButtonProps['icon']) : undefined, + disabled: !!disabled, + onClick: onClick as MouseEventHandler, + }), + [children, leadingVisual, disabled, onClick], + ), + ) + + const clickHandler = useCallback( + (event: React.MouseEvent) => { + if (disabled) return + onClick?.(event) + }, + [disabled, onClick], + ) + + return ( +