import { Check, CaretDown } from "@phosphor-icons/react"; import { useEffect, useRef, useState, type CSSProperties, type RefObject } from "react"; import { createPortal } from "react-dom"; export type BottomControlSelectOption = { value: TValue; label: string; }; export type BottomControlSelectMenuProps = { value: TValue; options: readonly BottomControlSelectOption[]; "aria-label": string; label?: string; placement?: "top" | "bottom" | "inline"; onValueChange: (value: TValue) => void; }; export function BottomControlSelectMenu({ value, options, label, placement = "top", onValueChange, ...props }: BottomControlSelectMenuProps) { const rootRef = useRef(null); const buttonRef = useRef(null); const menuRef = useRef(null); const [open, setOpen] = useState(false); const [menuStyle, setMenuStyle] = useState(); const selectedOption = options.find((option) => option.value === value) ?? options[0]; useEffect(() => { if (!open) return; const updateMenuPosition = () => { if (placement === "inline") return; const rect = buttonRef.current?.getBoundingClientRect(); if (!rect) return; const gap = 8; const maxHeight = Math.max(160, placement === "bottom" ? window.innerHeight - rect.bottom - gap * 2 : rect.top - gap * 2); setMenuStyle({ position: "fixed", left: rect.left, top: placement === "bottom" ? rect.bottom + gap : undefined, bottom: placement === "top" ? window.innerHeight - rect.top + gap : undefined, width: Math.max(rect.width, 192), maxHeight, }); }; updateMenuPosition(); const handlePointerDown = (event: PointerEvent) => { const target = event.target as Node; if (!rootRef.current?.contains(target) && !menuRef.current?.contains(target)) setOpen(false); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") setOpen(false); }; window.addEventListener("pointerdown", handlePointerDown); window.addEventListener("keydown", handleKeyDown); window.addEventListener("resize", updateMenuPosition); window.addEventListener("scroll", updateMenuPosition, true); return () => { window.removeEventListener("pointerdown", handlePointerDown); window.removeEventListener("keydown", handleKeyDown); window.removeEventListener("resize", updateMenuPosition); window.removeEventListener("scroll", updateMenuPosition, true); }; }, [open, placement]); return (
{open && placement === "inline" ? ( ) : open ? ( createPortal( , document.body, ) ) : null}
); } type SelectOptionsProps = { menuRef: RefObject; options: readonly BottomControlSelectOption[]; value: TValue; className: string; style?: CSSProperties; "aria-label": string; onValueChange: (value: TValue) => void; setOpen: (open: boolean) => void; }; function SelectOptions({ menuRef, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps) { return (
{options.map((option) => { const selected = option.value === value; return ( ); })}
); }