import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import clsx from 'clsx'; import { CursorEffect } from '@/components/cursor'; import { manrope } from '@/styles/fonts'; import styles from './selectMenu.module.scss'; type Option = { value: string; label: string }; type SelectMenuProps = { label: string; value: string; options: Option[]; placeholder: string; searchLabel: string; noResults: string; onChange: (value: string) => void; }; export function SelectMenu({ label, value, options, placeholder, searchLabel, noResults, onChange, }: SelectMenuProps) { const id = useId(); const triggerRef = useRef(null); const panelRef = useRef(null); const searchRef = useRef(null); const [position, setPosition] = useState<{ left: number; top?: number; bottom?: number; width: number; maxHeight: number; } | null>(null); const [query, setQuery] = useState(''); const [active, setActive] = useState(value); const searchable = options.length > 8; const filtered = useMemo(() => { const search = query.trim().toLocaleLowerCase(); return options.filter((option) => option.label.toLocaleLowerCase().includes(search) ); }, [query, options]); const activeIndex = Math.max( 0, filtered.findIndex((option) => option.value === active) ); const activeValue = filtered[activeIndex]?.value; const selected = options.find((option) => option.value === value); function close(restoreFocus = false) { setPosition(null); if (restoreFocus) triggerRef.current?.focus(); } function open() { const rect = triggerRef.current?.getBoundingClientRect(); if (!rect) return; const below = window.innerHeight - rect.bottom - 20; const above = rect.top - 20; const upwards = below < 280 && above > below; const maxHeight = Math.min(340, upwards ? above : below); setQuery(''); setActive(value); setPosition({ left: rect.left, ...(upwards ? { bottom: window.innerHeight - rect.top + 8 } : { top: rect.bottom + 8 }), width: rect.width, maxHeight, }); } useEffect(() => { if (!position) return; if (searchable) searchRef.current?.focus(); else panelRef.current?.querySelector('[role="listbox"]')?.focus(); const dismiss = (event: PointerEvent) => { const target = event.target as Node; if ( !panelRef.current?.contains(target) && !triggerRef.current?.contains(target) ) setPosition(null); }; const dismissOnScroll = (event: Event) => { if (!panelRef.current?.contains(event.target as Node)) setPosition(null); }; const dismissOnResize = () => setPosition(null); document.addEventListener('pointerdown', dismiss); window.addEventListener('scroll', dismissOnScroll, true); window.addEventListener('resize', dismissOnResize); return () => { document.removeEventListener('pointerdown', dismiss); window.removeEventListener('scroll', dismissOnScroll, true); window.removeEventListener('resize', dismissOnResize); }; }, [position, searchable]); useEffect(() => { if (position && activeValue) { document .getElementById(`${id}-option-${encodeURIComponent(activeValue)}`) ?.scrollIntoView({ block: 'nearest' }); } }, [id, position, activeValue]); function choose(nextValue: string) { onChange(nextValue); close(true); } return (
{label} {position && createPortal(
{ if ( event.relatedTarget && !event.currentTarget.contains(event.relatedTarget) ) close(); }} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); close(true); } else if (event.key === 'Tab') { close(true); } else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); const next = (activeIndex + (event.key === 'ArrowDown' ? 1 : -1) + filtered.length) % filtered.length; if (filtered[next]) setActive(filtered[next].value); } else if (event.key === 'Enter' && activeValue) { event.preventDefault(); choose(activeValue); } }} > {searchable ? ( { setQuery(event.target.value); setActive(''); }} /> ) : (

{placeholder}

)}
    {filtered.map((option) => (
  • ))}
{!filtered.length && (

{noResults}

)}
, document.body )}
); }