import { Check, CaretDown } from "@phosphor-icons/react"; import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent, 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 listboxId = useId(); 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: globalThis.KeyboardEvent) => { if (event.key === "Escape") { setOpen(false); buttonRef.current?.focus(); } }; 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]); const openAndFocusOption = (index: number) => { setOpen(true); window.requestAnimationFrame(() => { const optionButtons = menuRef.current?.querySelectorAll('[role="option"]'); optionButtons?.[Math.max(0, Math.min(index, options.length - 1))]?.focus(); }); }; const handleTriggerKeyDown = (event: KeyboardEvent) => { if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Home" && event.key !== "End") return; event.preventDefault(); const selectedIndex = Math.max(0, options.findIndex((option) => option.value === value)); openAndFocusOption(event.key === "End" ? options.length - 1 : event.key === "Home" ? 0 : selectedIndex); }; return (
{open && placement === "inline" ? ( ) : open ? ( createPortal( , document.body, ) ) : null}
); } type SelectOptionsProps = { menuRef: RefObject; id: string; options: readonly BottomControlSelectOption[]; value: TValue; className: string; style?: CSSProperties; "aria-label": string; onValueChange: (value: TValue) => void; setOpen: (open: boolean) => void; }; function SelectOptions({ menuRef, id, options, value, className, style, onValueChange, setOpen, ...props }: SelectOptionsProps) { const handleOptionKeyDown = (event: KeyboardEvent, index: number) => { const optionButtons = menuRef.current?.querySelectorAll('[role="option"]'); if (!optionButtons) return; if (event.key === "Escape") { event.preventDefault(); setOpen(false); return; } const nextIndex = event.key === "ArrowDown" ? (index + 1) % options.length : event.key === "ArrowUp" ? (index - 1 + options.length) % options.length : event.key === "Home" ? 0 : event.key === "End" ? options.length - 1 : undefined; if (nextIndex === undefined) return; event.preventDefault(); optionButtons[nextIndex]?.focus(); }; return (
{options.map((option, index) => { const selected = option.value === value; return ( ); })}
); }