import variables from 'config/variables'; import { memo, useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { createPortal } from 'react-dom'; import { MdExpandMore, MdCheck, MdRefresh, MdClose } from 'react-icons/md'; import { toast } from 'react-toastify'; import EventBus from 'utils/eventbus'; import './Dropdown.scss'; const Dropdown = memo((props) => { const [value, setValue] = useState(localStorage.getItem(props.name) || props.items[0]?.value); const [isOpen, setIsOpen] = useState(false); const [isClosing, setIsClosing] = useState(false); const [focusedIndex, setFocusedIndex] = useState(-1); const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 }); const [searchQuery, setSearchQuery] = useState(''); const containerRef = useRef(null); const controlRef = useRef(null); const menuRef = useRef(null); const optionsRef = useRef([]); const searchInputRef = useRef(null); const closeDropdown = useCallback(() => { setIsClosing(true); setTimeout(() => { setIsOpen(false); setIsClosing(false); setFocusedIndex(-1); setSearchQuery(''); }, 200); // Match animation duration }, []); useEffect(() => { const handleClickOutside = (event) => { if ( containerRef.current && !containerRef.current.contains(event.target) && menuRef.current && !menuRef.current.contains(event.target) ) { closeDropdown(); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [closeDropdown]); // Memoize items count to avoid unnecessary recalculations const itemsCount = useMemo(() => props.items.filter((i) => i !== null).length, [props.items] ); const calculatePosition = useCallback(() => { if (controlRef.current) { const rect = controlRef.current.getBoundingClientRect(); const gap = 4; const viewportHeight = window.innerHeight; // Estimate menu height (will be more accurate after first render) const estimatedMenuHeight = Math.min(itemsCount * 44, 250); // Calculate if dropdown would overflow bottom of viewport const spaceBelow = viewportHeight - rect.bottom - gap; const spaceAbove = rect.top - gap; // If not enough space below but more space above, flip to top const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow; return { top: shouldFlipUp ? rect.top - gap : rect.bottom + gap, left: rect.left, width: rect.width, maxHeight: shouldFlipUp ? Math.min(250, spaceAbove) : Math.min(250, spaceBelow), flipped: shouldFlipUp, }; } return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false }; }, [itemsCount]); const openDropdown = useCallback(() => { const position = calculatePosition(); setMenuPosition(position); setIsOpen(true); }, [calculatePosition]); // Update dropdown position on scroll or resize useEffect(() => { if (!isOpen) return; let rafId = null; const updatePosition = () => { if (rafId) window.cancelAnimationFrame(rafId); rafId = window.requestAnimationFrame(() => { const newPosition = calculatePosition(); setMenuPosition(newPosition); }); }; // Listen to window scroll and resize window.addEventListener('scroll', updatePosition, { passive: true }); window.addEventListener('resize', updatePosition, { passive: true }); // Find and listen to scrollable ancestors let element = controlRef.current?.parentElement; const scrollableElements = []; while (element) { const hasScrollableContent = element.scrollHeight > element.clientHeight; const overflowYStyle = window.getComputedStyle(element).overflowY; const isOverflowYScrollable = overflowYStyle !== 'visible' && overflowYStyle !== 'hidden'; if (hasScrollableContent && isOverflowYScrollable) { scrollableElements.push(element); element.addEventListener('scroll', updatePosition, { passive: true }); } element = element.parentElement; } return () => { if (rafId) window.cancelAnimationFrame(rafId); window.removeEventListener('scroll', updatePosition); window.removeEventListener('resize', updatePosition); scrollableElements.forEach(el => el.removeEventListener('scroll', updatePosition) ); }; }, [isOpen, calculatePosition]); useEffect(() => { if (isOpen && props.searchable && searchInputRef.current) { // Focus the search input when dropdown opens setTimeout(() => searchInputRef.current?.focus(), 0); } }, [isOpen, props.searchable]); const handleSearchChange = useCallback((e) => { setSearchQuery(e.target.value); if (!isOpen) { openDropdown(); } }, [isOpen, openDropdown]); const handleInputClick = useCallback((e) => { e.stopPropagation(); if (!isOpen) { openDropdown(); } }, [isOpen, openDropdown]); const handleInputFocus = useCallback(() => { // When focusing, if not default value, pre-fill with current value for editing const defaultValue = props.default || props.items[0]?.value; if (value !== defaultValue && !searchQuery) { const currentItem = props.items.find((item) => item?.value === value); const currentText = currentItem?.text || value; setSearchQuery(currentText); } }, [value, props.default, props.items, searchQuery]); const onChange = useCallback( (newValue) => { if (newValue === variables.getMessage('modals.main.loading')) { return; } variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`); setValue(newValue); closeDropdown(); if (!props.noSetting) { localStorage.setItem(props.name, newValue); localStorage.setItem(props.name2, props.value2); } if (props.onChange) { props.onChange(newValue); } if (props.element) { if (!document.querySelector(props.element)) { document.querySelector('.reminder-info').style.display = 'flex'; return localStorage.setItem('showReminder', true); } } EventBus.emit('refresh', props.category); }, [value, props], ); const handleKeyDown = useCallback( (e) => { if (props.disabled) return; switch (e.key) { case 'Enter': case ' ': e.preventDefault(); if (isOpen) { closeDropdown(); } else { openDropdown(); } break; case 'Escape': closeDropdown(); break; case 'ArrowDown': e.preventDefault(); if (!isOpen) { openDropdown(); } else { setFocusedIndex((prev) => prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev, ); } break; case 'ArrowUp': e.preventDefault(); if (isOpen) { setFocusedIndex((prev) => (prev > 0 ? prev - 1 : prev)); } break; } }, [isOpen, props.items, props.disabled, openDropdown, closeDropdown], ); const handleOptionKeyDown = useCallback( (e, item) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onChange(item.value); } }, [onChange], ); const resetItem = useCallback((e) => { e?.stopPropagation(); const defaultValue = props.default || props.items[0]?.value; onChange(defaultValue); toast(variables.getMessage('toasts.reset')); }, [onChange, props.default, props.items]); const clearSearch = useCallback((e) => { e.stopPropagation(); setSearchQuery(''); if (props.searchable) { // Reset to default value (first item, usually "Automatic") const defaultValue = props.default || props.items[0]?.value; onChange(defaultValue); } if (searchInputRef.current) { searchInputRef.current.focus(); } }, [props, onChange]); const id = 'dropdown' + props.name; const label = props.label || ''; const selectedItem = props.items.find((item) => item?.value === value); const defaultValue = props.default || props.items[0]?.value; // Filter items based on search query const filteredItems = props.searchable && searchQuery ? props.items.filter((item) => item !== null && item.text.toLowerCase().includes(searchQuery.toLowerCase()), ) : props.items; return (
e.stopPropagation()}> {label && (
{variables.getMessage('modals.main.settings.buttons.reset')}
)}
{ e.stopPropagation(); if (props.disabled) return; if (isOpen) { closeDropdown(); } else { openDropdown(); } }} onKeyDown={handleKeyDown} role="button" aria-haspopup="listbox" aria-expanded={isOpen} aria-label={label || props.name} tabIndex={props.disabled ? -1 : 0} > {props.searchable ? ( <> { if (e.key === 'Escape') { if (searchQuery) { setSearchQuery(''); } else { closeDropdown(); } } if (e.key === ' ') { e.stopPropagation(); } }} /> {(searchQuery || value !== (props.default || props.items[0]?.value)) && ( )} ) : ( {selectedItem?.text || value} )}
{(isOpen || isClosing) && createPortal(
{filteredItems.map((item, index) => item !== null ? (
(optionsRef.current[index] = el)} className={`dropdown-option ${value === item.value ? 'selected' : ''} ${index === focusedIndex ? 'focused' : ''}`} onClick={() => onChange(item.value)} onKeyDown={(e) => handleOptionKeyDown(e, item)} role="option" aria-selected={value === item.value} tabIndex={0} > {item.text} {item.value === defaultValue && ( {' '} ({variables.getMessage('modals.main.settings.buttons.default')}) )} {value === item.value && }
) : null, )}
, document.body, )}
); }); Dropdown.displayName = 'Dropdown'; export { Dropdown as default, Dropdown };