diff --git a/.changeset/eighty-queens-tap.md b/.changeset/eighty-queens-tap.md new file mode 100644 index 00000000000..6d5648d6aae --- /dev/null +++ b/.changeset/eighty-queens-tap.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Breadcrumbs : Add overflow menu for responsive behavior diff --git a/.changeset/hot-bears-cry.md b/.changeset/hot-bears-cry.md new file mode 100644 index 00000000000..b675e41c057 --- /dev/null +++ b/.changeset/hot-bears-cry.md @@ -0,0 +1,5 @@ +--- +"@primer/react": patch +--- + +Breadcrumb overflow styling diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-colorblind-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-colorblind-focus-linux.png index 80bf84cd927..abac0d357a0 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-colorblind-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-colorblind-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-dimmed-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-dimmed-focus-linux.png index aa6bbbcfb2f..7b9d82a8513 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-dimmed-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-dimmed-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-focus-linux.png index 80bf84cd927..abac0d357a0 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-high-contrast-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-high-contrast-focus-linux.png index e8c5b1e5c32..92992ec726a 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-high-contrast-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-high-contrast-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-tritanopia-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-tritanopia-focus-linux.png index 80bf84cd927..abac0d357a0 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-tritanopia-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-dark-tritanopia-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-colorblind-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-colorblind-focus-linux.png index a95ed49f6ea..28540b3c2d3 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-colorblind-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-colorblind-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-focus-linux.png index 7f5a8125dfd..28540b3c2d3 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-high-contrast-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-high-contrast-focus-linux.png index 29efc80181a..5e6d66f8b8f 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-high-contrast-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-high-contrast-focus-linux.png differ diff --git a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-tritanopia-focus-linux.png b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-tritanopia-focus-linux.png index a95ed49f6ea..28540b3c2d3 100644 Binary files a/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-tritanopia-focus-linux.png and b/.playwright/snapshots/components/Breadcrumbs.test.ts-snapshots/Breadcrumbs-Default-light-tritanopia-focus-linux.png differ diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx new file mode 100644 index 00000000000..d122887dc6c --- /dev/null +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.dev.stories.tsx @@ -0,0 +1,162 @@ +import {useState} from 'react' +import Breadcrumbs from '.' +import TextInput from '../TextInput' + +export default { + title: 'Components/Breadcrumbs/Dev', +} + +export const DynamicChildren = () => { + const [items, setItems] = useState([ + {id: 1, href: '#', name: 'Home'}, + {id: 2, href: '#', name: 'Docs'}, + {id: 3, href: '#', name: 'Components'}, + ]) + + const addItem = () => { + const newId = Math.max(...items.map(item => item.id)) + 1 + const names = ['Advanced', 'Examples', 'Guides', 'API', 'Tutorials', 'Reference'] + const randomName = names[Math.floor(Math.random() * names.length)] + setItems([...items, {id: newId, href: '#', name: `${randomName}-${newId}`}]) + } + + const removeItem = () => { + if (items.length > 1) { + setItems(items.slice(0, -1)) + } + } + + const addMultipleItems = () => { + const newItems = [ + {id: Date.now() + 1, href: '#', name: 'Category'}, + {id: Date.now() + 2, href: '#', name: 'Subcategory'}, + {id: Date.now() + 3, href: '#', name: 'Item'}, + {id: Date.now() + 4, href: '#', name: 'Details'}, + {id: Date.now() + 5, href: '#', name: 'Specifications'}, + ] + setItems([...items, ...newItems]) + } + + const reset = () => { + setItems([ + {id: 1, href: '#', name: 'Home'}, + {id: 2, href: '#', name: 'Docs'}, + {id: 3, href: '#', name: 'Components'}, + ]) + } + + return ( +
+
+ + + + +
+ +
+

+ Dynamic breadcrumbs +

+ + {items.map((item, index) => ( + + {item.name} + + ))} + +
+ +
+ Current items: {items.length} | Try adding/removing items to see how overflow behavior changes +
+
+ ) +} + +export const OverflowMenuNarrowContainer = () => ( +
+ + Home + Products + Category + Subcategory + + Current Page + + +
+) + +// Wrapper components to test that BreadcrumbsItem works when wrapped +const StyledWrapper = ({children}: {children: React.ReactNode}) => ( + {children} +) + +const ConditionalWrapper = ({children, condition}: {children: React.ReactNode; condition: boolean}) => { + return condition ? {children} : <>{children} +} + +const DataAttributeWrapper = ({children}: {children: React.ReactNode}) => ( + + {children} + +) + +export const WrappedBreadcrumbItemsWithOverflow = () => ( + + + Wrapped Home + + + Products + + + Category + + + Subcategory + + + Item + + + Details + + + Current Page + + +) + +export const WithEditableNameInput = () => ( + + Home + Documents + Project Alpha + + + + +) diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.docs.json b/packages/react/src/Breadcrumbs/Breadcrumbs.docs.json index ce0747151d6..41321620375 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.docs.json +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.docs.json @@ -14,19 +14,27 @@ "name": "className", "type": "string", "required": false, - "description": "", + "description": "Additional CSS class names to apply to the breadcrumbs container", "defaultValue": "" }, { "name": "children", "type": "Breadcrumbs.Item[]", "defaultValue": "", - "description": "" + "description": "Breadcrumb items to render. Each item should be a Breadcrumbs.Item component." + }, + { + "name": "overflow", + "type": "'wrap' | 'menu' | 'menu-with-root'", + "required": false, + "description": "How to handle overflow when breadcrumbs don't fit in the container. 'wrap' allows items to wrap to new lines. 'menu' collapses items into an overflow menu. 'menu-with-root' also collapses items into an overflow menu but includes the root (first) breadcrumb in the menu so only the last items remain visible.", + "defaultValue": "'wrap'" }, { "name": "sx", "type": "SystemStyleObject", - "deprecated": true + "deprecated": true, + "description": "System styles (deprecated, use CSS classes instead)" } ], "subcomponents": [ @@ -37,7 +45,7 @@ "name": "selected", "type": "boolean", "defaultValue": "false", - "description": "Whether this item represents the current page" + "description": "Whether this item represents the current page. Sets aria-current='page' for accessibility." }, { "name": "to", @@ -46,6 +54,18 @@ "description": "Used when the item is rendered using a component like React Router's `Link`. The path to navigate to.", "defaultValue": "" }, + { + "name": "href", + "type": "string", + "required": false, + "description": "The URL that the breadcrumb item links to. Used with regular anchor elements." + }, + { + "name": "children", + "type": "React.ReactNode", + "required": true, + "description": "The content to display inside the breadcrumb item, typically text." + }, { "name": "ref", "type": "React.RefObject" diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.features.stories.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.features.stories.tsx new file mode 100644 index 00000000000..1e8d695d3a4 --- /dev/null +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.features.stories.tsx @@ -0,0 +1,112 @@ +import type {Meta} from '@storybook/react-vite' +import type React from 'react' +import type {ComponentProps} from '../utils/types' +import Breadcrumbs from './Breadcrumbs' +import {FeatureFlags} from '../FeatureFlags' + +export default { + title: 'Components/Breadcrumbs/Features', + component: Breadcrumbs, +} as Meta> + +export const OverflowWrap = () => ( + + Home + Products + Category + Subcategory + Item + Details + + Current Page + + +) + +export const OverflowMenuFeatureFlagEnabled = () => ( + + + Home + Products + Category + Subcategory + Item + Details + + Current Page + + + +) + +export const OverflowMenuFeatureFlagDisabled = () => ( + + Home + Products + Category + Subcategory + Item + Details + + Current Page + + +) + +export const OverflowMenuShowRootFeatureFlagDisabled = () => ( + + github + Teams + Engineering + core-productivity + collaboration-workflows-flex + + global-navigation-reviewers + + +) + +export const OverflowMenuShowRootFeatureFlagEnabled = () => ( + + + github + Teams + Engineering + core-productivity + collaboration-workflows-flex + + global-navigation-reviewers + + + +) + +export const SpaciousVariantWithOverflowMenu = () => ( + + + Home + Products + Category + Subcategory + Item + Details + + Current Page + + + +) + +export const SpaciousVariantWithOverflowWrap = () => ( + + 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 96c61e7072c..b4d53bfef01 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.module.css +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.module.css @@ -1,6 +1,7 @@ .BreadcrumbsBase { display: flex; justify-content: space-between; + width: 100%; } .BreadcrumbsList { @@ -9,10 +10,27 @@ margin-bottom: 0; } +[data-overflow='menu'], +[data-overflow='menu-with-root'] { + & .BreadcrumbsList { + white-space: nowrap; + display: flex; + flex-direction: row; + } +} + +.ItemSeparator { + color: var(--fgColor-muted); + display: flex; + align-self: center; + justify-content: center; + white-space: nowrap; + user-select: none; +} + .ItemWrapper { display: inline-block; font-size: var(--text-body-size-medium); - white-space: nowrap; list-style: none; &::after { @@ -41,20 +59,105 @@ .Item { display: inline-block; font-size: var(--text-body-size-medium); - color: var(--fgColor-link); - text-decoration: none; - &:hover, - &:focus { - text-decoration: underline; + &:focus-visible { + @mixin focusOutline 1px; } } -.ItemSelected { - color: var(--fgColor-default); - pointer-events: none; +[data-variant='normal'] { + & .Item { + color: var(--fgColor-link); + text-decoration: none; + + &:not([aria-current]) { + &:hover { + text-decoration: underline; + } + } - &:focus { + &:focus-visible { + text-decoration: none; + } + + &[aria-current] { + color: var(--fgColor-default); + } + } +} + +[data-variant='spacious'] { + & .Item { + color: var(--fgColor-default); text-decoration: none; + padding-inline: var(--base-size-6); + padding-block: var(--base-size-4); + border-radius: var(--borderRadius-medium); + + &:hover { + background: var(--control-transparent-bgColor-hover); + text-decoration: none; + } + + &[aria-current] { + font-weight: var(--base-text-weight-semibold); + } + } +} + +.BreadcrumbsItem { + display: inline-grid; + grid-auto-flow: column; + align-items: center; + flex: 0 99999 auto; + min-width: auto; + white-space: nowrap; + list-style: none; + + /* allow menu items to wrap line */ + &:has(.MenuOverlay) { + white-space: normal; + } + + &:first-child { + margin-left: 0; + } + + &:last-child { + .ItemSeparator { + display: none; + } + } + + .MenuDetails { + position: relative; + display: inline-block; + + & summary { + list-style: none; + cursor: pointer; + + &::-webkit-details-marker { + display: none; + } + } + } + + .MenuOverlay { + position: absolute; + z-index: 1; + box-shadow: var(--shadow-floating-small); + border-radius: var(--borderRadius-large); + background-color: var(--overlay-bgColor); + min-width: var(--overlay-width-xsmall); + max-height: 100vh; + max-width: var(--overlay-width-small); + overflow: hidden; + /* stylelint-disable-next-line primer/spacing */ + top: calc(var(--overlay-offset) + var(--control-small-size)); + + @media (prefers-reduced-motion: no-preference) { + animation: overlay-appear 200ms cubic-bezier(0.33, 1, 0.68, 1); + } } } diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.stories.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.stories.tsx index 16be2bd7bff..44e6b6ce1f7 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.stories.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.stories.tsx @@ -1,4 +1,5 @@ import type {Meta} from '@storybook/react-vite' +import React from 'react' import type {ComponentProps} from '../utils/types' import Breadcrumbs from './Breadcrumbs' diff --git a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx index fe47e2307ec..72cf11d6301 100644 --- a/packages/react/src/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/react/src/Breadcrumbs/Breadcrumbs.tsx @@ -1,17 +1,41 @@ import {clsx} from 'clsx' import type {To} from 'history' -import React from 'react' +import React, {useState, useRef, useCallback, useEffect, useMemo} from 'react' import type {SxProp} from '../sx' import type {ComponentProps} from '../utils/types' import classes from './Breadcrumbs.module.css' import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic' import {BoxWithFallback} from '../internal/components/BoxWithFallback' - -const SELECTED_CLASS = 'selected' +import Details from '../Details' +import {ActionList} from '../ActionList' +import {IconButton} from '../Button/IconButton' +import {Tooltip} from '../TooltipV2' +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' export type BreadcrumbsProps = React.PropsWithChildren< { + /** + * Optional class name for the breadcrumbs container. + */ className?: string + /** + * Controls the overflow behavior of the breadcrumbs. + * By default all overflowing crumbs will "wrap" in the given space taking up extra height. + * In the "menu" option we'll see the overflowing crumbs as part of a menu like dropdown instead of the root breadcrumb. + * In "menu-with-root" we see that instead of the root, the menu button will take the place of the next breadcrumb. + */ + overflow?: 'wrap' | 'menu' | 'menu-with-root' + /** + * Controls the visual variant of the breadcrumbs. + * By default, the breadcrumbs will have a normal appearance. + * In the "spacious" option, the breadcrumbs will have increased padding and a more relaxed layout. + */ + variant?: 'normal' | 'spacious' } & SxProp > @@ -19,15 +43,332 @@ const BreadcrumbsList = ({children}: React.PropsWithChildren) => { return
    {children}
} -function Breadcrumbs({className, children, sx: sxProp}: BreadcrumbsProps) { +type BreadcrumbsMenuItemProps = { + 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) => { + return React.Children.toArray(children).filter(child => React.isValidElement(child)) as React.ReactElement[] +} + +function Breadcrumbs({className, children, sx: sxProp, overflow = 'wrap', variant = 'normal'}: BreadcrumbsProps) { + const overflowMenuEnabled = useFeatureFlag('primer_react_breadcrumbs_overflow_menu') const wrappedChildren = React.Children.map(children, child =>
  • {child}
  • ) - return ( - + 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 + setMenuButtonWidth(measuredWidth) + } + } + }, []) + + 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 MENU_BUTTON_FALLBACK_WIDTH = 32 // Design system small IconButton + const [menuButtonWidth, setMenuButtonWidth] = useState(MENU_BUTTON_FALLBACK_WIDTH) + + // if (typeof window !== 'undefined') { + // effectiveOverflow = overflow + // } + + 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] + 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 + } + } + } + return { + visibleItems: currentVisibleItems, + menuItems: currentMenuItems, + effectiveHideRoot: eHideRoot, + } + }, + [childArray, childArrayWidths, effectiveHideRoot, hideRoot, overflow, rootItemWidth, menuButtonWidth], + ) + + 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], + ) + + useResizeObserver(handleResize, containerRef) + + 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) + } + }, [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 rootElement = ( +
  • + {rootItem} + +
  • + ) + + if (effectiveHideRoot) { + // 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 ? ( + + {finalChildren} + + ) : ( + {wrappedChildren} ) } +const ItemSeparator = () => { + return ( + + + + ) +} + type StyledBreadcrumbsItemProps = { to?: To selected?: boolean @@ -40,10 +381,7 @@ const BreadcrumbsItem = React.forwardRef(({selected, className, ...rest}, ref) = return ( { - it('should support `className` on the outermost element', () => { - expect(HTMLRender().container.firstChild).toHaveClass( - 'test-class-name', - ) - }) +// Helper function to render with theme and feature flags +const renderWithTheme = (component: React.ReactElement, flags?: Record) => { + const wrappedComponent = flags ? ( + + {component} + + ) : ( + {component} + ) + return HTMLRender(wrappedComponent) +} + +// Mock ResizeObserver for tests +const mockObserve = vi.fn() +const mockUnobserve = vi.fn() +const mockDisconnect = vi.fn() +globalThis.ResizeObserver = vi.fn().mockImplementation(() => ({ + observe: mockObserve, + unobserve: mockUnobserve, + disconnect: mockDisconnect, +})) + +describe('Breadcrumbs', () => { it('renders a