import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { cn, zIndex } from "@/shared"; export interface TicketViewportSelectOption { value: string; label: string; disabled?: boolean; } export interface TicketViewportSelectProps { ariaLabel?: string; "aria-label"?: string; value: string; options: TicketViewportSelectOption[]; onChange: (value: string) => void; className?: string; disabled?: boolean; } interface PopupPosition { top: number; left: number; width: number; maxHeight: number; placement: "top" | "bottom"; } const VIEWPORT_MARGIN = 8; const MIN_POPUP_HEIGHT = 96; const MAX_POPUP_HEIGHT = 280; export function TicketViewportSelect({ ariaLabel: ariaLabelProp, "aria-label": ariaLabelAttribute, value, options, onChange, className, disabled = false, }: TicketViewportSelectProps) { const triggerRef = useRef(null); const popupRef = useRef(null); const [open, setOpen] = useState(false); const [position, setPosition] = useState(null); const selected = options.find((option) => option.value === value) ?? options[0]; const ariaLabel = ariaLabelAttribute ?? ariaLabelProp ?? "select option"; const updatePosition = useCallback(() => { const trigger = triggerRef.current; if (!trigger) return; const rect = trigger.getBoundingClientRect(); const viewportWidth = window.innerWidth || document.documentElement.clientWidth; const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const popupWidth = Math.max(rect.width, 160); const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN; const spaceAbove = rect.top - VIEWPORT_MARGIN; const placement = spaceBelow < MIN_POPUP_HEIGHT && spaceAbove > spaceBelow ? "top" : "bottom"; const available = Math.max( MIN_POPUP_HEIGHT, placement === "top" ? spaceAbove : spaceBelow, ); const maxHeight = Math.min(MAX_POPUP_HEIGHT, available); const left = Math.min( Math.max(VIEWPORT_MARGIN, rect.left), Math.max(VIEWPORT_MARGIN, viewportWidth - popupWidth - VIEWPORT_MARGIN), ); const top = placement === "top" ? Math.max(VIEWPORT_MARGIN, rect.top - maxHeight - 4) : Math.min(viewportHeight - VIEWPORT_MARGIN, rect.bottom + 4); setPosition({ top, left, width: popupWidth, maxHeight, placement }); }, []); useLayoutEffect(() => { if (!open) return; updatePosition(); }, [open, updatePosition]); useEffect(() => { if (!open) return; function onPointerDown(event: PointerEvent) { const target = event.target as Node | null; if ( target && (triggerRef.current?.contains(target) || popupRef.current?.contains(target)) ) { return; } setOpen(false); } function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); setOpen(false); triggerRef.current?.focus(); } } const onViewportChange = () => { updatePosition(); }; document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); window.addEventListener("resize", onViewportChange); window.addEventListener("scroll", onViewportChange, true); window.visualViewport?.addEventListener("resize", onViewportChange); window.visualViewport?.addEventListener("scroll", onViewportChange); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); window.removeEventListener("resize", onViewportChange); window.removeEventListener("scroll", onViewportChange, true); window.visualViewport?.removeEventListener("resize", onViewportChange); window.visualViewport?.removeEventListener("scroll", onViewportChange); }; }, [open, updatePosition]); return ( <> {open && position && createPortal(
{options.map((option) => ( ))}
, document.body, )} ); }