diff --git a/docs/pages/experiments/responsive-breadcrumbs.tsx b/docs/pages/experiments/responsive-breadcrumbs.tsx new file mode 100644 index 00000000000000..65154435e275f8 --- /dev/null +++ b/docs/pages/experiments/responsive-breadcrumbs.tsx @@ -0,0 +1,550 @@ +import * as React from 'react'; +import useEventCallback from '@mui/utils/useEventCallback'; +import useEnhancedEffect from '@mui/utils/useEnhancedEffect'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; +import Box from '@mui/material/Box'; +import Breadcrumbs, { breadcrumbsClasses } from '@mui/material/Breadcrumbs'; +import type { BreadcrumbsProps } from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import Container from '@mui/material/Container'; +import IconButton from '@mui/material/IconButton'; +import Link from '@mui/material/Link'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Paper from '@mui/material/Paper'; +import Slider from '@mui/material/Slider'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; +import { styled } from '@mui/material/styles'; +import { AppLayoutHead as Head } from '@mui/internal-core-docs/AppLayout'; +import { Link as DocsLink } from '@mui/internal-core-docs/Link'; + +// The
    wraps by default. Container collapsing needs a single row that can overflow. +// Applying this on the wrapper covers the real list and both mirrors at once. +const BreadcrumbsRow = styled('div')({ + position: 'relative', + minWidth: 0, + overflowX: 'auto', + [`& .${breadcrumbsClasses.ol}`]: { flexWrap: 'nowrap' }, + [`& .${breadcrumbsClasses.li}`]: { whiteSpace: 'nowrap' }, +}); + +// height: 0 + overflow: hidden keeps the mirror out of the scrollable overflow. +// Clipping is paint-only, so the items inside still measure at their natural width. +const Mirror = styled('div')({ + position: 'absolute', + top: 0, + left: 0, + height: 0, + width: 'max-content', + overflow: 'hidden', + visibility: 'hidden', + pointerEvents: 'none', +}); + +function outerWidth(element: Element) { + const style = window.getComputedStyle(element); + return ( + element.getBoundingClientRect().width + + parseFloat(style.marginLeft || '0') + + parseFloat(style.marginRight || '0') + ); +} + +function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0); +} + +export interface BreadcrumbsLayout { + collapsed: boolean; + hiddenCount: number; + visibleCount: number; +} + +export interface ResponsiveBreadcrumbsProps extends Omit { + /** + * Label for the button that opens the hidden items. + * @default 'Show hidden path' + */ + menuLabel?: string; + onLayoutChange?: (layout: BreadcrumbsLayout) => void; +} + +/** + * Collapses `` to fit its container, without patching the component. + * + * The built-in collapse is not used at all. `maxItems` is a switch, not a dial: above + * the threshold the component always renders the same three-slot layout, and its + * `expanded` state never resets. So this wrapper slices the children itself and passes + * a menu button as an ordinary child, the way the "condensed with menu" demo does. + */ +function ResponsiveBreadcrumbs(props: ResponsiveBreadcrumbsProps) { + const { + children, + itemsBeforeCollapse = 1, + itemsAfterCollapse = 1, + menuLabel = 'Show hidden path', + onLayoutChange, + sx, + ...other + } = props; + + const items = React.useMemo( + () => React.Children.toArray(children).filter(React.isValidElement), + [children], + ); + const count = items.length; + + // Widest to narrowest. `null` means "no menu, every item is visible". + const ladder = React.useMemo(() => { + const steps: (number | null)[] = [null]; + for (let after = count - 1 - itemsBeforeCollapse; after >= itemsAfterCollapse; after -= 1) { + steps.push(after); + } + return steps; + }, [count, itemsBeforeCollapse, itemsAfterCollapse]); + + const containerRef = React.useRef(null); + const fullRef = React.useRef(null); + const triggerRef = React.useRef(null); + const [step, setStep] = React.useState(0); + const [anchorEl, setAnchorEl] = React.useState(null); + + const update = useEventCallback(() => { + const container = containerRef.current; + const fullOl = fullRef.current?.querySelector('ol'); + const triggerItem = triggerRef.current?.querySelector('ol')?.firstElementChild; + + if (!container || !fullOl || !triggerItem) { + return; + } + + // The mirror alternates item, separator, item, ... so even indexes are items. + const itemWidths: number[] = []; + let separatorWidth = 0; + Array.from(fullOl.children).forEach((child, index) => { + if (index % 2 === 0) { + itemWidths.push(outerWidth(child)); + } else { + separatorWidth = Math.max(separatorWidth, outerWidth(child)); + } + }); + + const triggerWidth = outerWidth(triggerItem); + + // clientWidth is rounded to an integer, so it can hide a sub-pixel overflow. + // Derive the content box from the fractional rect instead. + // offsetWidth - clientWidth covers the borders plus any vertical scrollbar. + const containerStyle = window.getComputedStyle(container); + const available = + container.getBoundingClientRect().width - + (container.offsetWidth - container.clientWidth) - + parseFloat(containerStyle.paddingLeft || '0') - + parseFloat(containerStyle.paddingRight || '0'); + + const widthOf = (after: number | null) => { + if (after === null) { + return sum(itemWidths) + Math.max(count - 1, 0) * separatorWidth; + } + const visible = [ + ...itemWidths.slice(0, itemsBeforeCollapse), + ...itemWidths.slice(count - after), + ]; + const slots = itemsBeforeCollapse + 1 + after; + return sum(visible) + triggerWidth + (slots - 1) * separatorWidth; + }; + + // First step that fits wins; fall back to the narrowest one. + let next = ladder.length - 1; + for (let index = 0; index < ladder.length; index += 1) { + if (widthOf(ladder[index]) <= available) { + next = index; + break; + } + } + setStep(next); + }); + + useEnhancedEffect(() => { + const container = containerRef.current; + const fullOl = fullRef.current?.querySelector('ol'); + if (!container || !fullOl || typeof ResizeObserver === 'undefined') { + return undefined; + } + update(); + const observer = new ResizeObserver(update); + observer.observe(container); + // The mirror never collapses, so its width only changes when the labels or + // the fonts change. Observing it re-measures on both without a second hook. + observer.observe(fullOl); + return () => observer.disconnect(); + }, [update, ladder]); + + const after = ladder[Math.min(step, ladder.length - 1)] ?? null; + const hidden = after === null ? [] : items.slice(itemsBeforeCollapse, count - after); + + // The hidden set changed under the open menu, so the menu no longer describes it. + React.useEffect(() => { + setAnchorEl(null); + }, [after]); + + const reportLayout = useEventCallback((layout: BreadcrumbsLayout) => { + onLayoutChange?.(layout); + }); + + React.useEffect(() => { + const visibleCount = count - hidden.length; + reportLayout({ + collapsed: hidden.length > 0, + hiddenCount: hidden.length, + visibleCount, + }); + }, [count, hidden.length, reportLayout]); + + // Breadcrumbs rejects a Fragment child, so the trigger carries its own key. + const renderTrigger = (interactive: boolean) => ( + setAnchorEl(event.currentTarget) : undefined} + > + + + ); + + const visible = + after === null + ? items + : [ + ...items.slice(0, itemsBeforeCollapse), + renderTrigger(true), + ...items.slice(count - after), + ]; + + if (count === 0) { + return ( + + {children} + + ); + } + + return ( + + {/* Off-screen mirrors keep every item measurable while the real list is collapsed. */} + + + {children} + + + {renderTrigger(false)} + + + + + {visible} + + + setAnchorEl(null)} + // The scroll lock pads the body, which resizes the container and would + // immediately recompute the layout under the open menu. + disableScrollLock + > + {hidden.map((child, index) => { + const childProps = child.props as { href?: string; children?: React.ReactNode }; + // A children-based API hides the target, so the href has to be recovered + // from the child to keep the menu item a real link. + return childProps.href ? ( + // An cannot be a direct child of the menu
      , so keep a list item + // around it. Same wrapper the roving-focus experiment uses. +
    • + setAnchorEl(null)}> + {childProps.children} + +
    • + ) : ( + setAnchorEl(null)}> + {child} + + ); + })} +
    +
    + ); +} + +const SHORT_PATH = [ + 'Home', + 'Catalog', + 'Accessories', + 'Bags', + 'Backpacks', + 'Travel', + 'Carry-on', + 'Model 42', +]; + +const LONG_PATH = [ + 'Organization', + 'Engineering department', + 'Design systems guild', + 'Component library', + 'Material UI package', + 'Navigation components', + 'Breadcrumbs', +]; + +function renderPath(labels: string[]) { + return labels.map((label, index) => + index === labels.length - 1 ? ( + + {label} + + ) : ( + + {label} + + ), + ); +} + +interface WidthFrameProps { + children: React.ReactNode; + label: string; + width: number; +} + +function WidthFrame(props: WidthFrameProps) { + const { children, label, width } = props; + + return ( + + + {label} + + + {children} + + + ); +} + +function ComparisonExample() { + const [width, setWidth] = React.useState(720); + const [layout, setLayout] = React.useState({ + collapsed: false, + hiddenCount: 0, + visibleCount: SHORT_PATH.length, + }); + + return ( + + +
    + Container width drives the collapse + + Drag the slider. The top list drops one middle item at a time until the row fits, and + the dropped items move into the menu. Open the menu, then keep dragging: the row + recollapses every time, because there is no expanded state to get stuck in. + + + The bottom list is a plain <Breadcrumbs> at the same width for + comparison: it wraps to a second line instead of collapsing. + +
    + + + + Container width: {width}px + + setWidth(value as number)} + /> + + + + + {renderPath(SHORT_PATH)} + + + + + {renderPath(SHORT_PATH)} + +
    +
    + ); +} + +function LongLabelExample() { + const [width, setWidth] = React.useState(560); + + return ( + + +
    + Uneven label widths + + Item widths differ a lot here, so a count-based threshold cannot predict the fit. The + measurement pass uses real widths, so each step removes exactly as much as it needs to. + + + The second row shows the floor. itemsBeforeCollapse and{' '} + itemsAfterCollapse are a hard minimum, so below about 500px it cannot + shrink any further and the row scrolls instead. Only text truncation could go narrower. + +
    + + + + Container width: {width}px + + setWidth(value as number)} + /> + + + + {renderPath(LONG_PATH)} + + + + + {renderPath(LONG_PATH)} + + +
    +
    + ); +} + +function FluidExample() { + return ( + + +
    + Fluid container + + No slider here. Resize the browser window and the list reacts through a{' '} + ResizeObserver on the wrapper. + +
    + + {renderPath(LONG_PATH)} + +
    +
    + ); +} + +export default function ResponsiveBreadcrumbsExperiment() { + return ( + + + + + + + + Experiments + + + Breadcrumbs that collapse to fit their container + + + A userland wrapper around the published <Breadcrumbs>. It + measures the items once in an off-screen mirror, picks the widest layout that still + fits the container, and puts the dropped items in a menu. No change to the + component. + + + + + + + + + + + + + What the current API cannot do + + +
  1. + The built-in collapse cannot be reused. maxItems{' '} + is a switch, not a dial: above the threshold the component always renders{' '} + itemsBeforeCollapse + ellipsis + itemsAfterCollapse, and its{' '} + expanded state never resets. This wrapper bypasses it and slices + the children itself. +
  2. +
  3. + A children-based API makes the menu guesswork. The hidden items + are opaque nodes, so the wrapper has to read href off each child to + keep the menu item a real link, then wrap it in{' '} + <li role="none"> because an <a>{' '} + cannot be a direct child of the menu <ul>. A built-in version + would want structured items instead. +
  4. +
  5. + The floor can still overflow. Once{' '} + itemsBeforeCollapse + itemsAfterCollapse items remain, dropping + stops. A container narrower than that floor overflows, because nothing in the + current API truncates label text. +
  6. +
  7. + Measurement needs a duplicate DOM tree. Collapsed items leave + the DOM, so the wrapper renders every child twice. A built-in version could + measure the real list before it collapses. +
  8. +
  9. + The menu scroll lock fights the measurement. Opening a default{' '} + Menu pads the body, which resizes the container and recomputes the + layout under the open menu. It needs disableScrollLock. +
  10. +
  11. + + flexWrap: wrap is hardcoded on the ol. + {' '} + Every consumer of this pattern has to override it. +
  12. +
    +
    +
    +
    +
    +
    +
    + ); +}