Skip to content
Merged
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/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": "'none' | 'condensed'",
"required": false,
"defaultValue": "'condensed'",
"description": "Horizontal gap scale between items (restricted to none or condensed)."
}
],
"subcomponents": [
Expand Down
25 changes: 25 additions & 0 deletions packages/react/src/ActionBar/ActionBar.examples.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,31 @@ export const SmallActionBar = () => (
</ActionBar>
)

export const GapScale = () => (
Copy link
Member

Choose a reason for hiding this comment

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

should we add this to VRT?

<div style={{display: 'flex', flexDirection: 'column', gap: 16}}>
<div>
<Text as="p" style={{marginBottom: 4}}>
gap=&quot;none&quot;
</Text>
<ActionBar aria-label="Toolbar gap none" gap="none">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
<ActionBar.IconButton icon={CodeIcon} aria-label="Code" />
</ActionBar>
</div>
<div>
<Text as="p" style={{marginBottom: 4}}>
gap=&quot;condensed&quot; (default)
</Text>
<ActionBar aria-label="Toolbar gap condensed" gap="condensed">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
<ActionBar.IconButton icon={CodeIcon} aria-label="Code" />
</ActionBar>
</div>
</div>
)

export const WithDisabledItems = () => (
<ActionBar aria-label="Toolbar">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold"></ActionBar.IconButton>
Expand Down
11 changes: 10 additions & 1 deletion packages/react/src/ActionBar/ActionBar.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,16 @@
white-space: nowrap;
list-style: none;
align-items: center;
gap: var(--base-size-8);
gap: var(--actionbar-gap, var(--stack-gap-condensed));

/* Gap scale (mirrors Stack) */
&:where([data-gap='none']) {
--actionbar-gap: 0;
}

&:where([data-gap='condensed']) {
--actionbar-gap: var(--stack-gap-condensed);
}
}

.Nav {
Expand Down
7 changes: 7 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,17 @@ Playground.argTypes = {
type: 'boolean',
},
},
gap: {
control: {type: 'radio'},
options: ['none', 'condensed'],
description: 'Horizontal gap scale between items',
table: {defaultValue: {summary: 'condensed'}},
},
}
Playground.args = {
size: 'medium',
flush: false,
gap: 'condensed',
}

export const Default = () => (
Expand Down
35 changes: 35 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,38 @@ describe('ActionBar Registry System', () => {
expect(screen.queryByRole('button', {name: 'Will unmount'})).not.toBeInTheDocument()
})
})

describe('ActionBar gap prop', () => {
it('defaults to condensed', () => {
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')
expect(toolbar).toHaveAttribute('data-gap', 'condensed')
})

it('applies provided gap scale (none)', () => {
render(
<ActionBar aria-label="Toolbar" gap="none">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
</ActionBar>,
)
const toolbar = screen.getByRole('toolbar')
expect(toolbar).toHaveAttribute('data-gap', 'none')
})

it('applies provided gap scale (condensed)', () => {
render(
<ActionBar aria-label="Toolbar" gap="condensed">
<ActionBar.IconButton icon={BoldIcon} aria-label="Bold" />
<ActionBar.IconButton icon={ItalicIcon} aria-label="Italic" />
</ActionBar>,
)
const toolbar = screen.getByRole('toolbar')
expect(toolbar).toHaveAttribute('data-gap', 'condensed')
})
})
38 changes: 32 additions & 6 deletions packages/react/src/ActionBar/ActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ type A11yProps =
'aria-labelledby': React.AriaAttributes['aria-labelledby']
}

type GapScale = 'none' | 'condensed'

export type ActionBarProps = {
/**
* Size of the action bar
Expand All @@ -79,18 +81,29 @@ export type ActionBarProps = {

/** Custom className */
className?: string

/**
* Horizontal gap scale between items (mirrors Stack gap scale)
* @default 'condensed'
*/
gap?: GapScale
} & 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 +119,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 @@ -158,8 +173,13 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
'aria-labelledby': ariaLabelledBy,
flush = false,
className,
gap = 'condensed',
} = props

// We derive the numeric gap from computed style so layout math stays in sync with CSS
const listRef = useRef<HTMLDivElement>(null)
const [computedGap, setComputedGap] = useState<number>(ACTIONBAR_ITEM_GAP)

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

const registerChild = useCallback(
Expand All @@ -171,7 +191,13 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
const [menuItemIds, setMenuItemIds] = useState<Set<string>>(() => new Set())

const navRef = useRef<HTMLDivElement>(null)
const listRef = useRef<HTMLDivElement>(null)
// measure gap after first render & whenever gap scale changes
useIsomorphicLayoutEffect(() => {
if (!listRef.current) return
const g = window.getComputedStyle(listRef.current).gap
const parsed = parseFloat(g)
if (!Number.isNaN(parsed)) setComputedGap(parsed)
}, [gap])
const moreMenuRef = useRef<HTMLLIElement>(null)
const moreMenuBtnRef = useRef<HTMLButtonElement>(null)
const containerRef = React.useRef<HTMLUListElement>(null)
Expand All @@ -182,7 +208,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, computedGap)
if (newMenuItemIds) setMenuItemIds(newMenuItemIds)
}
}, navRef as RefObject<HTMLElement>)
Expand Down Expand Up @@ -230,9 +256,9 @@ export const ActionBar: React.FC<React.PropsWithChildren<ActionBarProps>> = prop
ref={listRef}
role="toolbar"
className={styles.List}
style={{gap: `${ACTIONBAR_ITEM_GAP}px`}}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
data-gap={gap}
>
{children}
{menuItemIds.size > 0 && (
Expand Down
Loading