97 lines
2.9 KiB
TypeScript
97 lines
2.9 KiB
TypeScript
'use client';
|
|
|
|
import { useLayoutEffect, useRef, useState, type CSSProperties, type RefObject } from 'react';
|
|
|
|
const VIEWPORT_PADDING = 12;
|
|
const DEFAULT_GAP = 8;
|
|
|
|
export type AnchoredPanelAlign = 'start' | 'stretch';
|
|
|
|
type UseAnchoredPanelPositionArgs = {
|
|
open: boolean;
|
|
/** Element the panel should attach to (field / trigger shell). */
|
|
anchorRef: RefObject<HTMLElement | null>;
|
|
/** Prefer aligning the panel's inline-start with the anchor's inline-start. */
|
|
align?: AnchoredPanelAlign;
|
|
gap?: number;
|
|
};
|
|
|
|
/**
|
|
* Positions a panel with `position: fixed`, clamped to the viewport.
|
|
* Flips above the anchor when there is not enough room below.
|
|
* Handles LTR/RTL via `document.documentElement.dir`.
|
|
*/
|
|
export function useAnchoredPanelPosition({
|
|
open,
|
|
anchorRef,
|
|
align = 'start',
|
|
gap = DEFAULT_GAP,
|
|
}: UseAnchoredPanelPositionArgs) {
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
const [style, setStyle] = useState<CSSProperties | undefined>(undefined);
|
|
|
|
useLayoutEffect(() => {
|
|
if (!open) {
|
|
setStyle(undefined);
|
|
return;
|
|
}
|
|
|
|
function update() {
|
|
const anchor = anchorRef.current;
|
|
const panel = panelRef.current;
|
|
if (!anchor || !panel) return;
|
|
|
|
const anchorRect = anchor.getBoundingClientRect();
|
|
const panelRect = panel.getBoundingClientRect();
|
|
const vw = window.innerWidth;
|
|
const vh = window.innerHeight;
|
|
const isRtl = document.documentElement.dir === 'rtl';
|
|
|
|
let width =
|
|
align === 'stretch'
|
|
? Math.min(anchorRect.width, vw - VIEWPORT_PADDING * 2)
|
|
: Math.min(panelRect.width || panel.offsetWidth, vw - VIEWPORT_PADDING * 2);
|
|
|
|
if (width < 1) width = Math.min(280, vw - VIEWPORT_PADDING * 2);
|
|
|
|
let left: number;
|
|
if (align === 'stretch') {
|
|
left = anchorRect.left;
|
|
} else if (isRtl) {
|
|
left = anchorRect.right - width;
|
|
} else {
|
|
left = anchorRect.left;
|
|
}
|
|
left = Math.max(VIEWPORT_PADDING, Math.min(left, vw - width - VIEWPORT_PADDING));
|
|
|
|
const height = panelRect.height || panel.offsetHeight;
|
|
let top = anchorRect.bottom + gap;
|
|
if (top + height > vh - VIEWPORT_PADDING) {
|
|
const above = anchorRect.top - height - gap;
|
|
top = above >= VIEWPORT_PADDING ? above : Math.max(VIEWPORT_PADDING, vh - height - VIEWPORT_PADDING);
|
|
}
|
|
|
|
setStyle({
|
|
position: 'fixed',
|
|
top,
|
|
left,
|
|
width,
|
|
zIndex: 50,
|
|
});
|
|
}
|
|
|
|
update();
|
|
// Second pass after fonts/layout settle panel size.
|
|
const raf = window.requestAnimationFrame(update);
|
|
window.addEventListener('resize', update);
|
|
window.addEventListener('scroll', update, true);
|
|
return () => {
|
|
window.cancelAnimationFrame(raf);
|
|
window.removeEventListener('resize', update);
|
|
window.removeEventListener('scroll', update, true);
|
|
};
|
|
}, [align, anchorRef, gap, open]);
|
|
|
|
return { panelRef, style };
|
|
}
|