import React, { useEffect, useLayoutEffect, useMemo, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; import PropTypes from 'prop-types'; import useFloatingMenu from './useFloatingMenu'; const normalizeEntries = (entries) => (Array.isArray(entries) ? entries : []) .map((entry, index) => { if (!entry) { return null; } const id = entry.id ?? entry.value ?? index; const label = entry.label ?? entry.name ?? entry.title ?? ''; const onClick = typeof entry.onClick === 'function' ? entry.onClick : null; return label ? { id, label, onClick, raw: entry } : null; }) .filter(Boolean); const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null }; const WIDTH_TOLERANCE = 1; const WIDTH_BUFFER_RATIO = 0.99; const BreadcrumbTrail = ({ entries = [], className = '', separator = '/', truncateFromStart = true, }) => { const normalized = useMemo(() => normalizeEntries(entries), [entries]); const shouldTruncateFromStart = truncateFromStart !== false; const measurementEntries = useMemo( () => (shouldTruncateFromStart ? normalized : normalized.slice().reverse()), [normalized, shouldTruncateFromStart], ); const containerRef = useRef(null); const measurementRef = useRef(null); const ellipsisButtonRef = useRef(null); const [availableWidth, setAvailableWidth] = useState(null); const [startIndex, setStartIndex] = useState(0); const { isOpen: ellipsisMenuOpen, toggle: toggleEllipsisMenu, close: closeEllipsisMenu, menuRef: ellipsisMenuRef, menuStyle: ellipsisMenuStyle, updatePosition: refreshEllipsisMenuPosition, } = useFloatingMenu({ anchorRef: ellipsisButtonRef, minWidth: 192, offset: 6, }); useEffect(() => { setStartIndex(0); closeEllipsisMenu(); }, [normalized, shouldTruncateFromStart, closeEllipsisMenu]); useEffect(() => { const container = containerRef.current; if (!container || typeof ResizeObserver === 'undefined') { return undefined; } const updateWidth = () => { const host = containerRef.current; if (!host) { return; } const nextWidth = host.getBoundingClientRect().width; if (!nextWidth) { return; } setAvailableWidth((prev) => (prev && Math.abs(prev - nextWidth) < 0.5 ? prev : nextWidth)); }; updateWidth(); const observer = new ResizeObserver(updateWidth); observer.observe(container.parentElement || container); return () => observer.disconnect(); }, []); useLayoutEffect(() => { if (!measurementEntries.length) { return; } const container = containerRef.current; const measurement = measurementRef.current; if (!container || !measurement) { return; } const entryNodes = Array.from(measurement.querySelectorAll('[data-item-type="entry"]')); if (!entryNodes.length) { return; } const separatorNodes = Array.from(measurement.querySelectorAll('[data-item-type="separator"]')); const ellipsisNode = measurement.querySelector('[data-item-type="ellipsis"]'); const originalEntryDisplay = entryNodes.map((node) => node.style.display); const originalSeparatorDisplay = separatorNodes.map((node) => node.style.display); const originalEllipsisDisplay = ellipsisNode ? ellipsisNode.style.display : null; const widths = []; for (let start = 0; start < entryNodes.length; start += 1) { entryNodes.forEach((node, index) => { // Hide entries that fall before the visible window. // eslint-disable-next-line no-param-reassign node.style.display = index < start ? 'none' : ''; }); separatorNodes.forEach((node) => { const targetIndex = Number(node.getAttribute('data-target-index')); // eslint-disable-next-line no-param-reassign node.style.display = targetIndex < Math.max(start, 1) ? 'none' : ''; }); if (ellipsisNode) { // eslint-disable-next-line no-param-reassign ellipsisNode.style.display = start > 0 ? '' : 'none'; } widths[start] = measurement.getBoundingClientRect().width; } entryNodes.forEach((node, index) => { // eslint-disable-next-line no-param-reassign node.style.display = originalEntryDisplay[index] ?? ''; }); separatorNodes.forEach((node, index) => { // eslint-disable-next-line no-param-reassign node.style.display = originalSeparatorDisplay[index] ?? ''; }); if (ellipsisNode) { // eslint-disable-next-line no-param-reassign ellipsisNode.style.display = originalEllipsisDisplay ?? 'none'; } const available = availableWidth ?? container.getBoundingClientRect().width; if (!available || !widths.length) { return; } // Reserve a tiny buffer so the live trail doesn't oscillate when the // container width barely fits; shrink the measured allowance by 1%. const adjustedAvailable = available * WIDTH_BUFFER_RATIO; let nextStart = widths.length - 1; for (let start = 0; start < widths.length; start += 1) { if (widths[start] <= adjustedAvailable + WIDTH_TOLERANCE) { nextStart = start; break; } } if (nextStart !== startIndex) { setStartIndex(nextStart); } }, [measurementEntries, separator, availableWidth, startIndex]); const trimmedCount = Math.min(startIndex, Math.max(0, normalized.length - 1)); const visibleEntries = shouldTruncateFromStart ? normalized.slice(trimmedCount) : normalized.slice(0, Math.max(normalized.length - trimmedCount, 1)); const hiddenEntries = trimmedCount === 0 ? [] : shouldTruncateFromStart ? normalized.slice(0, trimmedCount) : normalized.slice(-trimmedCount); const hasHiddenEntries = hiddenEntries.length > 0; const ellipsisPlacement = shouldTruncateFromStart ? 'start' : 'end'; const displayEntries = hasHiddenEntries ? ellipsisPlacement === 'start' ? [ELLIPSIS, ...visibleEntries] : [...visibleEntries, ELLIPSIS] : visibleEntries; useEffect(() => { if (!hasHiddenEntries) { closeEllipsisMenu(); return; } if (ellipsisMenuOpen) { refreshEllipsisMenuPosition(); } }, [ hasHiddenEntries, ellipsisMenuOpen, closeEllipsisMenu, refreshEllipsisMenuPosition, hiddenEntries.length, ]); if (!normalized.length) { return null; } const wrapperClassName = className ? `breadcrumb-trail ${className}`.trim() : 'breadcrumb-trail'; return ( <> {displayEntries.map((entry, index) => { const isEllipsis = entry.id === ELLIPSIS.id; const isLast = index === displayEntries.length - 1; if (isEllipsis) { return ( {index > 0 ? ( ) : null} ); } const commonProps = { className: `breadcrumb-trail__link${isLast ? ' is-current' : ''}`, title: entry.label, 'aria-current': isLast ? 'page' : undefined, }; const content = !entry.onClick || isLast ? ( {entry.label} ) : ( ); return ( {index > 0 ? ( ) : null} {content} ); })} {ellipsisMenuOpen && hasHiddenEntries && ellipsisMenuStyle && typeof document !== 'undefined' ? createPortal(
{hiddenEntries.map((hiddenEntry) => ( ))}
, document.body, ) : null} ); }; const breadcrumbEntryShape = PropTypes.shape({ id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), name: PropTypes.string, label: PropTypes.string, title: PropTypes.string, onClick: PropTypes.func, }); BreadcrumbTrail.propTypes = { entries: PropTypes.arrayOf(breadcrumbEntryShape), className: PropTypes.string, separator: PropTypes.string, truncateFromStart: PropTypes.bool, }; export default BreadcrumbTrail;