310 lines
8.4 KiB
TypeScript
310 lines
8.4 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import type { MutableRefObject, CSSProperties } from 'react';
|
|
import { clamp } from '../utils/math';
|
|
import { DEFAULT_VIEWPORT_MARGIN } from '../constants/ui';
|
|
|
|
type PositionStrategy = 'fixed' | 'absolute' | (string & {});
|
|
|
|
interface FloatingMenuMetrics {
|
|
strategy: PositionStrategy;
|
|
top: number;
|
|
left: number;
|
|
minWidth?: number;
|
|
width?: number;
|
|
}
|
|
|
|
type FloatingMenuStyle = (CSSProperties & { '--floating-min-width'?: string }) | null;
|
|
|
|
const resolveViewportWidth = () => window.innerWidth || document.documentElement.clientWidth || 0;
|
|
|
|
const computeWidth = (anchorWidth: number, minWidth: number, matchAnchorWidth: boolean) => {
|
|
if (matchAnchorWidth) {
|
|
return Math.max(anchorWidth, minWidth);
|
|
}
|
|
return Math.max(minWidth || 0, anchorWidth || 0);
|
|
};
|
|
|
|
const formatStyle = (metrics: FloatingMenuMetrics | null): FloatingMenuStyle => {
|
|
if (!metrics) {
|
|
return null;
|
|
}
|
|
const style: FloatingMenuStyle = {
|
|
position: metrics.strategy === 'absolute' ? 'absolute' : 'fixed',
|
|
top: metrics.top,
|
|
left: metrics.left,
|
|
};
|
|
if (Number.isFinite(metrics.minWidth)) {
|
|
style['--floating-min-width'] = `${Math.max(metrics.minWidth ?? 0, 0)}px`;
|
|
}
|
|
if (metrics.width) {
|
|
style.width = metrics.width;
|
|
}
|
|
return style;
|
|
};
|
|
|
|
interface UseFloatingMenuOptions {
|
|
anchorRef?: MutableRefObject<HTMLElement | null>;
|
|
offset?: number;
|
|
minWidth?: number;
|
|
matchAnchorWidth?: boolean;
|
|
align?: 'start' | 'center' | 'end' | (string & {});
|
|
viewportMargin?: number;
|
|
onOpenChange?: (isOpen: boolean) => void;
|
|
positionStrategy?: PositionStrategy;
|
|
}
|
|
|
|
const useFloatingMenu = ({
|
|
anchorRef,
|
|
offset = 6,
|
|
minWidth = 0,
|
|
matchAnchorWidth = false,
|
|
align = 'start',
|
|
viewportMargin = DEFAULT_VIEWPORT_MARGIN,
|
|
onOpenChange,
|
|
positionStrategy = 'fixed',
|
|
}: UseFloatingMenuOptions = {}) => {
|
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
|
const [menuMetrics, setMenuMetrics] = useState<FloatingMenuMetrics | null>(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const onOpenChangeRef = useRef(onOpenChange);
|
|
|
|
useEffect(() => {
|
|
onOpenChangeRef.current = onOpenChange;
|
|
}, [onOpenChange]);
|
|
|
|
const updatePosition = useCallback(() => {
|
|
const anchor = anchorRef?.current;
|
|
if (!anchor) {
|
|
return false;
|
|
}
|
|
|
|
const rect = anchor.getBoundingClientRect();
|
|
const desiredWidth = computeWidth(rect.width, minWidth, matchAnchorWidth);
|
|
const menu = menuRef.current;
|
|
const measuredWidth = menu?.offsetWidth ?? desiredWidth;
|
|
const widthForAlignment = matchAnchorWidth ? desiredWidth : Math.max(desiredWidth, measuredWidth);
|
|
|
|
if (positionStrategy === 'absolute') {
|
|
const anchor = anchorRef?.current;
|
|
if (!anchor) {
|
|
return false;
|
|
}
|
|
const offsetParent = (menu && menu.offsetParent) || anchor.offsetParent || anchor.parentElement;
|
|
if (!offsetParent) {
|
|
// Fall back to fixed positioning if we cannot resolve a relative parent.
|
|
setMenuMetrics({
|
|
strategy: 'fixed',
|
|
top: rect.bottom + offset,
|
|
left: rect.left,
|
|
minWidth: desiredWidth,
|
|
width: matchAnchorWidth ? desiredWidth : undefined,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
let left;
|
|
if (align === 'end') {
|
|
left = anchor.offsetLeft + anchor.offsetWidth - widthForAlignment;
|
|
} else if (align === 'center') {
|
|
left = anchor.offsetLeft + anchor.offsetWidth / 2 - widthForAlignment / 2;
|
|
} else {
|
|
left = anchor.offsetLeft;
|
|
}
|
|
|
|
const top = anchor.offsetTop + anchor.offsetHeight + offset;
|
|
|
|
setMenuMetrics({
|
|
strategy: 'absolute',
|
|
top,
|
|
left,
|
|
minWidth: desiredWidth,
|
|
width: matchAnchorWidth ? desiredWidth : undefined,
|
|
});
|
|
return true;
|
|
}
|
|
|
|
const viewportWidth = resolveViewportWidth();
|
|
const viewportHeight = window.innerHeight || 0;
|
|
const safeMargin = viewportMargin ?? DEFAULT_VIEWPORT_MARGIN;
|
|
const menuHeight = menu?.offsetHeight ?? 0;
|
|
|
|
let left;
|
|
if (align === 'end') {
|
|
left = rect.right - widthForAlignment;
|
|
} else if (align === 'center') {
|
|
left = rect.left + rect.width / 2 - widthForAlignment / 2;
|
|
} else {
|
|
left = rect.left;
|
|
}
|
|
|
|
const maxLeft = viewportWidth > 0 ? viewportWidth - widthForAlignment - safeMargin : left;
|
|
const clampedLeft = viewportWidth > 0 ? clamp(left, safeMargin, Math.max(maxLeft, safeMargin)) : left;
|
|
|
|
let top = rect.bottom + offset;
|
|
if (viewportHeight > 0 && menuHeight > 0) {
|
|
const projectedBottom = top + menuHeight + safeMargin;
|
|
if (projectedBottom > viewportHeight) {
|
|
const upwardTop = rect.top - offset - menuHeight;
|
|
top = Math.max(upwardTop, safeMargin);
|
|
}
|
|
}
|
|
|
|
setMenuMetrics({
|
|
strategy: 'fixed',
|
|
top,
|
|
left: clampedLeft,
|
|
minWidth: desiredWidth,
|
|
width: matchAnchorWidth ? desiredWidth : undefined,
|
|
});
|
|
|
|
return true;
|
|
}, [
|
|
anchorRef,
|
|
align,
|
|
matchAnchorWidth,
|
|
minWidth,
|
|
offset,
|
|
positionStrategy,
|
|
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;
|
|
}
|
|
|
|
let ignoreFocusEvents = true;
|
|
const raf = window.requestAnimationFrame;
|
|
const rafId = raf
|
|
? raf(() => {
|
|
ignoreFocusEvents = false;
|
|
})
|
|
: null;
|
|
|
|
if (!anchorRef?.current) {
|
|
close();
|
|
return undefined;
|
|
}
|
|
|
|
const handlePointer = (event) => {
|
|
if (event.type === 'focusin' && ignoreFocusEvents) {
|
|
return;
|
|
}
|
|
const target = event.target;
|
|
// If the target is no longer in the document, it means it was unmounted
|
|
// (e.g. due to a re-render caused by the open action).
|
|
// In this case, we should ignore the event.
|
|
if (target instanceof Node && !document.contains(target)) {
|
|
return;
|
|
}
|
|
|
|
const menu = menuRef.current;
|
|
const anchor = anchorRef?.current;
|
|
|
|
if ((anchor && anchor.contains(target)) || (menu && menu.contains(target))) {
|
|
return;
|
|
}
|
|
close();
|
|
};
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'Escape') {
|
|
close();
|
|
}
|
|
};
|
|
|
|
// Delay adding listeners to avoid capturing the event that opened the menu
|
|
const timer = setTimeout(() => {
|
|
document.addEventListener('mousedown', handlePointer);
|
|
document.addEventListener('touchstart', handlePointer, { passive: true });
|
|
document.addEventListener('focusin', handlePointer);
|
|
document.addEventListener('keydown', handleKeyDown);
|
|
}, 0);
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
if (rafId != null) {
|
|
window.cancelAnimationFrame(rafId);
|
|
}
|
|
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;
|