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 {
|
||||
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={
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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