typescript
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { MutableRefObject, CSSProperties } from 'react';
|
||||
import { clamp } from '../utils/math';
|
||||
|
||||
const DEFAULT_VIEWPORT_MARGIN = 8;
|
||||
|
||||
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 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 () => {
|
||||
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;
|
||||
Reference in New Issue
Block a user