Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
9 changes: 9 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 Expand Up @@ -93,3 +100,5 @@ export const DeepChildTree = () => (
<AdvancedFormattingButtons />
</ActionBar>
)

// GapExamples story moved to examples (Next.js example page) to reduce Storybook surface area.
Copy link
Member

Choose a reason for hiding this comment

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

is this accurate?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ugh no!

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