Files
eranos/src/hooks/usePortalDropdown.ts
T
Mary Kate Fain 173f789242 Extract shared portal dropdown logic into usePortalDropdown hook
Both EmojiShortcodeAutocomplete and MentionAutocomplete had identical
logic for fixed viewport positioning with viewport-flip, scroll/resize
dismissal, and portal rendering. Extract into a shared hook to reduce
duplication and centralize the positioning behavior.
2026-04-10 12:50:05 -05:00

80 lines
3.0 KiB
TypeScript

import { useEffect, useCallback, type RefObject } from 'react';
import { createPortal } from 'react-dom';
interface DropdownPosition {
top: number;
left: number;
}
interface UsePortalDropdownOptions {
/** Ref to the textarea the dropdown is anchored to. */
textareaRef: RefObject<HTMLTextAreaElement | null>;
/** Whether the dropdown is currently visible. */
isOpen: boolean;
/** Callback to close the dropdown (e.g. on scroll/resize). */
onClose: () => void;
/** Max height of the dropdown in px (must match the CSS max-h value). */
dropdownHeight: number;
/** Width of the dropdown in px (must match the CSS width value). */
dropdownWidth?: number;
}
/**
* Computes fixed viewport coordinates for an autocomplete dropdown anchored
* to a caret position inside a textarea. The dropdown is positioned below
* the caret line, or flipped above if it would overflow the viewport bottom.
*
* Also dismisses the dropdown on scroll or resize, since fixed positioning
* would cause misalignment.
*
* Use `renderPortal` to render the dropdown as a portal to `document.body`
* so it escapes ancestor overflow clipping and CSS transform containing
* blocks (e.g. Radix Dialog).
*/
export function usePortalDropdown({
textareaRef,
isOpen,
onClose,
dropdownHeight,
dropdownWidth = 280,
}: UsePortalDropdownOptions) {
/** Compute fixed viewport position for the dropdown given a caret index. */
const computePosition = useCallback(
(caretCoords: { top: number; left: number }): DropdownPosition => {
const textarea = textareaRef.current;
if (!textarea) return { top: 0, left: 0 };
const lineHeight = parseFloat(window.getComputedStyle(textarea).lineHeight) || 20;
const rect = textarea.getBoundingClientRect();
const top = rect.top + caretCoords.top - textarea.scrollTop + lineHeight + 4;
const left = rect.left + Math.max(0, Math.min(caretCoords.left, textarea.clientWidth - dropdownWidth));
// If the dropdown would overflow the bottom of the viewport, flip above
const flippedTop = rect.top + caretCoords.top - textarea.scrollTop - dropdownHeight - 4;
const useFlipped = top + dropdownHeight > window.innerHeight && flippedTop > 0;
return {
top: useFlipped ? flippedTop : top,
left: Math.max(8, Math.min(left, window.innerWidth - dropdownWidth - 8)),
};
},
[textareaRef, dropdownHeight, dropdownWidth],
);
// Dismiss the dropdown when any ancestor scrolls or the window resizes,
// since fixed positioning would cause the dropdown to become misaligned.
useEffect(() => {
if (!isOpen) return;
const handleDismiss = () => onClose();
window.addEventListener('scroll', handleDismiss, true);
window.addEventListener('resize', handleDismiss);
return () => {
window.removeEventListener('scroll', handleDismiss, true);
window.removeEventListener('resize', handleDismiss);
};
}, [isOpen, onClose]);
return { computePosition, renderPortal: createPortal };
}