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
+36 -40
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState, useId } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useId } from 'react';
import { createPortal } from 'react-dom';
import { import {
ChevronIcon, ChevronIcon,
TrashIcon, TrashIcon,
@@ -17,6 +18,7 @@ import {
DesktopIcon, DesktopIcon,
} from '../ui/icons'; } from '../ui/icons';
import PanelHeader from '../ui/PanelHeader'; import PanelHeader from '../ui/PanelHeader';
import useFloatingMenu from '../ui/useFloatingMenu';
import { getTagColorStyle } from '../utils/colors'; import { getTagColorStyle } from '../utils/colors';
@@ -282,44 +284,32 @@ const Sidebar = ({
onSearchClear?.(); onSearchClear?.();
}, [onSearchClear]); }, [onSearchClear]);
const tenantButtonRef = useRef(null); const tenantButtonRef = useRef(null);
const tenantMenuRef = useRef(null); const {
const [tenantMenuOpen, setTenantMenuOpen] = useState(false); isOpen: tenantMenuOpen,
toggle: toggleTenantMenuFloating,
close: closeTenantMenu,
menuRef: tenantMenuRef,
menuStyle: tenantMenuStyle,
updatePosition: refreshTenantMenuPosition,
} = useFloatingMenu({
anchorRef: tenantButtonRef,
minWidth: 220,
offset: 6,
});
const showTenantList = tenants.length > 1; const showTenantList = tenants.length > 1;
const toggleTenantMenu = useCallback(() => { const toggleTenantMenu = useCallback(() => {
const next = !tenantMenuOpen; if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) {
setTenantMenuOpen(next);
if (next && tenants.length === 0 && onSelectTenant) {
onSelectTenant(null, { refreshOnly: true }); onSelectTenant(null, { refreshOnly: true });
} }
}, [tenantMenuOpen, tenants.length, onSelectTenant]); toggleTenantMenuFloating();
}, [tenantMenuOpen, tenants.length, onSelectTenant, toggleTenantMenuFloating]);
useEffect(() => { useEffect(() => {
if (!tenantMenuOpen) { if (tenantMenuOpen) {
return undefined; refreshTenantMenuPosition();
} }
const handlePointer = (event) => { }, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]);
const menuNode = tenantMenuRef.current;
const buttonNode = tenantButtonRef.current;
if (!menuNode) return;
if (menuNode.contains(event.target)) return;
if (buttonNode && buttonNode.contains(event.target)) return;
setTenantMenuOpen(false);
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
setTenantMenuOpen(false);
}
};
document.addEventListener('mousedown', handlePointer);
document.addEventListener('touchstart', handlePointer);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('touchstart', handlePointer);
document.removeEventListener('keydown', handleKeyDown);
};
}, [tenantMenuOpen]);
const handleTenantSelect = useCallback( const handleTenantSelect = useCallback(
(tenant) => { (tenant) => {
@@ -327,21 +317,21 @@ const Sidebar = ({
if (!targetId) { if (!targetId) {
return; return;
} }
setTenantMenuOpen(false); closeTenantMenu();
onSelectTenant?.(tenant); onSelectTenant?.(tenant);
}, },
[onSelectTenant], [closeTenantMenu, onSelectTenant],
); );
const handleLogoutFromMenu = useCallback(() => { const handleLogoutFromMenu = useCallback(() => {
setTenantMenuOpen(false); closeTenantMenu();
onLogout?.(); onLogout?.();
}, [onLogout]); }, [closeTenantMenu, onLogout]);
const handleSettingsFromMenu = useCallback(() => { const handleSettingsFromMenu = useCallback(() => {
setTenantMenuOpen(false); closeTenantMenu();
onOpenSettings?.(); onOpenSettings?.();
}, [onOpenSettings]); }, [closeTenantMenu, onOpenSettings]);
const renderNodes = useCallback( const renderNodes = useCallback(
(ids, depth) => (ids, depth) =>
@@ -452,12 +442,16 @@ const Sidebar = ({
size={16} size={16}
/> />
</button> </button>
{tenantMenuOpen ? ( {tenantMenuOpen
&& tenantMenuStyle
&& typeof document !== 'undefined'
? createPortal(
<div <div
className={`menu${showTenantList ? '' : ' menu--simple'}`} className={`menu${showTenantList ? '' : ' menu--simple'}`}
ref={tenantMenuRef} ref={tenantMenuRef}
role="menu" role="menu"
aria-label="Account menu" aria-label="Account menu"
style={tenantMenuStyle}
> >
{showTenantList ? ( {showTenantList ? (
<> <>
@@ -503,8 +497,10 @@ const Sidebar = ({
Log out Log out
</button> </button>
</div> </div>
</div> </div>,
) : null} document.body,
)
: null}
</> </>
)} )}
actions={ actions={
+1 -4
View File
@@ -306,10 +306,6 @@ button[disabled] {
cursor: not-allowed; cursor: not-allowed;
} }
button:hover:not([disabled]) {
background: var(--accent-hover);
}
a.button-link { a.button-link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -2422,6 +2418,7 @@ button.danger:hover:not([disabled]) {
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
overflow: hidden; overflow: hidden;
text-wrap: nowrap;
} }
.panel-header > .panel-header__title:first-child { .panel-header > .panel-header__title:first-child {
+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 { createPortal } from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import useFloatingMenu from './useFloatingMenu';
const normalizeEntries = (entries) => const normalizeEntries = (entries) =>
(Array.isArray(entries) ? entries : []) (Array.isArray(entries) ? entries : [])
@@ -16,23 +23,34 @@ const normalizeEntries = (entries) =>
.filter(Boolean); .filter(Boolean);
const ELLIPSIS = { id: '__breadcrumbs_ellipsis__', label: '…', onClick: null, raw: null }; 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 BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
const normalized = useMemo(() => normalizeEntries(entries), [entries]); const normalized = useMemo(() => normalizeEntries(entries), [entries]);
const containerRef = useRef(null); const containerRef = useRef(null);
const measurementRef = useRef(null); const measurementRef = useRef(null);
const ellipsisRef = useRef(null); const ellipsisButtonRef = useRef(null);
const menuRef = useRef(null);
const [availableWidth, setAvailableWidth] = useState(null); const [availableWidth, setAvailableWidth] = useState(null);
const [startIndex, setStartIndex] = useState(0); 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(() => { useEffect(() => {
setStartIndex(0); setStartIndex(0);
setShowHiddenMenu(false); closeEllipsisMenu();
setMenuPosition(null); }, [normalized, closeEllipsisMenu]);
}, [normalized]);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
@@ -123,13 +141,13 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
return; return;
} }
const TOLERANCE = 1;
// Reserve a tiny buffer so the live trail doesn't oscillate when the // Reserve a tiny buffer so the live trail doesn't oscillate when the
// container width barely fits; shrink the measured allowance a bit. // container width barely fits; shrink the measured allowance by 1%.
const adjustedAvailable = available * 0.98; const adjustedAvailable = available * WIDTH_BUFFER_RATIO;
let nextStart = widths.length - 1; let nextStart = widths.length - 1;
for (let start = 0; start < widths.length; start += 1) { for (let start = 0; start < widths.length; start += 1) {
if (widths[start] <= adjustedAvailable + TOLERANCE) { if (widths[start] <= adjustedAvailable + WIDTH_TOLERANCE) {
nextStart = start; nextStart = start;
break; break;
} }
@@ -140,119 +158,42 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
} }
}, [normalized, separator, availableWidth, startIndex]); }, [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 sliceStart = Math.min(startIndex, Math.max(0, normalized.length - 1));
const slice = normalized.slice(sliceStart); const slice = normalized.slice(sliceStart);
const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice; const visibleEntries = sliceStart > 0 ? [ELLIPSIS, ...slice] : slice;
const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : []; const hiddenEntries = sliceStart > 0 ? normalized.slice(0, sliceStart) : [];
const hasHiddenEntries = hiddenEntries.length > 0; 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 const wrapperClassName = className
? `breadcrumb-trail ${className}`.trim() ? `breadcrumb-trail ${className}`.trim()
: 'breadcrumb-trail'; : 'breadcrumb-trail';
useEffect(() => {
if (!hasHiddenEntries && showHiddenMenu) {
setShowHiddenMenu(false);
setMenuPosition(null);
}
}, [hasHiddenEntries, showHiddenMenu]);
return ( return (
<> <>
<span ref={containerRef} className={wrapperClassName}> <span ref={containerRef} className={wrapperClassName}>
{visibleEntries.map((entry, index) => { {visibleEntries.map((entry, index) => {
const isEllipsis = entry.id === ELLIPSIS.id; const isEllipsis = entry.id === ELLIPSIS.id;
const isLast = index === visibleEntries.length - 1; 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) { if (isEllipsis) {
return ( return (
@@ -262,47 +203,19 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
{separator} {separator}
</span> </span>
) : null} ) : null}
<span className="breadcrumb-trail__ellipsis" ref={ellipsisRef}> <span className="breadcrumb-trail__ellipsis">
<button <button
ref={ellipsisButtonRef}
type="button" type="button"
className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button" className="breadcrumb-trail__link breadcrumb-trail__ellipsis-button"
aria-haspopup="menu" aria-haspopup="menu"
aria-expanded={showHiddenMenu} aria-expanded={ellipsisMenuOpen}
onClick={() => { onClick={() => {
if (!hasHiddenEntries) { if (!hasHiddenEntries) {
setShowHiddenMenu(false); closeEllipsisMenu();
setMenuPosition(null);
return; return;
} }
toggleEllipsisMenu();
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);
}} }}
title="Show parent folders" 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 const content = !entry.onClick || isLast
? ( ? (
<span key={`${entry.id}-label`} {...commonProps}> <span key={`${entry.id}-label`} {...commonProps}>
@@ -375,18 +294,16 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
</React.Fragment> </React.Fragment>
))} ))}
</span> </span>
{showHiddenMenu && hasHiddenEntries && menuPosition && typeof document !== 'undefined' {ellipsisMenuOpen
&& hasHiddenEntries
&& ellipsisMenuStyle
&& typeof document !== 'undefined'
? createPortal( ? createPortal(
<div <div
className="menu menu--floating" className="menu menu--floating"
role="menu" role="menu"
style={{ ref={ellipsisMenuRef}
position: 'fixed', style={ellipsisMenuStyle}
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
minWidth: `${menuPosition.minWidth}px`,
}}
ref={menuRef}
> >
<div className="menu__list"> <div className="menu__list">
{hiddenEntries.map((hiddenEntry) => ( {hiddenEntries.map((hiddenEntry) => (
@@ -396,8 +313,7 @@ const BreadcrumbTrail = ({ entries = [], className = '', separator = '/' }) => {
className="menu__item" className="menu__item"
role="menuitem" role="menuitem"
onClick={() => { onClick={() => {
setShowHiddenMenu(false); closeEllipsisMenu();
setMenuPosition(null);
hiddenEntry.onClick?.(hiddenEntry.raw ?? hiddenEntry); hiddenEntry.onClick?.(hiddenEntry.raw ?? hiddenEntry);
}} }}
disabled={!hiddenEntry.onClick} disabled={!hiddenEntry.onClick}
+207
View File
@@ -0,0 +1,207 @@
import { useCallback, useEffect, useRef, useState } from 'react';
const DEFAULT_VIEWPORT_MARGIN = 8;
const clamp = (value, min, max) => Math.min(Math.max(value, min), max);
const resolveViewportWidth = () => {
if (typeof window !== 'undefined' && typeof window.innerWidth === 'number') {
return window.innerWidth;
}
if (typeof document !== 'undefined' && document.documentElement) {
return document.documentElement.clientWidth;
}
return 0;
};
const computeWidth = (anchorWidth, minWidth, matchAnchorWidth) => {
if (matchAnchorWidth) {
return Math.max(anchorWidth, minWidth);
}
return Math.max(minWidth || 0, anchorWidth || 0);
};
const formatStyle = (metrics) => {
if (!metrics) {
return null;
}
const style = {
position: 'fixed',
top: metrics.top,
left: metrics.left,
minWidth: metrics.minWidth,
};
if (metrics.width) {
style.width = metrics.width;
}
return style;
};
const useFloatingMenu = ({
anchorRef,
offset = 6,
minWidth = 0,
matchAnchorWidth = false,
align = 'start',
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
onOpenChange,
} = {}) => {
const menuRef = useRef(null);
const [menuMetrics, setMenuMetrics] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const onOpenChangeRef = useRef(onOpenChange);
useEffect(() => {
onOpenChangeRef.current = onOpenChange;
}, [onOpenChange]);
const updatePosition = useCallback(() => {
const anchor = anchorRef?.current;
if (!anchor || typeof window === 'undefined') {
return false;
}
const rect = anchor.getBoundingClientRect();
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
const viewportWidth = resolveViewportWidth();
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
let left;
if (align === 'end') {
left = rect.right - desiredWidth;
} else if (align === 'center') {
left = rect.left + rect.width / 2 - desiredWidth / 2;
} else {
left = rect.left;
}
const maxLeft = viewportWidth > 0
? viewportWidth - desiredWidth - safeMargin
: left;
const clampedLeft = viewportWidth > 0
? clamp(left, safeMargin, Math.max(maxLeft, safeMargin))
: left;
const top = rect.bottom + offset;
setMenuMetrics({
top,
left: clampedLeft,
minWidth: desiredWidth,
width: matchAnchorWidth ? desiredWidth : undefined,
});
return true;
}, [anchorRef, align, matchAnchorWidth, minWidth, offset, viewportMargin]);
const close = useCallback(() => {
setIsOpen((prev) => {
if (!prev) {
return prev;
}
onOpenChangeRef.current?.(false);
return false;
});
}, []);
const open = useCallback(() => {
setIsOpen((prev) => {
if (prev) {
return prev;
}
const positioned = updatePosition();
if (!positioned) {
onOpenChangeRef.current?.(false);
return prev;
}
onOpenChangeRef.current?.(true);
return true;
});
}, [updatePosition]);
const toggle = useCallback(() => {
setIsOpen((prev) => {
if (prev) {
onOpenChangeRef.current?.(false);
return false;
}
const positioned = updatePosition();
if (!positioned) {
onOpenChangeRef.current?.(false);
return prev;
}
onOpenChangeRef.current?.(true);
return true;
});
}, [updatePosition]);
useEffect(() => {
if (!isOpen) {
return undefined;
}
if (!anchorRef?.current) {
close();
return undefined;
}
const handlePointer = (event) => {
const menu = menuRef.current;
const anchor = anchorRef?.current;
if ((anchor && anchor.contains(event.target)) || (menu && menu.contains(event.target))) {
return;
}
close();
};
const handleKeyDown = (event) => {
if (event.key === 'Escape') {
close();
}
};
document.addEventListener('mousedown', handlePointer);
document.addEventListener('touchstart', handlePointer, { passive: true });
document.addEventListener('focusin', handlePointer);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('touchstart', handlePointer);
document.removeEventListener('focusin', handlePointer);
document.removeEventListener('keydown', handleKeyDown);
};
}, [anchorRef, close, isOpen]);
useEffect(() => {
if (!isOpen) {
return undefined;
}
const handleRelayout = () => {
const positioned = updatePosition();
if (!positioned) {
close();
}
};
handleRelayout();
window.addEventListener('resize', handleRelayout);
window.addEventListener('scroll', handleRelayout, true);
return () => {
window.removeEventListener('resize', handleRelayout);
window.removeEventListener('scroll', handleRelayout, true);
};
}, [close, isOpen, updatePosition]);
return {
isOpen,
open,
close,
toggle,
menuRef,
menuStyle: formatStyle(menuMetrics),
updatePosition,
};
};
export default useFloatingMenu;