From 89523df1cfadb0c403077f1514aeeb0fe454af9a Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Tue, 27 Jan 2026 10:21:02 +0000 Subject: [PATCH] feat(Dropdown): implement dropdown closing animation and portal rendering --- .../Form/Settings/Dropdown/Dropdown.jsx | 121 ++++++++---- .../Form/Settings/Dropdown/Dropdown.scss | 175 ++++++++++-------- 2 files changed, 180 insertions(+), 116 deletions(-) diff --git a/src/components/Form/Settings/Dropdown/Dropdown.jsx b/src/components/Form/Settings/Dropdown/Dropdown.jsx index d4585d45..0e26f3fa 100644 --- a/src/components/Form/Settings/Dropdown/Dropdown.jsx +++ b/src/components/Form/Settings/Dropdown/Dropdown.jsx @@ -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) => { )}
!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) => { {selectedItem?.text || value}
- {isOpen && ( -
- {props.items.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} - {value === item.value && } -
- ) : null, - )} -
- )} + {(isOpen || isClosing) && + createPortal( +
+ {props.items.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} + {value === item.value && } +
+ ) : null, + )} +
, + document.body, + )} ); }); diff --git a/src/components/Form/Settings/Dropdown/Dropdown.scss b/src/components/Form/Settings/Dropdown/Dropdown.scss index 458c8de7..29fcf32e 100644 --- a/src/components/Form/Settings/Dropdown/Dropdown.scss +++ b/src/components/Form/Settings/Dropdown/Dropdown.scss @@ -13,6 +13,18 @@ } } +@include keyframes(dropdownSlideOut) { + 0% { + opacity: 1; + transform: translateY(0); + } + + 100% { + opacity: 0; + transform: translateY(-10px); + } +} + .dropdown { position: relative; width: 300px; @@ -119,98 +131,99 @@ transform: rotate(180deg); } } +} - .dropdown-menu { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - max-height: 250px; - overflow-y: auto; - z-index: 9999; - @include animation(dropdownSlideIn 0.2s ease-out); +// Dropdown menu and options are now portaled to body, so they need to be at root level +.dropdown-menu { + max-height: 250px; + overflow-y: auto; + z-index: 10000; + @include animation(dropdownSlideIn 0.2s ease-out); + &.closing { + @include animation(dropdownSlideOut 0.2s ease-out); + } + + @include themed { + background: t($modal-background); + border: 1px solid t($modal-sidebarActive); + border-radius: t($borderRadius); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + } + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { @include themed { - background: t($modal-background); - border: 1px solid t($modal-sidebarActive); - border-radius: t($borderRadius); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); - } - - &::-webkit-scrollbar { - width: 6px; - } - - &::-webkit-scrollbar-track { - @include themed { - background: t($modal-sidebar); - } - } - - &::-webkit-scrollbar-thumb { - @include themed { - background: t($modal-sidebarActive); - border-radius: 3px; - } - - &:hover { - @include themed { - background: t($color); - } - } + background: t($modal-sidebar); } } - .dropdown-option { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 12px 16px; - cursor: pointer; - transition: all 0.15s ease; - outline: none; - + &::-webkit-scrollbar-thumb { @include themed { - color: t($color); - - &:hover { - background: t($modal-sidebarActive); - padding-left: 20px; - } - - &.selected { - background: t($modal-sidebar); - font-weight: 500; - } - - &.focused { - background: t($modal-sidebarActive); - border-left: 2px solid t($link); - } + background: t($modal-sidebarActive); + border-radius: 3px; } - .dropdown-option-text { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - } - - .dropdown-option-check { - flex-shrink: 0; - font-size: 14px; - width: 20px; - height: 20px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - + &:hover { @include themed { - background: t($link); - color: white; + background: t($color); } } } } + +.dropdown-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 12px 16px; + cursor: pointer; + transition: all 0.15s ease; + outline: none; + + @include themed { + color: t($color); + + &:hover { + background: t($modal-sidebarActive); + padding-left: 20px; + } + + &.selected { + background: t($modal-sidebar); + font-weight: 500; + } + + &.focused { + background: t($modal-sidebarActive); + border-left: 2px solid t($link); + } + } + + .dropdown-option-text { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .dropdown-option-check { + flex-shrink: 0; + font-size: 14px; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + + @include themed { + background: t($link); + color: white; + } + } +}