|
1 | 1 | import {clsx} from 'clsx'
|
2 | 2 | import type {To} from 'history'
|
3 |
| -import React from 'react' |
| 3 | +import React, {useState, useRef, useCallback, useEffect} from 'react' |
4 | 4 | import type {SxProp} from '../sx'
|
5 | 5 | import type {ComponentProps} from '../utils/types'
|
6 | 6 | import classes from './Breadcrumbs.module.css'
|
7 | 7 | import type {ForwardRefComponent as PolymorphicForwardRefComponent} from '../utils/polymorphic'
|
8 | 8 | 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' |
9 | 13 |
|
10 | 14 | const SELECTED_CLASS = 'selected'
|
11 | 15 |
|
12 | 16 | export type BreadcrumbsProps = React.PropsWithChildren<
|
13 | 17 | {
|
14 | 18 | className?: string
|
| 19 | + overflow?: 'wrap' | 'menu' |
| 20 | + hideRoot?: boolean |
15 | 21 | } & SxProp
|
16 | 22 | >
|
17 | 23 |
|
18 | 24 | const BreadcrumbsList = ({children}: React.PropsWithChildren) => {
|
19 | 25 | return <ol className={classes.BreadcrumbsList}>{children}</ol>
|
20 | 26 | }
|
21 | 27 |
|
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 | + |
24 | 207 | 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> |
27 | 217 | </BoxWithFallback>
|
28 | 218 | )
|
29 | 219 | }
|
|
0 commit comments