Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/actionbar-text-button.md
Original file line number Diff line number Diff line change
@@ -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`
28 changes: 28 additions & 0 deletions packages/react/src/ActionBar/ActionBar.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": []
Expand Down
11 changes: 11 additions & 0 deletions packages/react/src/ActionBar/ActionBar.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,17 @@ export const SmallActionBar = () => (
</ActionBar>
)

export const WithTextButtons = () => (
<ActionBar aria-label="Toolbar">
<ActionBar.Button>Save</ActionBar.Button>
<ActionBar.Button leadingVisual={FileAddedIcon}>Add file</ActionBar.Button>
<ActionBar.Button leadingVisual={SearchIcon}>Search</ActionBar.Button>
<ActionBar.Divider />
<ActionBar.Button>Cancel</ActionBar.Button>
<ActionBar.Button disabled>Disabled</ActionBar.Button>
</ActionBar>
)

export const GapScale = () => (
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
<div>
Expand Down
77 changes: 77 additions & 0 deletions packages/react/src/ActionBar/ActionBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ActionBar aria-label="Toolbar">
<ActionBar.Button>Save</ActionBar.Button>
</ActionBar>,
)

expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument()
})

it('should trigger non-disabled button', () => {
const onClick = vi.fn()
render(
<ActionBar aria-label="Toolbar">
<ActionBar.Button onClick={onClick}>Save</ActionBar.Button>
</ActionBar>,
)

screen.getByRole('button', {name: 'Save'}).click()

expect(onClick).toHaveBeenCalled()
})

it('should not trigger disabled button', () => {
const onClick = vi.fn()
render(
<ActionBar aria-label="Toolbar">
<ActionBar.Button onClick={onClick} disabled>
Save
</ActionBar.Button>
</ActionBar>,
)

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(
<ActionBar aria-label="Toolbar">
<ActionBar.Button onClick={onClick} disabled>
Save
</ActionBar.Button>
</ActionBar>,
)

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(
Expand Down Expand Up @@ -442,6 +508,17 @@ describe('ActionBar data-component attributes', () => {
expect(iconButton).toBeInTheDocument()
})

it('renders ActionBar.Button with a text label', () => {
render(
<ActionBar aria-label="Toolbar">
<ActionBar.Button>Save</ActionBar.Button>
</ActionBar>,
)

const button = screen.getByRole('button', {name: 'Save'})
expect(button).toBeInTheDocument()
})

it('renders ActionBar.VerticalDivider with data-component attribute', () => {
const {container} = render(
<ActionBar aria-label="Toolbar">
Expand Down
65 changes: 57 additions & 8 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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'}
Expand Down Expand Up @@ -84,6 +84,8 @@ export type ActionBarProps = {

export type ActionBarIconButtonProps = {disabled?: boolean} & IconButtonProps

export type ActionBarButtonProps = {disabled?: boolean} & ButtonProps

export type ActionBarMenuItemProps =
| ({
/**
Expand Down Expand Up @@ -260,15 +262,17 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = ({
const {onClick, icon: Icon, label, disabled} = menuItem
return (
<ActionList.Item
key={label}
key={id}
onSelect={event => {
typeof onClick === 'function' && onClick(event as React.MouseEvent<HTMLElement>)
}}
disabled={disabled}
>
<ActionList.LeadingVisual>
<Icon />
</ActionList.LeadingVisual>
{Icon ? (
<ActionList.LeadingVisual>
<Icon />
</ActionList.LeadingVisual>
) : null}
{label}
</ActionList.Item>
)
Expand Down Expand Up @@ -390,6 +394,51 @@ export const ActionBarIconButton = forwardRef(
},
)

export const ActionBarButton = forwardRef(({disabled, onClick, ...props}: ActionBarButtonProps, forwardedRef) => {
const ref = useRef<HTMLButtonElement>(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<HTMLButtonElement>) => {
if (disabled) return
onClick?.(event)
},
[disabled, onClick],
)

return (
<Button
aria-disabled={disabled}
ref={mergedRef}
size={size}
onClick={clickHandler}
{...props}
variant="invisible"
data-overflowing={dataOverflowingAttr}
/>
)
})

const ActionBarGroupContext = React.createContext<{
isOverflowing: boolean
} | null>(null)
Expand Down
12 changes: 10 additions & 2 deletions packages/react/src/ActionBar/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import {ActionBar as Bar, ActionBarIconButton, VerticalDivider, ActionBarGroup, ActionBarMenu} from './ActionBar'
export type {ActionBarProps, ActionBarMenuProps, ActionBarMenuItemProps} from './ActionBar'
import {
ActionBar as Bar,
ActionBarIconButton,
ActionBarButton,
VerticalDivider,
ActionBarGroup,
ActionBarMenu,
} from './ActionBar'
export type {ActionBarProps, ActionBarButtonProps, ActionBarMenuProps, ActionBarMenuItemProps} from './ActionBar'

const ActionBar = Object.assign(Bar, {
IconButton: ActionBarIconButton,
Button: ActionBarButton,
Divider: VerticalDivider,
Group: ActionBarGroup,
Menu: ActionBarMenu,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,7 @@ 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 ActionBarButtonProps",
"type ActionBarMenuItemProps",
"type ActionBarMenuProps",
"type ActionBarProps",
Expand Down
Loading