From 1f8a13350a7e1cc2af7e8ca78e7f087945669804 Mon Sep 17 00:00:00 2001 From: Hector Garcia Date: Wed, 12 Nov 2025 14:26:35 +0000 Subject: [PATCH] WIP --- .../src/ButtonGroup/ButtonGroup.docs.json | 6 + .../src/ButtonGroup/ButtonGroup.module.css | 24 +- .../src/ButtonGroup/ButtonGroup.stories.tsx | 11 + .../src/ButtonGroup/ButtonGroup.test.tsx | 34 +- .../react/src/ButtonGroup/ButtonGroup.tsx | 304 +++++++++++++++++- 5 files changed, 366 insertions(+), 13 deletions(-) diff --git a/packages/react/src/ButtonGroup/ButtonGroup.docs.json b/packages/react/src/ButtonGroup/ButtonGroup.docs.json index e43db4799db..010583689c3 100644 --- a/packages/react/src/ButtonGroup/ButtonGroup.docs.json +++ b/packages/react/src/ButtonGroup/ButtonGroup.docs.json @@ -25,6 +25,12 @@ { "name": "ref", "type": "React.RefObject" + }, + { + "name": "overflowAriaLabel", + "type": "string", + "defaultValue": "\"More actions\"", + "description": "Accessible label announced for the overflow trigger button." } ], "subcomponents": [] diff --git a/packages/react/src/ButtonGroup/ButtonGroup.module.css b/packages/react/src/ButtonGroup/ButtonGroup.module.css index 7c289b69544..70b0f745ab3 100644 --- a/packages/react/src/ButtonGroup/ButtonGroup.module.css +++ b/packages/react/src/ButtonGroup/ButtonGroup.module.css @@ -3,7 +3,12 @@ vertical-align: middle; isolation: isolate; - & > *:not([data-loading-wrapper]) { + &[data-phase='measuring'] { + visibility: hidden; + pointer-events: none; + } + + & > [data-button-group-slot]:not([data-loading-wrapper]) { /* stylelint-disable-next-line primer/spacing */ margin-inline-end: -1px; position: relative; @@ -46,7 +51,7 @@ } /* if child is loading button */ - & > *[data-loading-wrapper] { + & > [data-button-group-slot][data-loading-wrapper] { /* stylelint-disable-next-line primer/spacing */ margin-inline-end: -1px; position: relative; @@ -80,3 +85,18 @@ } } } + +.MeasurementItem { + display: inline-flex; +} + +.OverflowOverlay { + display: flex; + flex-direction: column; + gap: var(--base-size-8); + padding: var(--base-size-8); +} + +.OverflowItem { + display: flex; +} diff --git a/packages/react/src/ButtonGroup/ButtonGroup.stories.tsx b/packages/react/src/ButtonGroup/ButtonGroup.stories.tsx index 095ac33d9cc..f5c893b20a3 100644 --- a/packages/react/src/ButtonGroup/ButtonGroup.stories.tsx +++ b/packages/react/src/ButtonGroup/ButtonGroup.stories.tsx @@ -23,6 +23,17 @@ export const Default = () => ( ) +export const Overflow = () => ( +
+ + + + + + +
+) + export const Playground: StoryFn = args => { const {buttonCount = 3, ...buttonProps} = args const buttons = Array.from({length: buttonCount}, (_, i) => ( diff --git a/packages/react/src/ButtonGroup/ButtonGroup.test.tsx b/packages/react/src/ButtonGroup/ButtonGroup.test.tsx index 8fdcea11432..58847679c69 100644 --- a/packages/react/src/ButtonGroup/ButtonGroup.test.tsx +++ b/packages/react/src/ButtonGroup/ButtonGroup.test.tsx @@ -1,5 +1,5 @@ import {render, screen} from '@testing-library/react' -import ButtonGroup from './ButtonGroup' +import ButtonGroup, {calculateVisibleCount} from './ButtonGroup' import {describe, expect, it} from 'vitest' describe('ButtonGroup', () => { @@ -18,3 +18,35 @@ describe('ButtonGroup', () => { expect(screen.getByRole('toolbar')).toBeInTheDocument() }) }) + +describe('calculateVisibleCount', () => { + it('returns total items when container is wide enough', () => { + const result = calculateVisibleCount({ + itemWidths: [80, 80, 80], + containerWidth: 240, + overflowWidth: 32, + }) + + expect(result).toBe(3) + }) + + it('returns zero when nothing fits', () => { + const result = calculateVisibleCount({ + itemWidths: [120, 80], + containerWidth: 60, + overflowWidth: 32, + }) + + expect(result).toBe(0) + }) + + it('avoids leaving a single item in overflow', () => { + const result = calculateVisibleCount({ + itemWidths: [120, 100, 100], + containerWidth: 230, + overflowWidth: 40, + }) + + expect(result).toBe(1) + }) +}) diff --git a/packages/react/src/ButtonGroup/ButtonGroup.tsx b/packages/react/src/ButtonGroup/ButtonGroup.tsx index cd07d132175..8e53ac26fb6 100644 --- a/packages/react/src/ButtonGroup/ButtonGroup.tsx +++ b/packages/react/src/ButtonGroup/ButtonGroup.tsx @@ -1,34 +1,318 @@ import React, {type PropsWithChildren} from 'react' -import classes from './ButtonGroup.module.css' import {clsx} from 'clsx' +import {KebabHorizontalIcon} from '@primer/octicons-react' + +import classes from './ButtonGroup.module.css' +import {IconButton} from '../Button' +import {AnchoredOverlay} from '../AnchoredOverlay' import {FocusKeys, useFocusZone} from '../hooks/useFocusZone' +import {useResizeObserver} from '../hooks/useResizeObserver' import {useProvidedRefOrCreate} from '../hooks' +import {useId} from '../hooks/useId' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' +type Phase = 'measuring' | 'ready' + +const DEFAULT_OVERFLOW_ARIA_LABEL = 'More actions' +const BORDER_OVERLAP_PX = 1 + +type Layout = { + visibleCount: number +} + +type ItemDescriptor = { + key: React.Key + node: React.ReactNode +} + +export function calculateVisibleCount({ + itemWidths, + containerWidth, + overflowWidth, +}: { + itemWidths: number[] + containerWidth: number + overflowWidth: number +}): number { + const totalItems = itemWidths.length + + if (totalItems === 0) { + return 0 + } + + if (containerWidth <= 0) { + return 0 + } + + if (overflowWidth <= 0) { + return totalItems + } + + // Calculate total width if everything were visible without overflow + const totalWidthWithoutOverflow = itemWidths.reduce((total, width, index) => { + const overlapAdjustment = index > 0 ? BORDER_OVERLAP_PX : 0 + return total + Math.max(width - overlapAdjustment, 0) + }, 0) + + if (totalWidthWithoutOverflow <= containerWidth) { + return totalItems + } + + let usedWidth = 0 + let visibleCount = 0 + + for (let index = 0; index < totalItems; index++) { + const width = itemWidths[index] + const contribution = Math.max(width - (index > 0 ? BORDER_OVERLAP_PX : 0), 0) + const nextUsedWidth = usedWidth + contribution + const itemsRemaining = totalItems - (index + 1) + + if (itemsRemaining === 0) { + if (nextUsedWidth <= containerWidth) { + visibleCount = totalItems + } + break + } + + const overflowContribution = Math.max(overflowWidth - BORDER_OVERLAP_PX, 0) + if (nextUsedWidth + overflowContribution > containerWidth) { + visibleCount = index + break + } + + usedWidth = nextUsedWidth + visibleCount = index + 1 + } + + if (visibleCount < 0) { + visibleCount = 0 + } + + const overflowCount = totalItems - visibleCount + if (overflowCount === 1) { + visibleCount = Math.max(visibleCount - 1, 0) + } + + return visibleCount +} + export type ButtonGroupProps = PropsWithChildren<{ /** The role of the group */ role?: string /** className passed in for styling */ className?: string + /** Accessible label for the overflow trigger */ + overflowAriaLabel?: string }> -const ButtonGroup = React.forwardRef(function ButtonGroup( - {as: BaseComponent = 'div', children, className, role, ...rest}, - forwardRef, -) { - const buttons = React.Children.map(children, (child, index) =>
{child}
) - const buttonRef = useProvidedRefOrCreate(forwardRef as React.RefObject) +const ButtonGroup = React.forwardRef(function ButtonGroup(props, forwardRef) { + const { + as: BaseComponent = 'div', + children, + className, + role, + overflowAriaLabel = DEFAULT_OVERFLOW_ARIA_LABEL, + ...rest + } = props + const items = React.useMemo(() => { + return React.Children.toArray(children).map((child, index) => { + if (React.isValidElement(child) && child.key !== null) { + return {key: child.key, node: child} + } + + return {key: index, node: child as React.ReactNode} + }) + }, [children]) + + const hasItems = items.length > 0 + + const containerRef = useProvidedRefOrCreate(forwardRef as React.RefObject) + const measurementItemRefs = React.useRef>([]) + const overflowMeasurementRef = React.useRef(null) + const previousWidthRef = React.useRef(null) + const previousItemCountRef = React.useRef(items.length) + + const [phase, setPhase] = React.useState(hasItems ? 'measuring' : 'ready') + const [layout, setLayout] = React.useState({visibleCount: items.length}) + const [isOverflowOpen, setOverflowOpen] = React.useState(false) + + React.useEffect(() => { + if (previousItemCountRef.current !== items.length) { + previousItemCountRef.current = items.length + setPhase(items.length > 0 ? 'measuring' : 'ready') + } + }, [items.length]) + + React.useEffect(() => { + if (phase === 'measuring') { + measurementItemRefs.current = [] + overflowMeasurementRef.current = null + } + }, [phase]) + + React.useLayoutEffect(() => { + if (phase !== 'measuring') { + return + } + + const containerWidth = containerRef.current?.getBoundingClientRect().width ?? 0 + if (containerWidth === 0) { + return + } + + const itemWidths = items.map((_, index) => { + const ref = measurementItemRefs.current[index] + return ref ? ref.getBoundingClientRect().width : 0 + }) + + const overflowWidth = overflowMeasurementRef.current?.getBoundingClientRect().width ?? 0 + + if (itemWidths.some(width => width === 0) && items.length > 0) { + return + } + + const visibleCount = calculateVisibleCount({ + itemWidths, + containerWidth, + overflowWidth, + }) + + setLayout({visibleCount}) + setPhase('ready') + }, [containerRef, items, phase]) + + const scheduleMeasurement = React.useCallback(() => { + setPhase(current => (current === 'measuring' || !hasItems ? current : 'measuring')) + }, [hasItems]) + + useResizeObserver(entries => { + const entry = entries[0] + const width = entry.contentRect.width + if (previousWidthRef.current === width) { + return + } + previousWidthRef.current = width + scheduleMeasurement() + }, containerRef as React.RefObject) + + React.useEffect(() => { + if (layout.visibleCount >= items.length && isOverflowOpen) { + setOverflowOpen(false) + } + }, [isOverflowOpen, items.length, layout.visibleCount]) useFocusZone({ - containerRef: buttonRef, + containerRef, disabled: role !== 'toolbar', bindKeys: FocusKeys.ArrowHorizontal, focusOutBehavior: 'wrap', }) + const overflowMenuId = useId() + const overflowAriaLabelText = overflowAriaLabel + + const visibleItems = React.useMemo(() => { + return items.slice(0, layout.visibleCount) + }, [items, layout.visibleCount]) + + const overflowItems = React.useMemo(() => { + return items.slice(layout.visibleCount) + }, [items, layout.visibleCount]) + + const setMeasurementRef = React.useCallback( + (index: number) => (node: HTMLDivElement | null) => { + measurementItemRefs.current[index] = node + }, + [], + ) + + const renderOverflowTrigger = (measurement: boolean) => { + const trigger = ( + + ) + + if (measurement) { + return trigger + } + + return ( + setOverflowOpen(true)} + onClose={() => setOverflowOpen(false)} + renderAnchor={anchorProps => ( + + )} + focusTrapSettings={{disabled: false}} + focusZoneSettings={{bindKeys: FocusKeys.ArrowVertical, focusOutBehavior: 'wrap'}} + > + + + ) + } + return ( - - {buttons} + + {phase === 'measuring' ? ( + <> + {items.map((item, index) => ( +
+ {item.node} +
+ ))} + {hasItems ? ( +
+ {renderOverflowTrigger(true)} +
+ ) : null} + + ) : ( + <> + {visibleItems.map(item => ( +
+ {item.node} +
+ ))} + {overflowItems.length > 0 ? ( +
+ {renderOverflowTrigger(false)} +
+ ) : null} + + )}
) }) as PolymorphicForwardRefComponent<'div', ButtonGroupProps>