diff --git a/src/components/ui/Select.module.scss b/src/components/ui/Select.module.scss index 93f1f03..7a3bd32 100644 --- a/src/components/ui/Select.module.scss +++ b/src/components/ui/Select.module.scss @@ -67,11 +67,7 @@ } .dropdown { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 1000; + position: fixed; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: $radius-lg; @@ -83,6 +79,7 @@ max-height: 240px; overflow-y: auto; overscroll-behavior: contain; + scrollbar-gutter: stable; } .option { diff --git a/src/components/ui/Select.tsx b/src/components/ui/Select.tsx index 3bd9e91..9c36515 100644 --- a/src/components/ui/Select.tsx +++ b/src/components/ui/Select.tsx @@ -1,4 +1,14 @@ -import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; +import { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties +} from 'react'; +import { createPortal } from 'react-dom'; import { IconChevronDown } from './icons'; import styles from './Select.module.scss'; @@ -21,6 +31,51 @@ interface SelectProps { id?: string; } +const VIEWPORT_MARGIN = 8; +const DROPDOWN_OFFSET = 6; +const DROPDOWN_MAX_HEIGHT = 240; +const DROPDOWN_Z_INDEX = 2010; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const resolveDropdownStyle = (element: HTMLElement): CSSProperties => { + const rect = element.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2)); + const left = clamp( + rect.left, + VIEWPORT_MARGIN, + Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN) + ); + const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET; + const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET; + const direction = + spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'; + const maxHeight = Math.max( + 0, + Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove) + ); + + return direction === 'down' + ? { + position: 'fixed', + top: rect.bottom + DROPDOWN_OFFSET, + left, + width, + maxHeight, + zIndex: DROPDOWN_Z_INDEX + } + : { + position: 'fixed', + bottom: viewportHeight - rect.top + DROPDOWN_OFFSET, + left, + width, + maxHeight, + zIndex: DROPDOWN_Z_INDEX + }; +}; + export function Select({ value, options, @@ -40,17 +95,78 @@ export function Select({ const [open, setOpen] = useState(false); const [highlightedIndex, setHighlightedIndex] = useState(-1); const wrapRef = useRef(null); + const dropdownRef = useRef(null); + const rafRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState(null); + const isOpen = open && !disabled; useEffect(() => { if (!open || disabled) return; const handleClickOutside = (event: MouseEvent) => { - if (!wrapRef.current?.contains(event.target as Node)) setOpen(false); + const target = event.target as Node; + if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return; + setOpen(false); }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [disabled, open]); - const isOpen = open && !disabled; + const updateDropdownStyle = useCallback(() => { + if (!wrapRef.current) return; + setDropdownStyle(resolveDropdownStyle(wrapRef.current)); + }, []); + + const scheduleDropdownStyleUpdate = useCallback(() => { + if (typeof window === 'undefined') return; + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + } + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + updateDropdownStyle(); + }); + }, [updateDropdownStyle]); + + useLayoutEffect(() => { + if (!isOpen) { + if (rafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + updateDropdownStyle(); + + const handleViewportChange = () => { + scheduleDropdownStyleUpdate(); + }; + + const resizeObserver = + typeof ResizeObserver !== 'undefined' && wrapRef.current + ? new ResizeObserver(() => { + scheduleDropdownStyleUpdate(); + }) + : null; + + if (resizeObserver && wrapRef.current) { + resizeObserver.observe(wrapRef.current); + } + + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange, true); + + return () => { + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange, true); + resizeObserver?.disconnect(); + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]); + const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]); const resolvedHighlightedIndex = highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1; @@ -136,60 +252,80 @@ export function Select({ [commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex] ); + useEffect(() => { + if (!isOpen || resolvedHighlightedIndex < 0) return; + const highlightedOption = document.getElementById(`${selectId}-option-${resolvedHighlightedIndex}`); + highlightedOption?.scrollIntoView({ block: 'nearest' }); + }, [isOpen, resolvedHighlightedIndex, selectId]); + + const dropdown = + isOpen && dropdownStyle + ? ( +
+ {options.map((opt, index) => { + const active = opt.value === value; + const highlighted = index === resolvedHighlightedIndex; + return ( + + ); + })} +
+ ) + : null; + return ( -
- - {isOpen && ( -
- {options.map((opt, index) => { - const active = opt.value === value; - const highlighted = index === resolvedHighlightedIndex; - return ( - - ); - })} -
- )} -
+ + + {dropdown && (typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))} + ); }