Files
mue/src/components/Form/Settings/Dropdown/Dropdown.jsx

389 lines
12 KiB
JavaScript

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 (
<div className={`dropdown ${id} ${props.disabled ? 'disabled' : ''}`} ref={containerRef} onClick={(e) => e.stopPropagation()}>
{label && (
<div className="dropdown-header">
<label className="dropdown-label">{label}</label>
<span className="dropdown-reset" onClick={resetItem}>
<MdRefresh />
{variables.getMessage('modals.main.settings.buttons.reset')}
</span>
</div>
)}
<div
ref={controlRef}
className={`dropdown-control ${props.searchable && (isOpen || searchQuery) ? 'searching' : ''}`}
onClick={(e) => {
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 ? (
<>
<input
ref={searchInputRef}
type="text"
className="dropdown-search-input-control"
placeholder={selectedItem?.text || value}
value={searchQuery}
onChange={handleSearchChange}
onClick={handleInputClick}
onFocus={handleInputFocus}
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (searchQuery) {
setSearchQuery('');
} else {
closeDropdown();
}
}
if (e.key === ' ') {
e.stopPropagation();
}
}}
/>
{(searchQuery || value !== (props.default || props.items[0]?.value)) && (
<MdClose className="dropdown-clear" onClick={clearSearch} />
)}
</>
) : (
<span className="dropdown-value">{selectedItem?.text || value}</span>
)}
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
</div>
{(isOpen || isClosing) &&
createPortal(
<div
ref={menuRef}
className={`dropdown-menu ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
role="listbox"
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
width: `${menuPosition.width}px`,
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
}}
>
{filteredItems.map((item, index) =>
item !== null ? (
<div
key={id + item.value}
ref={(el) => (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}
>
<span className="dropdown-option-text">
{item.text}
{item.value === defaultValue && (
<span className="dropdown-option-default">
{' '}
({variables.getMessage('modals.main.settings.buttons.default')})
</span>
)}
</span>
{value === item.value && <MdCheck className="dropdown-option-check" />}
</div>
) : null,
)}
</div>,
document.body,
)}
</div>
);
});
Dropdown.displayName = 'Dropdown';
export { Dropdown as default, Dropdown };