feat(Dropdown): implement closing animation and portal rendering for dropdown menu

fix(QuoteOptions): ensure authorDetails is set to true for users upgrading from older versions
This commit is contained in:
alexsparkes
2026-01-27 14:22:15 +00:00
parent 9bf160094e
commit 7a589de14b
3 changed files with 109 additions and 50 deletions

View File

@@ -1,5 +1,6 @@
import variables from 'config/variables';
import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { MdExpandMore, MdCheck, MdRefresh } from 'react-icons/md';
import { toast } from 'react-toastify';
@@ -8,25 +9,51 @@ 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 [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 containerRef = useRef(null);
const controlRef = useRef(null);
const menuRef = useRef(null);
const optionsRef = useRef([]);
const closeDropdown = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsOpen(false);
setIsClosing(false);
setFocusedIndex(-1);
}, 200); // Match animation duration
}, []);
useEffect(() => {
const handleClickOutside = (event) => {
if (containerRef.current && !containerRef.current.contains(event.target)) {
setIsOpen(false);
setFocusedIndex(-1);
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]);
useEffect(() => {
if (isOpen && controlRef.current) {
const rect = controlRef.current.getBoundingClientRect();
setMenuPosition({
top: rect.bottom + 4,
left: rect.left,
width: rect.width,
});
}
}, [isOpen]);
const onChange = useCallback(
(newValue) => {
@@ -37,8 +64,7 @@ const Dropdown = memo((props) => {
variables.stats.postEvent('setting', `${props.name} from ${value} to ${newValue}`);
setValue(newValue);
setIsOpen(false);
setFocusedIndex(-1);
closeDropdown();
if (!props.noSetting) {
localStorage.setItem(props.name, newValue);
@@ -69,18 +95,23 @@ const Dropdown = memo((props) => {
case 'Enter':
case ' ':
e.preventDefault();
setIsOpen(!isOpen);
if (isOpen) {
closeDropdown();
} else {
setIsOpen(true);
}
break;
case 'Escape':
setIsOpen(false);
setFocusedIndex(-1);
closeDropdown();
break;
case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setFocusedIndex((prev) => (prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev));
setFocusedIndex((prev) =>
prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev,
);
}
break;
case 'ArrowUp':
@@ -126,8 +157,16 @@ const Dropdown = memo((props) => {
</div>
)}
<div
ref={controlRef}
className="dropdown-control"
onClick={() => !props.disabled && setIsOpen(!isOpen)}
onClick={() => {
if (props.disabled) return;
if (isOpen) {
closeDropdown();
} else {
setIsOpen(true);
}
}}
onKeyDown={handleKeyDown}
role="button"
aria-haspopup="listbox"
@@ -138,27 +177,39 @@ const Dropdown = memo((props) => {
<span className="dropdown-value">{selectedItem?.text || value}</span>
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
</div>
{isOpen && (
<div className="dropdown-menu" role="listbox">
{props.items.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}</span>
{value === item.value && <MdCheck className="dropdown-option-check" />}
</div>
) : null,
)}
</div>
)}
{(isOpen || isClosing) &&
createPortal(
<div
ref={menuRef}
className={`dropdown-menu ${isClosing ? 'closing' : ''}`}
role="listbox"
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
width: `${menuPosition.width}px`,
}}
>
{props.items.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}</span>
{value === item.value && <MdCheck className="dropdown-option-check" />}
</div>
) : null,
)}
</div>,
document.body,
)}
</div>
);
});