breadcrumbs
This commit is contained in:
@@ -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,59 +442,65 @@ const Sidebar = ({
|
|||||||
size={16}
|
size={16}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{tenantMenuOpen ? (
|
{tenantMenuOpen
|
||||||
<div
|
&& tenantMenuStyle
|
||||||
className={`menu${showTenantList ? '' : ' menu--simple'}`}
|
&& typeof document !== 'undefined'
|
||||||
ref={tenantMenuRef}
|
? createPortal(
|
||||||
role="menu"
|
<div
|
||||||
aria-label="Account menu"
|
className={`menu${showTenantList ? '' : ' menu--simple'}`}
|
||||||
>
|
ref={tenantMenuRef}
|
||||||
{showTenantList ? (
|
role="menu"
|
||||||
<>
|
aria-label="Account menu"
|
||||||
<div className="menu__heading">Switch tenant</div>
|
style={tenantMenuStyle}
|
||||||
<div className="menu__list">
|
>
|
||||||
{tenants.map((tenant) => {
|
{showTenantList ? (
|
||||||
const tenantId = tenant?.id || null;
|
<>
|
||||||
const isActive = tenantId === activeTenantId;
|
<div className="menu__heading">Switch tenant</div>
|
||||||
const tenantLabel = tenant?.name;
|
<div className="menu__list">
|
||||||
return (
|
{tenants.map((tenant) => {
|
||||||
<button
|
const tenantId = tenant?.id || null;
|
||||||
key={tenantId || tenantLabel}
|
const isActive = tenantId === activeTenantId;
|
||||||
type="button"
|
const tenantLabel = tenant?.name;
|
||||||
className={`menu__item${isActive ? ' active' : ''}`}
|
return (
|
||||||
onClick={() => handleTenantSelect(tenant)}
|
<button
|
||||||
role="menuitem"
|
key={tenantId || tenantLabel}
|
||||||
>
|
type="button"
|
||||||
<span className="menu__check-slot">
|
className={`menu__item${isActive ? ' active' : ''}`}
|
||||||
{isActive ? <CheckIcon size={16} /> : null}
|
onClick={() => handleTenantSelect(tenant)}
|
||||||
</span>
|
role="menuitem"
|
||||||
<span className="menu__label">{tenantLabel}</span>
|
>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</>
|
</div>,
|
||||||
) : null}
|
document.body,
|
||||||
<div className="menu__footer">
|
)
|
||||||
<button
|
: null}
|
||||||
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}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
actions={
|
actions={
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user