From 58bf69f3351a872ac9c6be62544e8468dc86d1cf Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Tue, 3 Feb 2026 21:58:37 -0500 Subject: [PATCH 1/7] break out breadcrumb responsive hook --- .../Breadcrumbs/Breadcrumbs.dev.stories.tsx | 179 +++++++- .../react/src/Breadcrumbs/Breadcrumbs.tsx | 396 +++++------------- .../Breadcrumbs/BreadcrumbsOverflowMenu.tsx | 129 ++++++ .../__tests__/Breadcrumbs.test.tsx | 53 +++ .../useBreadcrumbsResponsive.test.tsx | 113 +++++ packages/react/src/Breadcrumbs/index.ts | 9 + .../Breadcrumbs/useBreadcrumbsResponsive.ts | 225 ++++++++++ packages/react/src/index.ts | 14 +- 8 files changed, 818 insertions(+), 300 deletions(-) create mode 100644 packages/react/src/Breadcrumbs/BreadcrumbsOverflowMenu.tsx create mode 100644 packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx create mode 100644 packages/react/src/Breadcrumbs/useBreadcrumbsResponsive.ts diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx index 45dd4b80712..3e26a1e6c6f 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx @@ -1,7 +1,8 @@ import {useState} from 'react' -import Breadcrumbs from '.' +import Breadcrumbs, {useBreadcrumbsResponsive, BreadcrumbsOverflowMenu} from '.' import TextInput from '../TextInput' import classes from './Breadcrumbs.dev.stories.module.css' +import overflowClasses from './Breadcrumbs.module.css' export default { title: 'Components/Breadcrumbs/Dev', @@ -153,3 +154,179 @@ export const WithEditableNameInput = () => ( ) + +/** + * Demonstrates using `responsive={false}` to disable automatic overflow behavior. + * All items are rendered as-is without any overflow menu, regardless of container width. + */ +export const ResponsiveFalse = () => ( +
+
+

With responsive=false (no overflow menu)

+
+ + Home + Products + Category + Subcategory + + Current Page + + +
+
+ +
+

With responsive=true (default, shows overflow menu)

+
+ + Home + Products + Category + Subcategory + + Current Page + + +
+
+
+) + +/** + * Demonstrates using the `useBreadcrumbsResponsive` hook for manual control. + * This enables SSR-friendly conditional rendering patterns. + */ +export const UseBreadcrumbsResponsiveHook = () => { + const children = [ + + Home + , + + Category + , + + Subcategory + , + + Product + , + + Details + , + + Specifications + , + + Reviews + , + ] + + const {visibleItems, menuItems, showRoot, rootItem, containerRef} = useBreadcrumbsResponsive({ + children, + overflow: 'menu-with-root', + }) + + // This is a simplified example - in real usage, you would use the hook + // to conditionally render different layouts for mobile vs desktop + + return ( +
+
+

Hook return values:

+
+          {JSON.stringify(
+            {
+              visibleItemsCount: visibleItems.length,
+              menuItemsCount: menuItems.length,
+              showRoot,
+              hasRootItem: !!rootItem,
+              hasContainerRef: !!containerRef,
+            },
+            null,
+            2,
+          )}
+        
+
+ +
+

Manual rendering using hook data:

+ +
+ +
+

+ The useBreadcrumbsResponsive hook allows you to get the computed visible/menu items and manually + render the breadcrumbs. This is useful for SSR scenarios where you want to conditionally render different + versions for mobile and desktop. +

+
+
+ ) +} + +// Helper component for stories +const ItemSeparator = () => ( + + + +) + +/** + * Demonstrates using the BreadcrumbsOverflowMenu component standalone. + */ +export const StandaloneBreadcrumbsOverflowMenu = () => { + const items = [ + + Home + , + + Products + , + + Category + , + ] + + return ( +
+

BreadcrumbsOverflowMenu as a standalone component:

+
+ Click the menu: + +
+

+ The BreadcrumbsOverflowMenu component can be used standalone when building custom breadcrumb + layouts with the useBreadcrumbsResponsive hook. +

+
+ ) +} diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index 5a59efe2f4c..6efa13a372c 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -1,16 +1,10 @@ import {clsx} from 'clsx' import type {To} from 'history' -import React, {useState, useRef, useCallback, useEffect, useMemo, type ForwardedRef} from 'react' +import React, {useCallback, type ForwardedRef} from 'react' import classes from './Breadcrumbs.module.css' -import Details from '../Details' -import {ActionList} from '../ActionList' -import {IconButton} from '../Button/IconButton' -import {KebabHorizontalIcon} from '@primer/octicons-react' -import {useResizeObserver} from '../hooks/useResizeObserver' -import type {ResizeObserverEntry} from '../hooks/useResizeObserver' -import {useOnEscapePress} from '../hooks/useOnEscapePress' -import {useOnOutsideClick} from '../hooks/useOnOutsideClick' import {useFeatureFlag} from '../FeatureFlags' +import {useBreadcrumbsResponsive} from './useBreadcrumbsResponsive' +import {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu' export type BreadcrumbsProps = React.PropsWithChildren<{ /** @@ -34,324 +28,132 @@ export type BreadcrumbsProps = React.PropsWithChildren<{ * Allows passing of CSS custom properties to the breadcrumbs container. */ style?: React.CSSProperties + /** + * Controls whether the component automatically handles responsive overflow behavior. + * When true (default), the component will automatically collapse items into an overflow menu + * based on available width. When false, children are rendered as-is, allowing manual control + * using the `useBreadcrumbsResponsive` hook for SSR-friendly conditional rendering. + * @default true + */ + responsive?: boolean }> const BreadcrumbsList = ({children}: React.PropsWithChildren) => { return
    {children}
} -type BreadcrumbsMenuItemProps = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - items: React.ReactElement[] - 'aria-label'?: string -} - -const BreadcrumbsMenuItem = React.forwardRef( - ({items, 'aria-label': ariaLabel, ...rest}, menuRefCallback) => { - const [isOpen, setIsOpen] = useState(false) - const detailsRef = useRef(null) - const menuButtonRef = useRef(null) - const menuContainerRef = useRef(null) - const detailsRefCallback = useCallback( - (element: HTMLDetailsElement | null) => { - detailsRef.current = element - if (typeof menuRefCallback === 'function') { - menuRefCallback(element) - } - }, - [menuRefCallback], - ) - const handleSummaryClick = useCallback((event: React.MouseEvent) => { - // Prevent the button click from bubbling up and interfering with details toggle - event.preventDefault() - // Manually toggle the details element - if (detailsRef.current) { - const newOpenState = !detailsRef.current.open - detailsRef.current.open = newOpenState - setIsOpen(newOpenState) - } - }, []) - - const closeOverlay = useCallback(() => { - if (detailsRef.current) { - detailsRef.current.open = false - setIsOpen(false) - } - }, []) - - const focusOnMenuButton = useCallback(() => { - menuButtonRef.current?.focus() - }, []) - - useOnEscapePress( - (event: KeyboardEvent) => { - if (isOpen) { - event.preventDefault() - closeOverlay() - focusOnMenuButton() - } - }, - [isOpen], - ) - - useOnOutsideClick({ - onClickOutside: closeOverlay, - containerRef: menuContainerRef, - ignoreClickRefs: [menuButtonRef], - }) - - return ( -
- -
- - {items.map((item, index) => { - const href = item.props.href - const children = item.props.children - const selected = item.props.selected - return ( - - {children} - - ) - })} - -
-
- ) - }, -) - -BreadcrumbsMenuItem.displayName = 'Breadcrumbs.MenuItem' - -const getValidChildren = (children: React.ReactNode) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] -} - -function Breadcrumbs({className, children, style, overflow = 'wrap', variant = 'normal'}: BreadcrumbsProps) { +function Breadcrumbs({ + className, + children, + style, + overflow = 'wrap', + variant = 'normal', + responsive = true, +}: BreadcrumbsProps) { const overflowMenuEnabled = useFeatureFlag('primer_react_breadcrumbs_overflow_menu') - const wrappedChildren = React.Children.map(children, child =>
  • {child}
  • ) - const containerRef = useRef(null) - - const measureMenuButton = useCallback((element: HTMLDetailsElement | null) => { - if (element) { - const iconButtonElement = element.querySelector('button[data-component="IconButton"]') - if (iconButtonElement) { - const measuredWidth = (iconButtonElement as HTMLElement).offsetWidth - // eslint-disable-next-line react-hooks/immutability - setMenuButtonWidth(measuredWidth) - } - } - }, []) - - const hideRoot = !(overflow === 'menu-with-root') - const [effectiveHideRoot, setEffectiveHideRoot] = useState(hideRoot) - const childArray = useMemo(() => getValidChildren(children), [children]) - - const rootItem = childArray[0] - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [visibleItems, setVisibleItems] = useState[]>(() => childArray) - const [childArrayWidths, setChildArrayWidths] = useState(() => []) - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [menuItems, setMenuItems] = useState[]>([]) - const [rootItemWidth, setRootItemWidth] = useState(0) - - const MENU_BUTTON_FALLBACK_WIDTH = 32 // Design system small IconButton - const [menuButtonWidth, setMenuButtonWidth] = useState(MENU_BUTTON_FALLBACK_WIDTH) - - useEffect(() => { - const listElement = containerRef.current?.querySelector('ol') - if ( - overflowMenuEnabled && - listElement && - listElement.children.length > 0 && - listElement.children.length === childArray.length - ) { - const listElementArray = Array.from(listElement.children) as HTMLElement[] - const widths = listElementArray.map(child => child.offsetWidth) - setChildArrayWidths(widths) - setRootItemWidth(listElementArray[0].offsetWidth) - } - }, [childArray, overflowMenuEnabled]) - - const calculateOverflow = useCallback( - (availableWidth: number) => { - let eHideRoot = effectiveHideRoot - const MENU_BUTTON_WIDTH = menuButtonWidth - const MIN_VISIBLE_ITEMS = !eHideRoot ? 3 : 4 - - const calculateVisibleItemsWidth = (w: number[]) => { - const widths = w.reduce((sum, width) => sum + width + 16, 0) - return !eHideRoot ? rootItemWidth + widths : widths - } - - let currentVisibleItems = [...childArray] - let currentVisibleItemWidths = [...childArrayWidths] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let currentMenuItems: React.ReactElement[] = [] - let currentMenuItemsWidths: number[] = [] - - if (availableWidth > 0 && currentVisibleItemWidths.length > 0) { - let visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths) - - if (currentMenuItems.length > 0) { - visibleItemsWidthTotal += MENU_BUTTON_WIDTH - } - while ( - (overflow === 'menu' || overflow === 'menu-with-root') && - (visibleItemsWidthTotal > availableWidth || currentVisibleItems.length > MIN_VISIBLE_ITEMS) - ) { - const itemToHide = currentVisibleItems[0] - const itemToHideWidth = currentVisibleItemWidths[0] - currentMenuItems = [...currentMenuItems, itemToHide] - currentMenuItemsWidths = [...currentMenuItemsWidths, itemToHideWidth] - currentVisibleItems = currentVisibleItems.slice(1) - currentVisibleItemWidths = currentVisibleItemWidths.slice(1) - - visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths) - if (currentMenuItems.length > 0) { - visibleItemsWidthTotal += MENU_BUTTON_WIDTH - } - - if (currentVisibleItems.length === 1 && visibleItemsWidthTotal > availableWidth) { - eHideRoot = true - break - } else { - eHideRoot = hideRoot - } + // Use the responsive hook for automatic overflow handling + const {visibleItems, menuItems, showRoot, rootItem, containerRef, _setMenuButtonWidth} = useBreadcrumbsResponsive({ + children, + overflow, + }) as ReturnType & { + _setMenuButtonWidth: React.Dispatch> + } + + const measureMenuButton = useCallback( + (element: HTMLDetailsElement | null) => { + if (element) { + const iconButtonElement = element.querySelector('button[data-component="IconButton"]') + if (iconButtonElement) { + const measuredWidth = (iconButtonElement as HTMLElement).offsetWidth + _setMenuButtonWidth(measuredWidth) } } - return { - visibleItems: currentVisibleItems, - menuItems: currentMenuItems, - effectiveHideRoot: eHideRoot, - } }, - [childArray, childArrayWidths, effectiveHideRoot, hideRoot, overflow, rootItemWidth, menuButtonWidth], + [_setMenuButtonWidth], ) - const handleResize = useCallback( - (entries: ResizeObserverEntry[]) => { - if (overflowMenuEnabled && entries[0]) { - const containerWidth = entries[0].contentRect.width - const result = calculateOverflow(containerWidth) - if ( - (visibleItems.length !== result.visibleItems.length && menuItems.length !== result.menuItems.length) || - result.effectiveHideRoot !== effectiveHideRoot - ) { - setVisibleItems(result.visibleItems) - setMenuItems(result.menuItems) - setEffectiveHideRoot(result.effectiveHideRoot) - } - } - }, - [calculateOverflow, effectiveHideRoot, menuItems.length, overflowMenuEnabled, visibleItems.length], - ) + // Non-responsive mode: render children as-is + if (!responsive) { + const wrappedChildren = React.Children.map(children, child =>
  • {child}
  • ) + return ( + + ) + } - useResizeObserver(handleResize, containerRef) + // Responsive mode with feature flag disabled: render wrapped children + if (!overflowMenuEnabled) { + const wrappedChildren = React.Children.map(children, child =>
  • {child}
  • ) + return ( + + ) + } - useEffect(() => { - if ( - overflowMenuEnabled && - (overflow === 'menu' || overflow === 'menu-with-root') && - childArray.length > 5 && - menuItems.length === 0 - ) { - const containerWidth = containerRef.current?.offsetWidth || 800 - const result = calculateOverflow(containerWidth) - setVisibleItems(result.visibleItems) - setMenuItems(result.menuItems) - setEffectiveHideRoot(result.effectiveHideRoot) + // Responsive mode with feature flag enabled: use computed overflow + const finalChildren = (() => { + if (overflow === 'wrap' || menuItems.length === 0) { + return React.Children.map(children, child =>
  • {child}
  • ) } - }, [overflow, childArray, calculateOverflow, menuItems.length, overflowMenuEnabled]) - - const finalChildren = React.useMemo(() => { - if (overflowMenuEnabled) { - if (overflow === 'wrap' || menuItems.length === 0) { - return React.Children.map(children, child =>
  • {child}
  • ) - } - - let effectiveMenuItems = [...menuItems] - // In 'menu-with-root' mode, include the root item inside the menu even if it's visible in the breadcrumbs - if (!effectiveHideRoot) { - effectiveMenuItems = [...menuItems.slice(1)] - } - const menuElement = ( -
  • - - -
  • - ) - const visibleElements = visibleItems.map((child, index) => ( -
  • - {child} - -
  • - )) + const menuElement = ( +
  • + + +
  • + ) - const rootElement = ( -
  • - {rootItem} - -
  • - ) + const visibleElements = visibleItems.map((child, index) => ( +
  • + {child} + +
  • + )) + + const rootElement = ( +
  • + {rootItem} + +
  • + ) - if (effectiveHideRoot) { - // Show: [overflow menu, leaf breadcrumb] - return [menuElement, ...visibleElements] - } else { - // Show: [root breadcrumb, overflow menu, leaf breadcrumb] - return [rootElement, menuElement, ...visibleElements] - } + if (!showRoot) { + // Show: [overflow menu, leaf breadcrumb] + return [menuElement, ...visibleElements] + } else { + // Show: [root breadcrumb, overflow menu, leaf breadcrumb] + return [rootElement, menuElement, ...visibleElements] } - }, [overflowMenuEnabled, overflow, menuItems, effectiveHideRoot, measureMenuButton, visibleItems, rootItem, children]) + })() - return overflowMenuEnabled ? ( + return ( - ) : ( - ) } diff --git a/packages/react/src/Breadcrumbs/BreadcrumbsOverflowMenu.tsx b/packages/react/src/Breadcrumbs/BreadcrumbsOverflowMenu.tsx new file mode 100644 index 00000000000..94a6c1e2b20 --- /dev/null +++ b/packages/react/src/Breadcrumbs/BreadcrumbsOverflowMenu.tsx @@ -0,0 +1,129 @@ +import React, {useState, useRef, useCallback} from 'react' +import classes from './Breadcrumbs.module.css' +import Details from '../Details' +import {ActionList} from '../ActionList' +import {IconButton} from '../Button/IconButton' +import {KebabHorizontalIcon} from '@primer/octicons-react' +import {useOnEscapePress} from '../hooks/useOnEscapePress' +import {useOnOutsideClick} from '../hooks/useOnOutsideClick' + +export type BreadcrumbsOverflowMenuProps = { + /** + * The breadcrumb items to display in the overflow menu + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + items: React.ReactElement[] + /** + * Accessible label for the menu button + */ + 'aria-label'?: string +} + +/** + * Overflow menu component for Breadcrumbs. + * Displays hidden breadcrumb items in a dropdown menu. + * + * @example + * ```tsx + * + * ``` + */ +export const BreadcrumbsOverflowMenu = React.forwardRef( + ({items, 'aria-label': ariaLabel, ...rest}, menuRefCallback) => { + const [isOpen, setIsOpen] = useState(false) + const detailsRef = useRef(null) + const menuButtonRef = useRef(null) + const menuContainerRef = useRef(null) + + const detailsRefCallback = useCallback( + (element: HTMLDetailsElement | null) => { + detailsRef.current = element + if (typeof menuRefCallback === 'function') { + menuRefCallback(element) + } + }, + [menuRefCallback], + ) + + const handleSummaryClick = useCallback((event: React.MouseEvent) => { + // Prevent the button click from bubbling up and interfering with details toggle + event.preventDefault() + // Manually toggle the details element + if (detailsRef.current) { + const newOpenState = !detailsRef.current.open + detailsRef.current.open = newOpenState + setIsOpen(newOpenState) + } + }, []) + + const closeOverlay = useCallback(() => { + if (detailsRef.current) { + detailsRef.current.open = false + setIsOpen(false) + } + }, []) + + const focusOnMenuButton = useCallback(() => { + menuButtonRef.current?.focus() + }, []) + + useOnEscapePress( + (event: KeyboardEvent) => { + if (isOpen) { + event.preventDefault() + closeOverlay() + focusOnMenuButton() + } + }, + [isOpen], + ) + + useOnOutsideClick({ + onClickOutside: closeOverlay, + containerRef: menuContainerRef, + ignoreClickRefs: [menuButtonRef], + }) + + return ( +
    + +
    + + {items.map((item, index) => { + const href = item.props.href + const children = item.props.children + const selected = item.props.selected + return ( + + {children} + + ) + })} + +
    +
    + ) + }, +) + +BreadcrumbsOverflowMenu.displayName = 'BreadcrumbsOverflowMenu' diff --git a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx index 54105c764de..fc4f0adb2bc 100644 --- a/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx +++ b/packages/react/src/Breadcrumbs/__tests__/Breadcrumbs.test.tsx @@ -548,4 +548,57 @@ describe('Breadcrumbs', () => { expect(container.firstChild).toHaveAttribute('data-variant', 'spacious') }) }) + + describe('responsive prop', () => { + it('renders children as-is when responsive={false}', () => { + renderWithTheme( + + Item 1 + Item 2 + Item 3 + Item 4 + Item 5 + Item 6 + , + {primer_react_breadcrumbs_overflow_menu: true}, + ) + + // All items should be visible when responsive is false + expect(screen.getByText('Item 1')).toBeInTheDocument() + expect(screen.getByText('Item 2')).toBeInTheDocument() + expect(screen.getByText('Item 3')).toBeInTheDocument() + expect(screen.getByText('Item 4')).toBeInTheDocument() + expect(screen.getByText('Item 5')).toBeInTheDocument() + expect(screen.getByText('Item 6')).toBeInTheDocument() + + // No overflow menu should be rendered + expect(screen.queryByRole('button', {name: /more breadcrumb items/i})).not.toBeInTheDocument() + }) + + it('does not set data-overflow when responsive={false}', () => { + const {container} = renderWithTheme( + + Home + Docs + , + {primer_react_breadcrumbs_overflow_menu: true}, + ) + + // Should not have data-overflow when responsive is false + expect(container.firstChild).not.toHaveAttribute('data-overflow') + }) + + it('defaults responsive to true', () => { + const {container} = renderWithTheme( + + Home + Docs + , + {primer_react_breadcrumbs_overflow_menu: true}, + ) + + // Should have data-overflow when responsive is true (default) + expect(container.firstChild).toHaveAttribute('data-overflow', 'menu') + }) + }) }) diff --git a/packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx b/packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx new file mode 100644 index 00000000000..f7306ee09ba --- /dev/null +++ b/packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx @@ -0,0 +1,113 @@ +import {renderHook} from '@testing-library/react' +import {describe, expect, it} from 'vitest' +import React from 'react' +import {useBreadcrumbsResponsive, getValidChildren} from '../useBreadcrumbsResponsive' +import {BreadcrumbsOverflowMenu} from '../BreadcrumbsOverflowMenu' + +// Mock breadcrumb items for testing +const createMockItems = (count: number) => { + return Array.from({length: count}, (_, i) => ( + + Item {i + 1} + + )) +} + +describe('useBreadcrumbsResponsive', () => { + describe('getValidChildren', () => { + it('filters out non-element children', () => { + const children = [Valid, null, undefined, 'text',
    Also Valid
    ] + const result = getValidChildren(children) + expect(result).toHaveLength(2) + }) + + it('returns empty array for no valid children', () => { + const result = getValidChildren([null, undefined, 'text']) + expect(result).toHaveLength(0) + }) + }) + + describe('hook return values', () => { + it('returns all children as visibleItems initially for wrap mode', () => { + const items = createMockItems(3) + const {result} = renderHook(() => + useBreadcrumbsResponsive({ + children: items, + overflow: 'wrap', + }), + ) + + expect(result.current.visibleItems).toHaveLength(3) + expect(result.current.menuItems).toHaveLength(0) + // In 'wrap' mode (not menu-with-root), showRoot is false since we're not using menu overflow + expect(result.current.showRoot).toBe(false) + }) + + it('returns containerRef for attaching to container element', () => { + const items = createMockItems(3) + const {result} = renderHook(() => + useBreadcrumbsResponsive({ + children: items, + overflow: 'menu', + }), + ) + + expect(result.current.containerRef).toBeDefined() + expect(result.current.containerRef.current).toBeNull() // Initially null + }) + + it('returns BreadcrumbsOverflowMenu component', () => { + const items = createMockItems(3) + const {result} = renderHook(() => + useBreadcrumbsResponsive({ + children: items, + overflow: 'menu', + }), + ) + + expect(result.current.BreadcrumbsOverflowMenu).toBe(BreadcrumbsOverflowMenu) + }) + + it('returns rootItem as the first valid child element', () => { + const items = createMockItems(3) + const {result} = renderHook(() => + useBreadcrumbsResponsive({ + children: items, + overflow: 'menu', + }), + ) + + // Check that rootItem is a valid element with the expected props + expect(result.current.rootItem).toBeDefined() + expect(result.current.rootItem?.props.href).toBe('/0') + }) + }) + + describe('overflow modes', () => { + it('defaults overflow to wrap', () => { + const items = createMockItems(3) + const {result} = renderHook(() => + useBreadcrumbsResponsive({ + children: items, + }), + ) + + // In wrap mode, all items should be visible + expect(result.current.visibleItems).toHaveLength(3) + expect(result.current.menuItems).toHaveLength(0) + }) + + it('handles menu-with-root overflow mode', () => { + const items = createMockItems(3) + const {result} = renderHook(() => + useBreadcrumbsResponsive({ + children: items, + overflow: 'menu-with-root', + }), + ) + + // showRoot should be true for menu-with-root mode + expect(result.current.showRoot).toBe(true) + }) + }) +}) diff --git a/packages/react/src/Breadcrumbs/index.ts b/packages/react/src/Breadcrumbs/index.ts index 1e65b108770..9c488176d73 100644 --- a/packages/react/src/Breadcrumbs/index.ts +++ b/packages/react/src/Breadcrumbs/index.ts @@ -1,2 +1,11 @@ export {default, Breadcrumb} from './Breadcrumbs' export type {BreadcrumbsProps, BreadcrumbProps, BreadcrumbsItemProps, BreadcrumbItemProps} from './Breadcrumbs' +export {useBreadcrumbsResponsive} from './useBreadcrumbsResponsive' +export type { + UseBreadcrumbsResponsiveOptions, + UseBreadcrumbsResponsiveReturn, + BreadcrumbsOverflowMode, + BreadcrumbsItemElement, +} from './useBreadcrumbsResponsive' +export {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu' +export type {BreadcrumbsOverflowMenuProps} from './BreadcrumbsOverflowMenu' diff --git a/packages/react/src/Breadcrumbs/useBreadcrumbsResponsive.ts b/packages/react/src/Breadcrumbs/useBreadcrumbsResponsive.ts new file mode 100644 index 00000000000..062429778fa --- /dev/null +++ b/packages/react/src/Breadcrumbs/useBreadcrumbsResponsive.ts @@ -0,0 +1,225 @@ +import React, {useState, useRef, useCallback, useEffect, useMemo} from 'react' +import {useResizeObserver} from '../hooks/useResizeObserver' +import type {ResizeObserverEntry} from '../hooks/useResizeObserver' +import {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu' + +export type BreadcrumbsOverflowMode = 'wrap' | 'menu' | 'menu-with-root' + +export interface UseBreadcrumbsResponsiveOptions { + /** + * The breadcrumb items to manage + */ + children: React.ReactNode + /** + * Controls the overflow behavior of the breadcrumbs. + * - 'wrap': All items wrap in the given space (no overflow menu) + * - 'menu': Overflowing items appear in a dropdown menu + * - 'menu-with-root': Like 'menu', but root item stays visible outside the menu + * @default 'wrap' + */ + overflow?: BreadcrumbsOverflowMode +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type BreadcrumbsItemElement = React.ReactElement + +export interface UseBreadcrumbsResponsiveReturn { + /** + * Items to display directly in the breadcrumb trail + */ + visibleItems: BreadcrumbsItemElement[] + /** + * Items to display in the overflow menu + */ + menuItems: BreadcrumbsItemElement[] + /** + * Whether to show the root item separately (outside menu). + * Only relevant when overflow is 'menu-with-root'. + */ + showRoot: boolean + /** + * The root item element (first child), if available + */ + rootItem: BreadcrumbsItemElement | undefined + /** + * Ref to attach to the breadcrumbs container for resize observation + */ + containerRef: React.RefObject + /** + * Pre-built overflow menu component for rendering hidden items + */ + BreadcrumbsOverflowMenu: typeof BreadcrumbsOverflowMenu +} + +/** + * Filters React children to only valid elements + */ +export const getValidChildren = (children: React.ReactNode): BreadcrumbsItemElement[] => { + return React.Children.toArray(children).filter(child => React.isValidElement(child)) as BreadcrumbsItemElement[] +} + +const MENU_BUTTON_FALLBACK_WIDTH = 32 // Design system small IconButton + +/** + * Hook that manages responsive overflow behavior for Breadcrumbs. + * + * This hook handles the logic for determining which breadcrumb items should be + * visible and which should be collapsed into an overflow menu based on + * available container width. + * + * @example + * ```tsx + * const { visibleItems, menuItems, showRoot, rootItem, containerRef, BreadcrumbsOverflowMenu } = + * useBreadcrumbsResponsive({ children, overflow: 'menu' }) + * + * return ( + * + * ) + * ``` + */ +export function useBreadcrumbsResponsive(options: UseBreadcrumbsResponsiveOptions): UseBreadcrumbsResponsiveReturn { + const {children, overflow = 'wrap'} = options + + const containerRef = useRef(null) + const hideRoot = !(overflow === 'menu-with-root') + const [effectiveHideRoot, setEffectiveHideRoot] = useState(hideRoot) + const childArray = useMemo(() => getValidChildren(children), [children]) + + const rootItem = childArray[0] + + const [visibleItems, setVisibleItems] = useState(() => childArray) + const [childArrayWidths, setChildArrayWidths] = useState(() => []) + const [menuItems, setMenuItems] = useState([]) + const [rootItemWidth, setRootItemWidth] = useState(0) + const [menuButtonWidth, setMenuButtonWidth] = useState(MENU_BUTTON_FALLBACK_WIDTH) + + // Measure child widths when children change + useEffect(() => { + const listElement = containerRef.current?.querySelector('ol') + if (listElement && listElement.children.length > 0 && listElement.children.length === childArray.length) { + const listElementArray = Array.from(listElement.children) as HTMLElement[] + const widths = listElementArray.map(child => child.offsetWidth) + // eslint-disable-next-line react-hooks/set-state-in-effect + setChildArrayWidths(widths) + + setRootItemWidth(listElementArray[0].offsetWidth) + } + }, [childArray]) + + const calculateOverflow = useCallback( + (availableWidth: number) => { + let eHideRoot = effectiveHideRoot + const MENU_BUTTON_WIDTH = menuButtonWidth + const MIN_VISIBLE_ITEMS = !eHideRoot ? 3 : 4 + + const calculateVisibleItemsWidth = (w: number[]) => { + const widths = w.reduce((sum, width) => sum + width + 16, 0) + return !eHideRoot ? rootItemWidth + widths : widths + } + + let currentVisibleItems = [...childArray] + let currentVisibleItemWidths = [...childArrayWidths] + let currentMenuItems: BreadcrumbsItemElement[] = [] + let currentMenuItemsWidths: number[] = [] + + if (availableWidth > 0 && currentVisibleItemWidths.length > 0) { + let visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths) + + if (currentMenuItems.length > 0) { + visibleItemsWidthTotal += MENU_BUTTON_WIDTH + } + while ( + (overflow === 'menu' || overflow === 'menu-with-root') && + (visibleItemsWidthTotal > availableWidth || currentVisibleItems.length > MIN_VISIBLE_ITEMS) + ) { + const itemToHide = currentVisibleItems[0] + const itemToHideWidth = currentVisibleItemWidths[0] + currentMenuItems = [...currentMenuItems, itemToHide] + currentMenuItemsWidths = [...currentMenuItemsWidths, itemToHideWidth] + currentVisibleItems = currentVisibleItems.slice(1) + currentVisibleItemWidths = currentVisibleItemWidths.slice(1) + + visibleItemsWidthTotal = calculateVisibleItemsWidth(currentVisibleItemWidths) + + if (currentMenuItems.length > 0) { + visibleItemsWidthTotal += MENU_BUTTON_WIDTH + } + + if (currentVisibleItems.length === 1 && visibleItemsWidthTotal > availableWidth) { + eHideRoot = true + break + } else { + eHideRoot = hideRoot + } + } + } + return { + visibleItems: currentVisibleItems, + menuItems: currentMenuItems, + effectiveHideRoot: eHideRoot, + } + }, + [childArray, childArrayWidths, effectiveHideRoot, hideRoot, overflow, rootItemWidth, menuButtonWidth], + ) + + const handleResize = useCallback( + (entries: ResizeObserverEntry[]) => { + if (entries[0]) { + const containerWidth = entries[0].contentRect.width + const result = calculateOverflow(containerWidth) + if ( + (visibleItems.length !== result.visibleItems.length && menuItems.length !== result.menuItems.length) || + result.effectiveHideRoot !== effectiveHideRoot + ) { + setVisibleItems(result.visibleItems) + setMenuItems(result.menuItems) + setEffectiveHideRoot(result.effectiveHideRoot) + } + } + }, + [calculateOverflow, effectiveHideRoot, menuItems.length, visibleItems.length], + ) + + useResizeObserver(handleResize, containerRef) + + // Initial overflow calculation for many items + useEffect(() => { + if ((overflow === 'menu' || overflow === 'menu-with-root') && childArray.length > 5 && menuItems.length === 0) { + const containerWidth = containerRef.current?.offsetWidth || 800 + const result = calculateOverflow(containerWidth) + // eslint-disable-next-line react-hooks/set-state-in-effect + setVisibleItems(result.visibleItems) + + setMenuItems(result.menuItems) + + setEffectiveHideRoot(result.effectiveHideRoot) + } + }, [overflow, childArray, calculateOverflow, menuItems.length]) + + // Compute effective menu items based on hideRoot setting + const effectiveMenuItems = useMemo(() => { + if (overflow === 'wrap' || menuItems.length === 0) { + return [] + } + // In 'menu-with-root' mode, exclude root from menu since it's shown separately + if (!effectiveHideRoot) { + return menuItems.slice(1) + } + return menuItems + }, [overflow, menuItems, effectiveHideRoot]) + + return { + visibleItems, + menuItems: effectiveMenuItems, + showRoot: !effectiveHideRoot, + rootItem, + containerRef, + BreadcrumbsOverflowMenu, + // Internal setter for menu button width measurement (used by Breadcrumbs component) + _setMenuButtonWidth: setMenuButtonWidth, + } as UseBreadcrumbsResponsiveReturn & {_setMenuButtonWidth: React.Dispatch>} +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b3b6a1ca40d..ace7a8e986d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -80,8 +80,18 @@ export type {BannerProps} from './Banner' export {default as BranchName} from './BranchName' export type {BranchNameProps} from './BranchName' -export {default as Breadcrumbs, Breadcrumb} from './Breadcrumbs' -export type {BreadcrumbsProps, BreadcrumbsItemProps, BreadcrumbProps, BreadcrumbItemProps} from './Breadcrumbs' +export {default as Breadcrumbs, Breadcrumb, useBreadcrumbsResponsive, BreadcrumbsOverflowMenu} from './Breadcrumbs' +export type { + BreadcrumbsProps, + BreadcrumbsItemProps, + BreadcrumbProps, + BreadcrumbItemProps, + UseBreadcrumbsResponsiveOptions, + UseBreadcrumbsResponsiveReturn, + BreadcrumbsOverflowMode, + BreadcrumbsItemElement, + BreadcrumbsOverflowMenuProps, +} from './Breadcrumbs' export {default as ButtonGroup} from './ButtonGroup' export type {ButtonGroupProps} from './ButtonGroup' export type {CircleBadgeProps, CircleBadgeIconProps} from './CircleBadge' From 85a1cb844db7b3ed77b9d6ac7b8288c56e3ca516 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Tue, 3 Feb 2026 22:57:33 -0500 Subject: [PATCH 2/7] Prevent overflow in breadcrumbs --- packages/react/src/Breadcrumbs/Breadcrumbs.module.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css index e699fb42587..1f980028aec 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css @@ -12,6 +12,9 @@ [data-overflow='menu'], [data-overflow='menu-with-root'] { + /* Prevent overflow during SSR before JS calculates the overflow menu */ + overflow: hidden; + & .BreadcrumbsList { white-space: nowrap; display: flex; From d9409a3581d70ef2f9eaccb5c7585ff222a64d76 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Tue, 3 Feb 2026 23:04:15 -0500 Subject: [PATCH 3/7] Add changeset --- .changeset/violet-horses-arrive.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/violet-horses-arrive.md diff --git a/.changeset/violet-horses-arrive.md b/.changeset/violet-horses-arrive.md new file mode 100644 index 00000000000..93fd0a02bc6 --- /dev/null +++ b/.changeset/violet-horses-arrive.md @@ -0,0 +1,5 @@ +--- +'@primer/react': minor +--- + +Update Breadcrumbs component to enable more SSR friendly rendering From 78ec27ccd175652e2f724d86817f3d9bef8a646b Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Tue, 3 Feb 2026 23:26:58 -0500 Subject: [PATCH 4/7] update snapshots --- .../react/src/__tests__/__snapshots__/exports.test.ts.snap | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap index 77f3b55f2a2..bb757c95bef 100644 --- a/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react/src/__tests__/__snapshots__/exports.test.ts.snap @@ -38,7 +38,11 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type BreadcrumbItemProps", "type BreadcrumbProps", "Breadcrumbs", + "type BreadcrumbsItemElement", "type BreadcrumbsItemProps", + "BreadcrumbsOverflowMenu", + "type BreadcrumbsOverflowMenuProps", + "type BreadcrumbsOverflowMode", "type BreadcrumbsProps", "Button", "ButtonBase", @@ -210,6 +214,9 @@ exports[`@primer/react > should not update exports without a semver change 1`] = "type UnderlineNavItemProps", "type UnderlineNavProps", "useAnchoredPosition", + "useBreadcrumbsResponsive", + "type UseBreadcrumbsResponsiveOptions", + "type UseBreadcrumbsResponsiveReturn", "useColorSchemeVar", "useConfirm", "useDetails", From d1af4c047003fe78389d0cec58f7fed0c799f76d Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Tue, 3 Feb 2026 23:44:46 -0500 Subject: [PATCH 5/7] Update tests --- packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts | 2 +- script/check-classname-tests.mjs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts b/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts index 0956fa0becb..c84f5bc6e7c 100644 --- a/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts +++ b/packages/doc-gen/src/__tests__/ts-utils.patterns.test.ts @@ -6,7 +6,7 @@ const directory = path.resolve(import.meta.dirname) const FIXTURE_PATH = path.join(directory, 'fixtures') describe('getPropTypeForComponent', () => { - it('extracts props for FunctionComponent', () => { + it('extracts props for FunctionComponent', {timeout: 10_000}, () => { const info = parseTypeInfo(FIXTURE_PATH, 'FunctionComponent') expect(info.props.foo).toMatchObject({name: 'foo', type: 'string', required: true}) expect(info.props.bar).toMatchObject({name: 'bar', type: 'number', required: false}) diff --git a/script/check-classname-tests.mjs b/script/check-classname-tests.mjs index 2b934a53fd3..f952a0a7a9d 100755 --- a/script/check-classname-tests.mjs +++ b/script/check-classname-tests.mjs @@ -23,6 +23,7 @@ const IGNORED_FILES = [ 'packages/react/src/__tests__/ThemeProvider.test.tsx', 'packages/react/src/__tests__/deprecated/ActionMenu.test.tsx', 'packages/react/src/__tests__/Caret.test.tsx', + 'packages/react/src/Breadcrumbs/__tests__/useBreadcrumbsResponsive.test.tsx', ] function getAllTestFiles(dir, files = []) { From 8c8df018b8ab184f5cef37e86d62e55d4b268a44 Mon Sep 17 00:00:00 2001 From: Adam Dierkens Date: Wed, 4 Feb 2026 02:14:37 -0500 Subject: [PATCH 6/7] Fix menu layouts --- .../Breadcrumbs.examples.stories.tsx | 57 +++++++++++++++++++ .../src/Breadcrumbs/Breadcrumbs.module.css | 8 ++- .../react/src/Breadcrumbs/Breadcrumbs.tsx | 13 ++++- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 packages/react/src/Breadcrumbs/Breadcrumbs.examples.stories.tsx diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.examples.stories.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.examples.stories.tsx new file mode 100644 index 00000000000..e0bc0f47759 --- /dev/null +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.examples.stories.tsx @@ -0,0 +1,57 @@ +import type {Meta} from '@storybook/react-vite' +import type React from 'react' +import type {ComponentProps} from '../utils/types' +import Breadcrumbs from './Breadcrumbs' +import {BreadcrumbsOverflowMenu} from './BreadcrumbsOverflowMenu' +import {FeatureFlags} from '../FeatureFlags' + +export default { + title: 'Components/Breadcrumbs/Examples', + component: Breadcrumbs, +} as Meta> + +export const ExternallyControlled = () => ( + + Home + Products + Category + Subcategory + Item + Details + + Current Page + + +) + +export const WithManualOverflow = () => ( + + + + Home + , + + Products + , + + Category + , + + Subcategory + , + + Item + , + + Details + , + ]} + /> + + Current Page + + + +) diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css index 1f980028aec..d98e3de24cf 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css @@ -12,14 +12,16 @@ [data-overflow='menu'], [data-overflow='menu-with-root'] { - /* Prevent overflow during SSR before JS calculates the overflow menu */ - overflow: hidden; - & .BreadcrumbsList { white-space: nowrap; display: flex; flex-direction: row; } + + /* Prevent overflow during SSR before JS calculates the overflow menu (responsive mode only) */ + &[data-responsive='true'] { + overflow: hidden; + } } .ItemSeparator { diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index 6efa13a372c..d8e0f27a60b 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -75,12 +75,22 @@ function Breadcrumbs({ // Non-responsive mode: render children as-is if (!responsive) { - const wrappedChildren = React.Children.map(children, child =>
  • {child}
  • ) + const childArray = React.Children.toArray(children) + const wrappedChildren = + overflow === 'menu' || overflow === 'menu-with-root' + ? childArray.map((child, index) => ( +
  • + {child} + {index < childArray.length - 1 && } +
  • + )) + : React.Children.map(children, child =>
  • {child}
  • ) return (