diff --git a/.changeset/healthy-laws-walk.md b/.changeset/healthy-laws-walk.md
new file mode 100644
index 00000000000..2c1b09b5a2f
--- /dev/null
+++ b/.changeset/healthy-laws-walk.md
@@ -0,0 +1,5 @@
+---
+"@primer/react": patch
+---
+
+Add gap prop to ActionBar for customizable spacing between items
diff --git a/packages/react/src/ActionBar/ActionBar.docs.json b/packages/react/src/ActionBar/ActionBar.docs.json
index 79a9c68f9f6..ca7e53e200b 100644
--- a/packages/react/src/ActionBar/ActionBar.docs.json
+++ b/packages/react/src/ActionBar/ActionBar.docs.json
@@ -34,7 +34,8 @@
},
{
"name": "flush",
- "derive": true
+ "derive": true,
+ "type": "boolean"
},
{
"name": "className",
@@ -42,6 +43,13 @@
"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": [
diff --git a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx
index d7c18e79123..2dab897a5ae 100644
--- a/packages/react/src/ActionBar/ActionBar.examples.stories.tsx
+++ b/packages/react/src/ActionBar/ActionBar.examples.stories.tsx
@@ -48,6 +48,31 @@ export const SmallActionBar = () => (
)
+export const GapScale = () => (
+
+
+
+ gap="none"
+
+
+
+
+
+
+
+
+
+ gap="condensed" (default)
+
+
+
+
+
+
+
+
+)
+
export const WithDisabledItems = () => (
diff --git a/packages/react/src/ActionBar/ActionBar.module.css b/packages/react/src/ActionBar/ActionBar.module.css
index d795c7b57a8..305b067d3fe 100644
--- a/packages/react/src/ActionBar/ActionBar.module.css
+++ b/packages/react/src/ActionBar/ActionBar.module.css
@@ -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 {
diff --git a/packages/react/src/ActionBar/ActionBar.stories.tsx b/packages/react/src/ActionBar/ActionBar.stories.tsx
index 53a7d6bc956..06cb18045dd 100644
--- a/packages/react/src/ActionBar/ActionBar.stories.tsx
+++ b/packages/react/src/ActionBar/ActionBar.stories.tsx
@@ -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 = () => (
diff --git a/packages/react/src/ActionBar/ActionBar.test.tsx b/packages/react/src/ActionBar/ActionBar.test.tsx
index 21139a9cb8e..c2ba761acda 100644
--- a/packages/react/src/ActionBar/ActionBar.test.tsx
+++ b/packages/react/src/ActionBar/ActionBar.test.tsx
@@ -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(
+
+
+
+ ,
+ )
+ const toolbar = screen.getByRole('toolbar')
+ expect(toolbar).toHaveAttribute('data-gap', 'condensed')
+ })
+
+ it('applies provided gap scale (none)', () => {
+ render(
+
+
+
+ ,
+ )
+ const toolbar = screen.getByRole('toolbar')
+ expect(toolbar).toHaveAttribute('data-gap', 'none')
+ })
+
+ it('applies provided gap scale (condensed)', () => {
+ render(
+
+
+
+ ,
+ )
+ const toolbar = screen.getByRole('toolbar')
+ expect(toolbar).toHaveAttribute('data-gap', 'condensed')
+ })
+})
diff --git a/packages/react/src/ActionBar/ActionBar.tsx b/packages/react/src/ActionBar/ActionBar.tsx
index 6dcc34d81b7..1585a1b56c6 100644
--- a/packages/react/src/ActionBar/ActionBar.tsx
+++ b/packages/react/src/ActionBar/ActionBar.tsx
@@ -61,6 +61,8 @@ type A11yProps =
'aria-labelledby': React.AriaAttributes['aria-labelledby']
}
+type GapScale = 'none' | 'condensed'
+
export type ActionBarProps = {
/**
* Size of the action bar
@@ -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
@@ -106,15 +119,17 @@ const getMenuItems = (
moreMenuWidth: number,
childRegistry: ChildRegistry,
hasActiveMenu: boolean,
+ gap: number,
): Set | 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()
@@ -158,8 +173,13 @@ export const ActionBar: React.FC> = 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(null)
+ const [computedGap, setComputedGap] = useState(ACTIONBAR_ITEM_GAP)
+
const [childRegistry, setChildRegistry] = useState(() => new Map())
const registerChild = useCallback(
@@ -171,7 +191,13 @@ export const ActionBar: React.FC> = prop
const [menuItemIds, setMenuItemIds] = useState>(() => new Set())
const navRef = useRef(null)
- const listRef = useRef(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(null)
const moreMenuBtnRef = useRef(null)
const containerRef = React.useRef(null)
@@ -182,7 +208,7 @@ export const ActionBar: React.FC> = 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)
@@ -230,9 +256,9 @@ export const ActionBar: React.FC> = 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 && (