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 && (