import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import styles from './Select.module.css'; export type SelectOption = { value: string; label: string; disabled?: boolean; }; export type SelectProps = { value: string; options: readonly SelectOption[]; onChange: (value: string) => void; disabled?: boolean; ariaLabel?: string; /** Доп. класс на кнопку-триггер (ширина и т.п.). */ className?: string; placeholder?: string; }; type MenuPos = { left: number; top: number; width: number; maxHeight: number }; function Chevron({ open }: { open: boolean }) { return ( ); } export function Select({ value, options, onChange, disabled = false, ariaLabel, className, placeholder = '—', }: SelectProps) { const listId = useId(); const triggerRef = useRef(null); const menuRef = useRef(null); const [open, setOpen] = useState(false); const [menuPos, setMenuPos] = useState(null); const [activeIndex, setActiveIndex] = useState(0); const selected = useMemo(() => options.find((o) => o.value === value) ?? null, [options, value]); const enabledIndexes = useMemo( () => options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0), [options], ); const close = useCallback(() => { setOpen(false); setMenuPos(null); }, []); const layoutMenu = useCallback(() => { const trigger = triggerRef.current; if (!trigger) return; const r = trigger.getBoundingClientRect(); const pad = 8; const gap = 4; const preferredMax = Math.min(280, window.innerHeight * 0.5); const spaceBelow = window.innerHeight - r.bottom - pad; const spaceAbove = r.top - pad; const placeBelow = spaceBelow >= 120 || spaceBelow >= spaceAbove; const maxHeight = Math.max(96, Math.min(preferredMax, placeBelow ? spaceBelow - gap : spaceAbove - gap)); const top = placeBelow ? r.bottom + gap : Math.max(pad, r.top - gap - maxHeight); setMenuPos({ left: Math.max(pad, Math.min(r.left, window.innerWidth - r.width - pad)), top, width: r.width, maxHeight, }); }, []); const openMenu = useCallback(() => { if (disabled) return; const selectedIdx = options.findIndex((o) => o.value === value && !o.disabled); const fallback = enabledIndexes[0] ?? 0; setActiveIndex(selectedIdx >= 0 ? selectedIdx : fallback); setOpen(true); }, [disabled, enabledIndexes, options, value]); useLayoutEffect(() => { if (!open) return; layoutMenu(); }, [layoutMenu, open, options.length]); useEffect(() => { if (!open) return; const onWin = () => layoutMenu(); window.addEventListener('resize', onWin); window.addEventListener('scroll', onWin, true); return () => { window.removeEventListener('resize', onWin); window.removeEventListener('scroll', onWin, true); }; }, [layoutMenu, open]); useEffect(() => { if (!open) return; const onPointerDown = (e: PointerEvent) => { const t = e.target; if (!(t instanceof Node)) return; if (triggerRef.current?.contains(t)) return; if (menuRef.current?.contains(t)) return; close(); }; window.addEventListener('pointerdown', onPointerDown, true); return () => window.removeEventListener('pointerdown', onPointerDown, true); }, [close, open]); useEffect(() => { if (!open) return; const el = menuRef.current?.querySelector(`[data-select-index="${String(activeIndex)}"]`); el?.scrollIntoView({ block: 'nearest' }); }, [activeIndex, open]); const moveActive = (dir: 1 | -1) => { if (enabledIndexes.length === 0) return; const pos = enabledIndexes.indexOf(activeIndex); const nextPos = pos < 0 ? dir === 1 ? 0 : enabledIndexes.length - 1 : (pos + dir + enabledIndexes.length) % enabledIndexes.length; setActiveIndex(enabledIndexes[nextPos]!); }; const commitIndex = (index: number) => { const opt = options[index]; if (!opt || opt.disabled) return; onChange(opt.value); close(); triggerRef.current?.focus(); }; const onTriggerKeyDown = (e: React.KeyboardEvent) => { if (disabled) return; if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!open) { openMenu(); return; } if (e.key === 'ArrowDown') moveActive(1); else if (e.key === 'ArrowUp') moveActive(-1); else if (e.key === 'Enter' || e.key === ' ') commitIndex(activeIndex); } else if (e.key === 'Escape' && open) { e.preventDefault(); close(); } }; const onMenuKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); } else if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); commitIndex(activeIndex); } else if (e.key === 'Escape') { e.preventDefault(); close(); triggerRef.current?.focus(); } else if (e.key === 'Tab') { close(); } }; const triggerClass = [ styles.trigger, open ? styles.triggerOpen : '', className ?? '', ] .filter(Boolean) .join(' '); const menu = open && menuPos && typeof document !== 'undefined' ? createPortal(
{options.map((opt, index) => { const selectedOpt = opt.value === value; const active = index === activeIndex; return ( ); })}
, document.body, ) : null; return (
{menu}
); }