Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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/healthy-laws-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@primer/react": patch
---

Add gap prop to ActionBar for customizable spacing between items
10 changes: 9 additions & 1 deletion packages/react/src/ActionBar/ActionBar.docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,22 @@
},
{
"name": "flush",
"derive": true
"derive": true,
"type": "boolean"
},
{
"name": "className",
"type": "string",
"required": false,
"description": "Custom className",
"defaultValue": ""
},
{
"name": "gap",
"type": "number",
"required": false,
"defaultValue": "8",
"description": "Horizontal gap in pixels between items. Clamped between 0 and 8 (default 8)."
}
],
"subcomponents": [
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/ActionBar/ActionBar.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,15 @@ Playground.argTypes = {
type: 'boolean',
},
},
gap: {
control: {type: 'number', min: 0, max: 8, step: 1},
description: 'Horizontal gap in pixels between items (0–8).',
},
}
Playground.args = {
size: 'medium',
flush: false,
gap: 8,
}

export const Default = () => (
Expand Down Expand Up @@ -93,3 +98,23 @@ export const DeepChildTree = () => (
<AdvancedFormattingButtons />
</ActionBar>
)

export const GapExamples = () => (
<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
<ActionBar aria-label="Toolbar gap 0" gap={0}>
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
<ActionBar.IconButton icon={CodeIcon} aria-label="Code" />
</ActionBar>
<ActionBar aria-label="Toolbar gap 4" gap={4}>
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
<ActionBar.IconButton icon={CodeIcon} aria-label="Code" />
</ActionBar>
<ActionBar aria-label="Toolbar gap 8" gap={8}>
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
<ActionBar.IconButton icon={CodeIcon} aria-label="Code" />
</ActionBar>
</div>
)
47 changes: 47 additions & 0 deletions packages/react/src/ActionBar/ActionBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -234,3 +234,50 @@ describe('ActionBar Registry System', () => {
expect(screen.queryByRole('button', {name: 'Will unmount'})).not.toBeInTheDocument()
})
})

describe('ActionBar gap prop', () => {
it('uses default gap (8px) when gap prop not provided', () => {
render(
<ActionBar aria-label="Toolbar">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
</ActionBar>,
)
const toolbar = screen.getByRole('toolbar') as HTMLElement
expect(toolbar.style.gap).toBe('8px')
})

it('applies provided gap value', () => {
render(
<ActionBar aria-label="Toolbar" gap={4}>
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
<ActionBar.IconButton icon={CodeIcon} aria-label="Code" />
</ActionBar>,
)
const toolbar = screen.getByRole('toolbar') as HTMLElement
expect(toolbar.style.gap).toBe('4px')
})

it('clamps gap > 8 down to 8', () => {
render(
<ActionBar aria-label="Toolbar" gap={99}>
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
</ActionBar>,
)
const toolbar = screen.getByRole('toolbar') as HTMLElement
expect(toolbar.style.gap).toBe('8px')
})

it('clamps negative gap to 0', () => {
render(
<ActionBar aria-label="Toolbar" gap={-3}>
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
</ActionBar>,
)
const toolbar = screen.getByRole('toolbar') as HTMLElement
expect(toolbar.style.gap).toBe('0px')
})
})
27 changes: 21 additions & 6 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,29 @@ export type ActionBarProps = {

/** Custom className */
className?: string

/**
* Horizontal gap in pixels between items. Capped at 8 to align with design spacing.
* @default 8
*/
gap?: number
} & A11yProps

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

const MORE_BTN_WIDTH = 32

const calculatePossibleItems = (registryEntries: Array<[string, ChildProps]>, navWidth: number, moreMenuWidth = 0) => {
const calculatePossibleItems = (
registryEntries: Array<[string, ChildProps]>,
navWidth: number,
gap: number,
moreMenuWidth = 0,
) => {
const widthToFit = navWidth - moreMenuWidth
let breakpoint = registryEntries.length // assume all items will fit
let sumsOfChildWidth = 0
for (const [index, [, child]] of registryEntries.entries()) {
sumsOfChildWidth += index > 0 ? child.width + ACTIONBAR_ITEM_GAP : child.width
sumsOfChildWidth += index > 0 ? child.width + gap : child.width
if (sumsOfChildWidth > widthToFit) {
breakpoint = index
break
Expand All @@ -106,15 +117,17 @@ const getMenuItems = (
moreMenuWidth: number,
childRegistry: ChildRegistry,
hasActiveMenu: boolean,
gap: number,
): Set<string> | void => {
const registryEntries = Array.from(childRegistry).filter((entry): entry is [string, ChildProps] => entry[1] !== null)

if (registryEntries.length === 0) return new Set()
const numberOfItemsPossible = calculatePossibleItems(registryEntries, navWidth)
const numberOfItemsPossible = calculatePossibleItems(registryEntries, navWidth, gap)

const numberOfItemsPossibleWithMoreMenu = calculatePossibleItems(
registryEntries,
navWidth,
gap,
moreMenuWidth || MORE_BTN_WIDTH,
)
const menuItems = new Set<string>()
Expand Down Expand Up @@ -151,7 +164,9 @@ const getMenuItems = (
}

export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = props => {
const {size = 'medium', children, 'aria-label': ariaLabel, flush = false, className} = props
const {size = 'medium', children, 'aria-label': ariaLabel, flush = false, className, gap} = props

const itemGap = Math.min(Math.max(gap ?? ACTIONBAR_ITEM_GAP, 0), ACTIONBAR_ITEM_GAP)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This keeps it between 0 and 8


const [childRegistry, setChildRegistry] = useState<ChildRegistry>(() => new Map())

Expand All @@ -175,7 +190,7 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
const hasActiveMenu = menuItemIds.size > 0

if (navWidth > 0) {
const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu)
const newMenuItemIds = getMenuItems(navWidth, moreMenuWidth, childRegistry, hasActiveMenu, itemGap)
if (newMenuItemIds) setMenuItemIds(newMenuItemIds)
}
}, navRef as RefObject<HTMLElement>)
Expand Down Expand Up @@ -219,7 +234,7 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
return (
<ActionBarContext.Provider value={{size, registerChild, unregisterChild, isVisibleChild}}>
<div ref={navRef} className={clsx(className, styles.Nav)} data-flush={flush}>
<div ref={listRef} role="toolbar" className={styles.List} style={{gap: `${ACTIONBAR_ITEM_GAP}px`}}>
<div ref={listRef} role="toolbar" className={styles.List} style={{gap: `${itemGap}px`}}>
{children}
{menuItemIds.size > 0 && (
<ActionMenu>
Expand Down
Loading