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;
+ }
+ }
+}