mirror of
https://github.com/mue/mue.git
synced 2026-06-06 07:55:48 +02:00
feat(ChipSelect): enhance dropdown with closing animations and position calculation
This commit is contained in:
@@ -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 (
|
||||
<div className="chipSelect" ref={containerRef}>
|
||||
{label && <label className="chipSelect-label">{label}</label>}
|
||||
<div className="chipSelect-control" onClick={() => setIsOpen(!isOpen)}>
|
||||
<div
|
||||
ref={controlRef}
|
||||
className="chipSelect-control"
|
||||
onClick={() => {
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="chipSelect-value">
|
||||
{optionsSelected.length === 0 ? (
|
||||
<span className="chipSelect-placeholder">Select options...</span>
|
||||
@@ -71,25 +132,38 @@ function ChipSelect({ label, options, onChange }) {
|
||||
</div>
|
||||
<MdExpandMore className={`chipSelect-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div className="chipSelect-dropdown">
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.name}
|
||||
className={`chipSelect-option ${optionsSelected.includes(option.name) ? 'selected' : ''}`}
|
||||
onClick={() => handleToggle(option.name)}
|
||||
>
|
||||
<span className="chipSelect-option-checkbox">
|
||||
{optionsSelected.includes(option.name) && '✓'}
|
||||
</span>
|
||||
<span className="chipSelect-option-label">
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}
|
||||
{option.count && ` (${option.count})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(isOpen || isClosing) &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`chipSelect-dropdown ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: `${menuPosition.top}px`,
|
||||
left: `${menuPosition.left}px`,
|
||||
width: `${menuPosition.width}px`,
|
||||
maxHeight: menuPosition.maxHeight ? `${menuPosition.maxHeight}px` : '250px',
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div
|
||||
key={option.name}
|
||||
className={`chipSelect-option ${optionsSelected.includes(option.name) ? 'selected' : ''}`}
|
||||
onClick={() => handleToggle(option.name)}
|
||||
>
|
||||
<div className="chipSelect-option-checkbox">
|
||||
{optionsSelected.includes(option.name) && <MdCheck />}
|
||||
</div>
|
||||
<span className="chipSelect-option-label">
|
||||
{option.name.charAt(0).toUpperCase() + option.name.slice(1)}
|
||||
{option.count && ` (${option.count})`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user