From 401e711bd8cf74c027990d7878ac3dcd15c3fdbc Mon Sep 17 00:00:00 2001 From: alexsparkes Date: Tue, 27 Jan 2026 16:29:07 +0000 Subject: [PATCH] feat(ChipSelect): enhance dropdown with closing animations and position calculation --- .../Form/Settings/ChipSelect/ChipSelect.jsx | 124 ++++++++-- .../Form/Settings/ChipSelect/ChipSelect.scss | 218 +++++++++++++----- 2 files changed, 255 insertions(+), 87 deletions(-) diff --git a/src/components/Form/Settings/ChipSelect/ChipSelect.jsx b/src/components/Form/Settings/ChipSelect/ChipSelect.jsx index 0bb1815c..c2a5c7ae 100644 --- a/src/components/Form/Settings/ChipSelect/ChipSelect.jsx +++ b/src/components/Form/Settings/ChipSelect/ChipSelect.jsx @@ -1,5 +1,6 @@ -import { useState, memo, useRef, useEffect } from 'react'; -import { MdExpandMore, MdClose } from 'react-icons/md'; +import { useState, memo, useRef, useEffect, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { MdExpandMore, MdClose, MdCheck } from 'react-icons/md'; import './ChipSelect.scss'; @@ -11,18 +12,68 @@ function ChipSelect({ label, options, onChange }) { const [optionsSelected, setOptionsSelected] = useState(start); const [isOpen, setIsOpen] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 }); const containerRef = useRef(null); + const controlRef = useRef(null); + const menuRef = useRef(null); + + const closeDropdown = useCallback(() => { + setIsClosing(true); + setTimeout(() => { + setIsOpen(false); + setIsClosing(false); + }, 200); // Match animation duration + }, []); useEffect(() => { const handleClickOutside = (event) => { - if (containerRef.current && !containerRef.current.contains(event.target)) { - setIsOpen(false); + 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]); + + const calculatePosition = useCallback(() => { + if (controlRef.current) { + const rect = controlRef.current.getBoundingClientRect(); + const gap = 4; + const viewportHeight = window.innerHeight; + + // Estimate menu height + const estimatedMenuHeight = Math.min(options.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, + }; + } + return { top: 0, left: 0, width: 0, maxHeight: 250, flipped: false }; + }, [options]); + + const openDropdown = useCallback(() => { + const position = calculatePosition(); + setMenuPosition(position); + setIsOpen(true); + }, [calculatePosition]); const handleToggle = (optionName) => { let newSelected; @@ -48,7 +99,17 @@ function ChipSelect({ label, options, onChange }) { return (
{label && } -
setIsOpen(!isOpen)}> +
{ + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }} + >
{optionsSelected.length === 0 ? ( Select options... @@ -71,25 +132,38 @@ function ChipSelect({ label, options, onChange }) {
- {isOpen && ( -
- {options.map((option) => ( -
handleToggle(option.name)} - > - - {optionsSelected.includes(option.name) && '✓'} - - - {option.name.charAt(0).toUpperCase() + option.name.slice(1)} - {option.count && ` (${option.count})`} - -
- ))} -
- )} + {(isOpen || isClosing) && + createPortal( +
+ {options.map((option) => ( +
handleToggle(option.name)} + > +
+ {optionsSelected.includes(option.name) && } +
+ + {option.name.charAt(0).toUpperCase() + option.name.slice(1)} + {option.count && ` (${option.count})`} + +
+ ))} +
, + document.body, + )}
); } diff --git a/src/components/Form/Settings/ChipSelect/ChipSelect.scss b/src/components/Form/Settings/ChipSelect/ChipSelect.scss index 31bb4009..8516896b 100644 --- a/src/components/Form/Settings/ChipSelect/ChipSelect.scss +++ b/src/components/Form/Settings/ChipSelect/ChipSelect.scss @@ -1,13 +1,63 @@ @use 'scss/variables' as *; +@use 'scss/mixins' as *; + +@include keyframes(chipSelectSlideIn) { + 0% { + opacity: 0; + transform: translateY(-10px); + } + + 100% { + opacity: 1; + transform: translateY(0); + } +} + +@include keyframes(chipSelectSlideOut) { + 0% { + opacity: 1; + transform: translateY(0); + } + + 100% { + opacity: 0; + transform: translateY(-10px); + } +} + +@include keyframes(chipSelectSlideInUp) { + 0% { + opacity: 0; + transform: translateY(-100%) translateY(10px); + } + + 100% { + opacity: 1; + transform: translateY(-100%); + } +} + +@include keyframes(chipSelectSlideOutUp) { + 0% { + opacity: 1; + transform: translateY(-100%); + } + + 100% { + opacity: 0; + transform: translateY(-100%) translateY(10px); + } +} .chipSelect { position: relative; width: 300px; margin-top: 10px; + gap: 8px; + display: flex; + flex-flow: column; .chipSelect-label { - display: block; - margin-bottom: 8px; font-size: 12px; font-weight: 500; text-transform: uppercase; @@ -23,9 +73,10 @@ align-items: center; justify-content: space-between; min-height: 56px; - padding: 8px 12px; + padding: 0 16px; cursor: pointer; - transition: 0.2s ease; + transition: all 0.2s ease; + outline: none; @include themed { background: t($modal-sidebar); @@ -42,6 +93,7 @@ .chipSelect-value { flex: 1; min-width: 0; + padding: 8px 0; } .chipSelect-placeholder { @@ -59,10 +111,11 @@ .chipSelect-chip { display: inline-flex; align-items: center; - gap: 4px; - padding: 4px 8px; + gap: 6px; + padding: 6px 10px; font-size: 13px; text-transform: capitalize; + transition: all 0.15s ease; @include themed { background: t($modal-sidebarActive); @@ -74,25 +127,27 @@ display: flex; align-items: center; justify-content: center; - padding: 2px; - margin-left: 2px; + width: 16px; + height: 16px; + padding: 0; + margin: 0; border: none; background: transparent; cursor: pointer; border-radius: 50%; - transition: 0.2s ease; + transition: all 0.15s ease; @include themed { color: t($subColor); &:hover { - background: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.15); color: t($color); } } svg { - font-size: 14px; + font-size: 12px; } } } @@ -110,69 +165,108 @@ transform: rotate(180deg); } } +} - .chipSelect-dropdown { - position: absolute; - top: calc(100% + 4px); - left: 0; - right: 0; - max-height: 250px; - overflow-y: auto; - z-index: 100; +.chipSelect-dropdown { + max-height: 250px; + overflow-y: auto; + z-index: 9999; + @include animation(chipSelectSlideIn 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); + } + + &.flipped { + @include animation(chipSelectSlideInUp 0.2s ease-out); + + &.closing { + @include animation(chipSelectSlideOutUp 0.2s ease-out forwards); } } - .chipSelect-option { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - cursor: pointer; - transition: 0.2s ease; + &.closing:not(.flipped) { + @include animation(chipSelectSlideOut 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); - } - - &.selected { - background: t($modal-sidebar); - } + &::-webkit-scrollbar-thumb { + @include themed { + background: t($modal-sidebarActive); + border-radius: 3px; } - .chipSelect-option-checkbox { - display: flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - font-size: 12px; - font-weight: bold; - + &:hover { @include themed { - border: 2px solid t($modal-sidebarActive); - border-radius: 4px; - color: t($color); + background: t($color); } } - - &.selected .chipSelect-option-checkbox { - @include themed { - background: t($modal-sidebarActive); - border-color: t($color); - } - } - - .chipSelect-option-label { - flex: 1; - } + } +} + +.chipSelect-option { + display: flex; + align-items: center; + gap: 10px; + 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); + } + } + + .chipSelect-option-checkbox { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + + @include themed { + border: 2px solid t($modal-sidebarActive); + color: t($color); + } + + svg { + font-size: 14px; + } + } + + &.selected .chipSelect-option-checkbox { + @include themed { + background: t($link); + border-color: t($link); + color: white; + } + } + + .chipSelect-option-label { + flex: 1; } }