breadcrumbs
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user