breadcrumbs

This commit is contained in:
2025-10-31 17:43:00 +01:00
parent 1a1e9a80c3
commit 1fcd265eb9
4 changed files with 361 additions and 245 deletions
+68 -152
View File
@@ -1,6 +1,13 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
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 : [])
@@ -16,23 +23,34 @@ const normalizeEntries = (entries) =>
.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 = '/' }) => {
const normalized = useMemo(() => normalizeEntries(entries), [entries]);
const containerRef = useRef(null);
const measurementRef = useRef(null);
const ellipsisRef = useRef(null);
const menuRef = useRef(null);
const ellipsisButtonRef = useRef(null);
const [availableWidth, setAvailableWidth] = useState(null);
const [startIndex, setStartIndex] = useState(0);
const [showHiddenMenu, setShowHiddenMenu] = useState(false);
const [menuPosition, setMenuPosition] = useState(null);
const {
isOpen: ellipsisMenuOpen,
toggle: toggleEllipsisMenu,
close: closeEllipsisMenu,
menuRef: ellipsisMenuRef,
menuStyle: ellipsisMenuStyle,
updatePosition: refreshEllipsisMenuPosition,
} = useFloatingMenu({
anchorRef: ellipsisButtonRef,
minWidth: 192,
offset: 6,
});
useEffect(() => {
setStartIndex(0);
setShowHiddenMenu(false);
setMenuPosition(null);
}, [normalized]);
closeEllipsisMenu();
}, [normalized, closeEllipsisMenu]);
useEffect(() => {
const container = containerRef.current;
@@ -123,13 +141,13 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
return;
}
const TOLERANCE = 1;
// Reserve a tiny buffer so the live trail doesn't oscillate when the
// container width barely fits; shrink the measured allowance a bit.
const adjustedAvailable = available * 0.98;
// 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 + TOLERANCE) {
if (widths[start] <= adjustedAvailable + WIDTH_TOLERANCE) {
nextStart = start;
break;
}
@@ -140,119 +158,42 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
}
}, [normalized, separator, availableWidth, startIndex]);
useEffect(() => {
if (!showHiddenMenu) {
return undefined;
}
if (typeof document === 'undefined') {
setShowHiddenMenu(false);
setMenuPosition(null);
return undefined;
}
const handleGlobalInteraction = (event) => {
const ellipsisEl = ellipsisRef.current;
const menuEl = menuRef.current;
if ((ellipsisEl && ellipsisEl.contains(event.target))
|| (menuEl && menuEl.contains(event.target))) {
return;
}
setShowHiddenMenu(false);
setMenuPosition(null);
};
const handleKey = (event) => {
if (event.key === 'Escape') {
setShowHiddenMenu(false);
setMenuPosition(null);
}
};
document.addEventListener('mousedown', handleGlobalInteraction);
document.addEventListener('touchstart', handleGlobalInteraction, { passive: true });
document.addEventListener('focusin', handleGlobalInteraction);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handleGlobalInteraction);
document.removeEventListener('touchstart', handleGlobalInteraction);
document.removeEventListener('focusin', handleGlobalInteraction);
document.removeEventListener('keydown', handleKey);
};
}, [showHiddenMenu]);
useEffect(() => {
if (!showHiddenMenu) {
return undefined;
}
if (typeof window === 'undefined') {
setShowHiddenMenu(false);
setMenuPosition(null);
return undefined;
}
const handleWindowChange = () => {
if (!ellipsisRef.current) {
setShowHiddenMenu(false);
setMenuPosition(null);
return;
}
const rect = ellipsisRef.current.getBoundingClientRect();
const menuWidth = 192;
const viewportWidth = window.innerWidth;
const left = Math.min(Math.max(rect.left, 8), Math.max(viewportWidth - menuWidth - 8, 8));
setMenuPosition({
top: rect.bottom + 6,
left,
minWidth: menuWidth,
});
};
handleWindowChange();
window.addEventListener('resize', handleWindowChange);
window.addEventListener('scroll', handleWindowChange, true);
return () => {
window.removeEventListener('resize', handleWindowChange);
window.removeEventListener('scroll', handleWindowChange, true);
};
}, [showHiddenMenu]);
if (!normalized.length) {
return null;
}
const sliceStart = Math.min(startIndex, Math.max(0, normalized.length - 1));
const slice = normalized.slice(sliceStart);
const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice;
const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : [];
const hasHiddenEntries = hiddenEntries.length > 0;
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';
useEffect(() => {
if (!hasHiddenEntries && showHiddenMenu) {
setShowHiddenMenu(false);
setMenuPosition(null);
}
}, [hasHiddenEntries, showHiddenMenu]);
return (
<>
<span ref={containerRef} className={wrapperClassName}>
{visibleEntries.map((entry, index) => {
const isEllipsis = entry.id === ELLIPSIS.id;
const isLast = index === visibleEntries.length - 1;
const commonProps = {
className: `breadcrumb-trail__link${isLast && !isEllipsis ? ' is-current' : ''}`,
title: entry.label,
'aria-current': isLast && !isEllipsis ? 'page' : undefined,
};
if (isEllipsis) {
return (
@@ -262,47 +203,19 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
{separator}
</span>
) : null}
<span className="breadcrumb-trail__ellipsis" ref={ellipsisRef}>
<span className="breadcrumb-trail__ellipsis">
<button
ref={ellipsisButtonRef}
type="button"
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
aria-haspopup="menu"
aria-expanded={showHiddenMenu}
aria-expanded={ellipsisMenuOpen}
onClick={() => {
if (!hasHiddenEntries) {
setShowHiddenMenu(false);
setMenuPosition(null);
closeEllipsisMenu();
return;
}
if (showHiddenMenu) {
setShowHiddenMenu(false);
setMenuPosition(null);
return;
}
if (!ellipsisRef.current) {
setShowHiddenMenu(false);
setMenuPosition(null);
return;
}
const rect = ellipsisRef.current.getBoundingClientRect();
const menuWidth = 192;
const viewportWidth = typeof window !== 'undefined'
? Math.max(window.innerWidth, menuWidth)
: menuWidth;
const left = Math.min(
Math.max(rect.left, 8),
Math.max(viewportWidth - menuWidth - 8, 8),
);
setMenuPosition({
top: rect.bottom + 6,
left,
minWidth: menuWidth,
});
setShowHiddenMenu(true);
toggleEllipsisMenu();
}}
title="Show parent folders"
>
@@ -313,6 +226,12 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
);
}
const commonProps = {
className: `breadcrumb-trail__link${isLast ? ' is-current' : ''}`,
title: entry.label,
'aria-current': isLast ? 'page' : undefined,
};
const content = !entry.onClick || isLast
? (
<span key={`${entry.id}-label`} {...commonProps}>
@@ -375,18 +294,16 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
</React.Fragment>
))}
</span>
{showHiddenMenu && hasHiddenEntries && menuPosition && typeof document !== 'undefined'
{ellipsisMenuOpen
&& hasHiddenEntries
&& ellipsisMenuStyle
&& typeof document !== 'undefined'
? createPortal(
<div
className="menu menu--floating"
role="menu"
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
minWidth: `${menuPosition.minWidth}px`,
}}
ref={menuRef}
ref={ellipsisMenuRef}
style={ellipsisMenuStyle}
>
<div className="menu__list">
{hiddenEntries.map((hiddenEntry) => (
@@ -396,8 +313,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
className="menu__item"
role="menuitem"
onClick={() => {
setShowHiddenMenu(false);
setMenuPosition(null);
closeEllipsisMenu();
hiddenEntry.onClick?.(hiddenEntry.raw ?? hiddenEntry);
}}
disabled={!hiddenEntry.onClick}