From f493eb186eb17e0d61cb10f6c309df169421a345 Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Tue, 27 Jan 2026 16:23:54 +0000 Subject: [PATCH] feat(Dropdown): enhance dropdown menu with position calculation and closing animations --- .../Form/Settings/Dropdown/Dropdown.jsx | 46 +++- .../Form/Settings/Dropdown/Dropdown.scss | 202 +++++++++++------- 2 files changed, 158 insertions(+), 90 deletions(-) diff --git a/src/components/Form/Settings/Dropdown/Dropdown.jsx b/src/components/Form/Settings/Dropdown/Dropdown.jsx index 0e26f3fa..320781ed 100644 --- a/src/components/Form/Settings/Dropdown/Dropdown.jsx +++ b/src/components/Form/Settings/Dropdown/Dropdown.jsx @@ -44,16 +44,38 @@ const Dropdown = memo((props) => { return () => document.removeEventListener('mousedown', handleClickOutside); }, [closeDropdown]); - useEffect(() => { - if (isOpen && controlRef.current) { + const calculatePosition = useCallback(() => { + if (controlRef.current) { const rect = controlRef.current.getBoundingClientRect(); - setMenuPosition({ - top: rect.bottom + 4, + const gap = 4; + const viewportHeight = window.innerHeight; + + // Estimate menu height (will be more accurate after first render) + const estimatedMenuHeight = Math.min(props.items.filter((i) => i !== null).length * 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, + }; } - }, [isOpen]); + return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false }; + }, [props.items]); + + const openDropdown = useCallback(() => { + const position = calculatePosition(); + setMenuPosition(position); + setIsOpen(true); + }, [calculatePosition]); const onChange = useCallback( (newValue) => { @@ -98,7 +120,7 @@ const Dropdown = memo((props) => { if (isOpen) { closeDropdown(); } else { - setIsOpen(true); + openDropdown(); } break; case 'Escape': @@ -107,7 +129,7 @@ const Dropdown = memo((props) => { case 'ArrowDown': e.preventDefault(); if (!isOpen) { - setIsOpen(true); + openDropdown(); } else { setFocusedIndex((prev) => prev < props.items.filter((i) => i !== null).length - 1 ? prev + 1 : prev, @@ -122,7 +144,7 @@ const Dropdown = memo((props) => { break; } }, - [isOpen, props.items, props.disabled], + [isOpen, props.items, props.disabled, openDropdown, closeDropdown], ); const handleOptionKeyDown = useCallback( @@ -164,7 +186,7 @@ const Dropdown = memo((props) => { if (isOpen) { closeDropdown(); } else { - setIsOpen(true); + openDropdown(); } }} onKeyDown={handleKeyDown} @@ -181,13 +203,15 @@ const Dropdown = memo((props) => { createPortal(
{props.items.map((item, index) => diff --git a/src/components/Form/Settings/Dropdown/Dropdown.scss b/src/components/Form/Settings/Dropdown/Dropdown.scss index 637fad37..00804a3c 100644 --- a/src/components/Form/Settings/Dropdown/Dropdown.scss +++ b/src/components/Form/Settings/Dropdown/Dropdown.scss @@ -13,6 +13,42 @@ } } +@include keyframes(dropdownSlideOut) { + 0% { + opacity: 1; + transform: translateY(0); + } + + 100% { + opacity: 0; + transform: translateY(-10px); + } +} + +@include keyframes(dropdownSlideInUp) { + 0% { + opacity: 0; + transform: translateY(-100%) translateY(10px); + } + + 100% { + opacity: 1; + transform: translateY(-100%); + } +} + +@include keyframes(dropdownSlideOutUp) { + 0% { + opacity: 1; + transform: translateY(-100%); + } + + 100% { + opacity: 0; + transform: translateY(-100%) translateY(10px); + } +} + .dropdown { position: relative; width: 300px; @@ -21,7 +57,6 @@ display: flex; flex-flow: column; - &.disabled { opacity: 0.5; cursor: not-allowed; @@ -121,98 +156,107 @@ 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 { + max-height: 250px; + overflow-y: auto; + z-index: 9999; + @include animation(dropdownSlideIn 0.2s ease-out); + will-change: transform, opacity; - @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); - } + @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; - } + &.flipped { + @include animation(dropdownSlideInUp 0.2s ease-out); - &::-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); - } - } + &.closing { + @include animation(dropdownSlideOutUp 0.2s ease-out forwards); } } - .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; + &.closing:not(.flipped) { + @include animation(dropdownSlideOut 0.2s ease-out forwards); + } + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { @include themed { - color: t($color); + background: t($modal-sidebar); + } + } - &: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); - } + &::-webkit-scrollbar-thumb { + @include themed { + 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; + } + } +}