Skip to content

Commit 0d9ba55

Browse files
committed
Spike for breadcrumbs overflow
1 parent e1268ff commit 0d9ba55

File tree

3 files changed

+317
-5
lines changed

3 files changed

+317
-5
lines changed

packages/react/src/Breadcrumbs/Breadcrumbs.module.css

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
margin-bottom: 0;
1010
}
1111

12+
/* Prevent wrapping when using menu overflow */
13+
[data-overflow='menu'] .BreadcrumbsList {
14+
white-space: nowrap;
15+
overflow: hidden;
16+
}
17+
1218
.ItemWrapper {
1319
display: inline-block;
1420
font-size: var(--text-body-size-medium);

packages/react/src/Breadcrumbs/Breadcrumbs.stories.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,119 @@ export const Default = () => (
1616
</Breadcrumbs.Item>
1717
</Breadcrumbs>
1818
)
19+
20+
export const OverflowWrap = () => (
21+
<Breadcrumbs overflow="wrap">
22+
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
23+
<Breadcrumbs.Item href="#">Products</Breadcrumbs.Item>
24+
<Breadcrumbs.Item href="#">Category</Breadcrumbs.Item>
25+
<Breadcrumbs.Item href="#">Subcategory</Breadcrumbs.Item>
26+
<Breadcrumbs.Item href="#">Item</Breadcrumbs.Item>
27+
<Breadcrumbs.Item href="#">Details</Breadcrumbs.Item>
28+
<Breadcrumbs.Item href="#" selected>
29+
Current Page
30+
</Breadcrumbs.Item>
31+
</Breadcrumbs>
32+
)
33+
34+
export const OverflowMenu = () => (
35+
<Breadcrumbs overflow="menu">
36+
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
37+
<Breadcrumbs.Item href="#">Products</Breadcrumbs.Item>
38+
<Breadcrumbs.Item href="#">Category</Breadcrumbs.Item>
39+
<Breadcrumbs.Item href="#">Subcategory</Breadcrumbs.Item>
40+
<Breadcrumbs.Item href="#">Item</Breadcrumbs.Item>
41+
<Breadcrumbs.Item href="#">Details</Breadcrumbs.Item>
42+
<Breadcrumbs.Item href="#" selected>
43+
Current Page
44+
</Breadcrumbs.Item>
45+
</Breadcrumbs>
46+
)
47+
48+
export const OverflowMenuHideRoot = () => (
49+
<Breadcrumbs overflow="menu" hideRoot={true}>
50+
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
51+
<Breadcrumbs.Item href="#">Products</Breadcrumbs.Item>
52+
<Breadcrumbs.Item href="#">Category</Breadcrumbs.Item>
53+
<Breadcrumbs.Item href="#">Subcategory</Breadcrumbs.Item>
54+
<Breadcrumbs.Item href="#">Item</Breadcrumbs.Item>
55+
<Breadcrumbs.Item href="#">Details</Breadcrumbs.Item>
56+
<Breadcrumbs.Item href="#" selected>
57+
Current Page
58+
</Breadcrumbs.Item>
59+
</Breadcrumbs>
60+
)
61+
62+
export const OverflowMenuShowRoot = () => (
63+
<Breadcrumbs overflow="menu" hideRoot={false}>
64+
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
65+
<Breadcrumbs.Item href="#">Products</Breadcrumbs.Item>
66+
<Breadcrumbs.Item href="#">Category</Breadcrumbs.Item>
67+
<Breadcrumbs.Item href="#">Subcategory</Breadcrumbs.Item>
68+
<Breadcrumbs.Item href="#">Item</Breadcrumbs.Item>
69+
<Breadcrumbs.Item href="#">Details</Breadcrumbs.Item>
70+
<Breadcrumbs.Item href="#" selected>
71+
Current Page
72+
</Breadcrumbs.Item>
73+
</Breadcrumbs>
74+
)
75+
76+
export const OverflowMenuFewItems = () => (
77+
<Breadcrumbs overflow="menu">
78+
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
79+
<Breadcrumbs.Item href="#">About</Breadcrumbs.Item>
80+
<Breadcrumbs.Item href="#" selected>
81+
Team
82+
</Breadcrumbs.Item>
83+
</Breadcrumbs>
84+
)
85+
86+
export const OverflowMenuManyItems = () => (
87+
<Breadcrumbs overflow="menu">
88+
<Breadcrumbs.Item href="#">Home</Breadcrumbs.Item>
89+
<Breadcrumbs.Item href="#">Level 1</Breadcrumbs.Item>
90+
<Breadcrumbs.Item href="#">Level 2</Breadcrumbs.Item>
91+
<Breadcrumbs.Item href="#">Level 3</Breadcrumbs.Item>
92+
<Breadcrumbs.Item href="#">Level 4</Breadcrumbs.Item>
93+
<Breadcrumbs.Item href="#">Level 5</Breadcrumbs.Item>
94+
<Breadcrumbs.Item href="#">Level 6</Breadcrumbs.Item>
95+
<Breadcrumbs.Item href="#">Level 7</Breadcrumbs.Item>
96+
<Breadcrumbs.Item href="#">Level 8</Breadcrumbs.Item>
97+
<Breadcrumbs.Item href="#" selected>
98+
Current Page
99+
</Breadcrumbs.Item>
100+
</Breadcrumbs>
101+
)
102+
103+
export const OverflowMenuLongWords = () => (
104+
<Breadcrumbs overflow="menu">
105+
<Breadcrumbs.Item href="#">SupercalifragilisticexpialidociousRepository</Breadcrumbs.Item>
106+
<Breadcrumbs.Item href="#">AnticonstitutionnellementConfiguration</Breadcrumbs.Item>
107+
<Breadcrumbs.Item href="#">PneumonoultramicroscopicsilicovolcanoconiosisDocumentation</Breadcrumbs.Item>
108+
<Breadcrumbs.Item href="#" selected>
109+
HippopotomonstrosesquippedaliophobiaCurrentPage
110+
</Breadcrumbs.Item>
111+
</Breadcrumbs>
112+
)
113+
114+
export const OverflowMenuLongWordsHideRoot = () => (
115+
<Breadcrumbs overflow="menu" hideRoot={true}>
116+
<Breadcrumbs.Item href="#">SupercalifragilisticexpialidociousRepository</Breadcrumbs.Item>
117+
<Breadcrumbs.Item href="#">AnticonstitutionnellementConfiguration</Breadcrumbs.Item>
118+
<Breadcrumbs.Item href="#">PneumonoultramicroscopicsilicovolcanoconiosisDocumentation</Breadcrumbs.Item>
119+
<Breadcrumbs.Item href="#" selected>
120+
HippopotomonstrosesquippedaliophobiaCurrentPage
121+
</Breadcrumbs.Item>
122+
</Breadcrumbs>
123+
)
124+
125+
export const OverflowMenuLongWordsShowRoot = () => (
126+
<Breadcrumbs overflow="menu" hideRoot={false}>
127+
<Breadcrumbs.Item href="#">SupercalifragilisticexpialidociousRepository</Breadcrumbs.Item>
128+
<Breadcrumbs.Item href="#">AnticonstitutionnellementConfiguration</Breadcrumbs.Item>
129+
<Breadcrumbs.Item href="#">PneumonoultramicroscopicsilicovolcanoconiosisDocumentation</Breadcrumbs.Item>
130+
<Breadcrumbs.Item href="#" selected>
131+
HippopotomonstrosesquippedaliophobiaCurrentPage
132+
</Breadcrumbs.Item>
133+
</Breadcrumbs>
134+
)

packages/react/src/Breadcrumbs/Breadcrumbs.tsx

Lines changed: 195 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,219 @@
11
import {clsx} from 'clsx'
22
import type {To} from 'history'
3-
import React from 'react'
3+
import React, {useState, useRef, useCallback, useEffect} from 'react'
44
import type {SxProp} from '../sx'
55
import type {ComponentProps} from '../utils/types'
66
import classes from './Breadcrumbs.module.css'
77
import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
88
import {BoxWithFallback} from '../internal/components/BoxWithFallback'
9+
import {ActionMenu} from '../ActionMenu'
10+
import {ActionList} from '../ActionList'
11+
import {useResizeObserver} from '../hooks/useResizeObserver'
12+
import type {ResizeObserverEntry} from '../hooks/useResizeObserver'
913

1014
const SELECTED_CLASS = 'selected'
1115

1216
export type BreadcrumbsProps = React.PropsWithChildren<
1317
{
1418
className?: string
19+
overflow?: 'wrap' | 'menu'
20+
hideRoot?: boolean
1521
} & SxProp
1622
>
1723

1824
const BreadcrumbsList = ({children}: React.PropsWithChildren) => {
1925
return <ol className={classes.BreadcrumbsList}>{children}</ol>
2026
}
2127

22-
function Breadcrumbs({className, children, sx: sxProp}: BreadcrumbsProps) {
23-
const wrappedChildren = React.Children.map(children, child => <li className={classes.ItemWrapper}>{child}</li>)
28+
type BreadcrumbsMenuItemProps = {
29+
items: React.ReactElement[]
30+
'aria-label'?: string
31+
}
32+
33+
const BreadcrumbsMenuItem = React.forwardRef<HTMLButtonElement, BreadcrumbsMenuItemProps>(
34+
({items, 'aria-label': ariaLabel, ...rest}, ref) => {
35+
return (
36+
<ActionMenu>
37+
<ActionMenu.Button
38+
ref={ref}
39+
aria-label={ariaLabel || `${items.length} more items`}
40+
variant="invisible"
41+
style={{display: 'inline-flex'}}
42+
{...rest}
43+
>
44+
45+
</ActionMenu.Button>
46+
<ActionMenu.Overlay width="auto">
47+
<ActionList>
48+
{items.map((item, index) => {
49+
const href = item.props.href
50+
const children = item.props.children
51+
const selected = item.props.selected
52+
return (
53+
<ActionList.LinkItem
54+
key={index}
55+
href={href}
56+
aria-current={selected ? 'page' : undefined}
57+
className={selected ? classes.ItemSelected : undefined}
58+
>
59+
{children}
60+
</ActionList.LinkItem>
61+
)
62+
})}
63+
</ActionList>
64+
</ActionMenu.Overlay>
65+
</ActionMenu>
66+
)
67+
},
68+
)
69+
70+
BreadcrumbsMenuItem.displayName = 'Breadcrumbs.MenuItem'
71+
72+
function Breadcrumbs({className, children, sx: sxProp, overflow = 'wrap', hideRoot = true}: BreadcrumbsProps) {
73+
const containerRef = useRef<HTMLElement>(null)
74+
const [containerWidth, setContainerWidth] = useState<number>(0)
75+
const [visibleItems, setVisibleItems] = useState<React.ReactElement[]>([])
76+
const [menuItems, setMenuItems] = useState<React.ReactElement[]>([])
77+
const [itemWidths, setItemWidths] = useState<number[]>([])
78+
const previousWidthsRef = useRef<string>('')
79+
80+
const childArray = React.Children.toArray(children).filter(child =>
81+
React.isValidElement(child),
82+
) as React.ReactElement[]
83+
84+
// Initialize visible items to show all items initially for measurement
85+
useEffect(() => {
86+
if (visibleItems.length === 0 && childArray.length > 0) {
87+
setVisibleItems(childArray)
88+
}
89+
}, [childArray, visibleItems.length])
90+
91+
const handleResize = useCallback((entries: ResizeObserverEntry[]) => {
92+
if (entries[0]) {
93+
setContainerWidth(entries[0].contentRect.width)
94+
}
95+
}, [])
96+
97+
useResizeObserver(handleResize, containerRef)
98+
99+
// Calculate item widths from rendered items using parent container
100+
useEffect(() => {
101+
if (containerRef.current && overflow === 'menu') {
102+
const listElement = containerRef.current.querySelector('ol')
103+
if (listElement && listElement.children.length > 0) {
104+
// Only measure widths when all original items are visible (no overflow menu yet)
105+
if (listElement.children.length === childArray.length) {
106+
const widths = Array.from(listElement.children).map(child => (child as HTMLElement).offsetWidth)
107+
const widthsString = JSON.stringify(widths)
108+
// Only update if widths have actually changed to prevent infinite loops
109+
if (widthsString !== previousWidthsRef.current) {
110+
previousWidthsRef.current = widthsString
111+
setItemWidths(widths)
112+
}
113+
}
114+
}
115+
}
116+
}, [childArray, overflow, visibleItems])
117+
118+
// Calculate which items are visible vs in menu
119+
useEffect(() => {
120+
if (overflow === 'wrap') {
121+
setVisibleItems(childArray)
122+
setMenuItems([])
123+
return
124+
}
125+
126+
// For 'menu' overflow mode
127+
const lastItem = childArray[childArray.length - 1] // Leaf breadcrumb
128+
const firstItem = childArray[0] // Root breadcrumb
129+
130+
// First check: if more than 5 items, always use overflow
131+
if (childArray.length > 5) {
132+
if (hideRoot) {
133+
// Show only overflow menu and leaf breadcrumb
134+
const itemsToHide = childArray.slice(0, -1) // All except last
135+
setMenuItems(itemsToHide)
136+
setVisibleItems([lastItem])
137+
} else {
138+
// Show root breadcrumb, overflow menu, and leaf breadcrumb
139+
const itemsToHide = childArray.slice(1, -1) // All except first and last
140+
setMenuItems(itemsToHide)
141+
setVisibleItems([firstItem, lastItem])
142+
}
143+
return
144+
}
145+
146+
// Second check: if we have measured widths and container width, check if items fit
147+
if (containerWidth > 0 && itemWidths.length === childArray.length && itemWidths.length > 0) {
148+
const totalItemsWidth = itemWidths.reduce((sum, width) => sum + width, 0)
149+
// Add some buffer for the ellipsis menu button (approximately 50px)
150+
const bufferWidth = 50
151+
152+
if (totalItemsWidth + bufferWidth > containerWidth) {
153+
// Items don't fit, need to overflow
154+
if (hideRoot) {
155+
// Show only overflow menu and leaf breadcrumb
156+
const itemsToHide = childArray.slice(0, -1) // All except last
157+
setMenuItems(itemsToHide)
158+
setVisibleItems([lastItem])
159+
} else {
160+
// Show root breadcrumb, overflow menu, and leaf breadcrumb
161+
const itemsToHide = childArray.slice(1, -1) // All except first and last
162+
setMenuItems(itemsToHide)
163+
setVisibleItems([firstItem, lastItem])
164+
}
165+
return
166+
}
167+
}
168+
169+
// No overflow needed - show all items
170+
setVisibleItems(childArray)
171+
setMenuItems([])
172+
}, [childArray, overflow, containerWidth, hideRoot, itemWidths])
173+
174+
// Determine final children to render
175+
const finalChildren = React.useMemo(() => {
176+
if (overflow === 'wrap' || menuItems.length === 0) {
177+
return visibleItems.map(child => (
178+
<li className={classes.ItemWrapper} key={child.key}>
179+
{child}
180+
</li>
181+
))
182+
}
183+
184+
// Create menu item and combine with visible items
185+
const menuElement = (
186+
<li className={classes.ItemWrapper} key="breadcrumbs-menu">
187+
<BreadcrumbsMenuItem items={menuItems} aria-label={`${menuItems.length} more breadcrumb items`} />
188+
</li>
189+
)
190+
191+
const visibleElements = visibleItems.map(child => (
192+
<li className={classes.ItemWrapper} key={child.key}>
193+
{child}
194+
</li>
195+
))
196+
197+
// Position menu based on hideRoot setting and visible items
198+
if (hideRoot) {
199+
// Show: [overflow menu, leaf breadcrumb]
200+
return [menuElement, ...visibleElements]
201+
} else {
202+
// Show: [root breadcrumb, overflow menu, leaf breadcrumb]
203+
return [visibleElements[0], menuElement, ...visibleElements.slice(1)]
204+
}
205+
}, [overflow, menuItems, visibleItems, hideRoot])
206+
24207
return (
25-
<BoxWithFallback as="nav" className={clsx(className, classes.BreadcrumbsBase)} aria-label="Breadcrumbs" sx={sxProp}>
26-
<BreadcrumbsList>{wrappedChildren}</BreadcrumbsList>
208+
<BoxWithFallback
209+
as="nav"
210+
className={clsx(className, classes.BreadcrumbsBase)}
211+
aria-label="Breadcrumbs"
212+
sx={sxProp}
213+
ref={containerRef}
214+
data-overflow={overflow}
215+
>
216+
<BreadcrumbsList>{finalChildren}</BreadcrumbsList>
27217
</BoxWithFallback>
28218
)
29219
}

0 commit comments

Comments
 (0)