typescript

This commit is contained in:
2025-11-13 01:07:55 +01:00
parent b812d748ea
commit ada089c05b
147 changed files with 7427 additions and 2534 deletions
+296
View File
@@ -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;