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
+85 -89
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 {
ChevronIcon,
TrashIcon,
@@ -17,6 +18,7 @@ import {
DesktopIcon,
} from '../ui/icons';
import PanelHeader from '../ui/PanelHeader';
import useFloatingMenu from '../ui/useFloatingMenu';
import { getTagColorStyle } from '../utils/colors';
@@ -282,44 +284,32 @@ const Sidebar = ({
onSearchClear?.();
}, [onSearchClear]);
const tenantButtonRef = useRef(null);
const tenantMenuRef = useRef(null);
const [tenantMenuOpen, setTenantMenuOpen] = useState(false);
const {
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 toggleTenantMenu = useCallback(() => {
const next = !tenantMenuOpen;
setTenantMenuOpen(next);
if (next && tenants.length === 0 && onSelectTenant) {
if (!tenantMenuOpen && tenants.length === 0 && onSelectTenant) {
onSelectTenant(null, { refreshOnly: true });
}
}, [tenantMenuOpen, tenants.length, onSelectTenant]);
toggleTenantMenuFloating();
}, [tenantMenuOpen, tenants.length, onSelectTenant, toggleTenantMenuFloating]);
useEffect(() => {
if (!tenantMenuOpen) {
return undefined;
if (tenantMenuOpen) {
refreshTenantMenuPosition();
}
const handlePointer = (event) => {
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]);
}, [tenantMenuOpen, refreshTenantMenuPosition, tenants.length]);
const handleTenantSelect = useCallback(
(tenant) => {
@@ -327,21 +317,21 @@ const Sidebar = ({
if (!targetId) {
return;
}
setTenantMenuOpen(false);
closeTenantMenu();
onSelectTenant?.(tenant);
},
[onSelectTenant],
[closeTenantMenu, onSelectTenant],
);
const handleLogoutFromMenu = useCallback(() => {
setTenantMenuOpen(false);
closeTenantMenu();
onLogout?.();
}, [onLogout]);
}, [closeTenantMenu, onLogout]);
const handleSettingsFromMenu = useCallback(() => {
setTenantMenuOpen(false);
closeTenantMenu();
onOpenSettings?.();
}, [onOpenSettings]);
}, [closeTenantMenu, onOpenSettings]);
const renderNodes = useCallback(
(ids, depth) =>
@@ -452,59 +442,65 @@ const Sidebar = ({
size={16}
/>
</button>
{tenantMenuOpen ? (
<div
className={`menu${showTenantList ? '' : ' menu--simple'}`}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
>
{showTenantList ? (
<>
<div className="menu__heading">Switch tenant</div>
<div className="menu__list">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name;
return (
<button
key={tenantId || tenantLabel}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
{tenantMenuOpen
&& tenantMenuStyle
&& typeof document !== 'undefined'
? createPortal(
<div
className={`menu${showTenantList ? '' : ' menu--simple'}`}
ref={tenantMenuRef}
role="menu"
aria-label="Account menu"
style={tenantMenuStyle}
>
{showTenantList ? (
<>
<div className="menu__heading">Switch tenant</div>
<div className="menu__list">
{tenants.map((tenant) => {
const tenantId = tenant?.id || null;
const isActive = tenantId === activeTenantId;
const tenantLabel = tenant?.name;
return (
<button
key={tenantId || tenantLabel}
type="button"
className={`menu__item${isActive ? ' active' : ''}`}
onClick={() => handleTenantSelect(tenant)}
role="menuitem"
>
<span className="menu__check-slot">
{isActive ? <CheckIcon size={16} /> : null}
</span>
<span className="menu__label">{tenantLabel}</span>
</button>
);
})}
</div>
</>
) : null}
<div className="menu__footer">
<button
type="button"
className="menu__settings"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</>
) : null}
<div className="menu__footer">
<button
type="button"
className="menu__settings"
onClick={handleSettingsFromMenu}
>
<SettingsIcon size={16} />
Settings
</button>
<button
type="button"
className="menu__logout"
onClick={handleLogoutFromMenu}
>
<LogoutIcon size={16} />
Log out
</button>
</div>
</div>
) : null}
</div>,
document.body,
)
: null}
</>
)}
actions={
+1 -4
View File
@@ -306,10 +306,6 @@ button[disabled] {
cursor: not-allowed;
}
button:hover:not([disabled]) {
background: var(--accent-hover);
}
a.button-link {
display: inline-flex;
align-items: center;
@@ -2422,6 +2418,7 @@ button.danger:hover:not([disabled]) {
align-items: center;
gap: 0.5rem;
overflow: hidden;
text-wrap: nowrap;
}
.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 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}
+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;