mirror of
https://github.com/mue/mue.git
synced 2026-07-07 22:23:37 +02:00
feat: new dropdown search capability
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 { MdExpandMore, MdCheck, MdRefresh, MdClose } from 'react-icons/md';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
import EventBus from 'utils/eventbus';
|
||||
@@ -14,10 +14,12 @@ const Dropdown = memo((props) => {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [focusedIndex, setFocusedIndex] = useState(-1);
|
||||
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const containerRef = useRef(null);
|
||||
const controlRef = useRef(null);
|
||||
const menuRef = useRef(null);
|
||||
const optionsRef = useRef([]);
|
||||
const searchInputRef = useRef(null);
|
||||
|
||||
const closeDropdown = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
@@ -25,6 +27,7 @@ const Dropdown = memo((props) => {
|
||||
setIsOpen(false);
|
||||
setIsClosing(false);
|
||||
setFocusedIndex(-1);
|
||||
setSearchQuery('');
|
||||
}, 200); // Match animation duration
|
||||
}, []);
|
||||
|
||||
@@ -77,6 +80,37 @@ const Dropdown = memo((props) => {
|
||||
setIsOpen(true);
|
||||
}, [calculatePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && props.searchable && searchInputRef.current) {
|
||||
// Focus the search input when dropdown opens
|
||||
setTimeout(() => searchInputRef.current?.focus(), 0);
|
||||
}
|
||||
}, [isOpen, props.searchable]);
|
||||
|
||||
const handleSearchChange = useCallback((e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
}
|
||||
}, [isOpen, openDropdown]);
|
||||
|
||||
const handleInputClick = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (!isOpen) {
|
||||
openDropdown();
|
||||
}
|
||||
}, [isOpen, openDropdown]);
|
||||
|
||||
const handleInputFocus = useCallback(() => {
|
||||
// When focusing, if not default value, pre-fill with current value for editing
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
if (value !== defaultValue && !searchQuery) {
|
||||
const currentItem = props.items.find((item) => item?.value === value);
|
||||
const currentText = currentItem?.text || value;
|
||||
setSearchQuery(currentText);
|
||||
}
|
||||
}, [value, props.default, props.items, searchQuery]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(newValue) => {
|
||||
if (newValue === variables.getMessage('modals.main.loading')) {
|
||||
@@ -163,11 +197,31 @@ const Dropdown = memo((props) => {
|
||||
toast(variables.getMessage('toasts.reset'));
|
||||
}, [onChange, props.default, props.items]);
|
||||
|
||||
const clearSearch = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
setSearchQuery('');
|
||||
if (props.searchable) {
|
||||
// Reset to default value (first item, usually "Automatic")
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
onChange(defaultValue);
|
||||
}
|
||||
if (searchInputRef.current) {
|
||||
searchInputRef.current.focus();
|
||||
}
|
||||
}, [props, onChange]);
|
||||
|
||||
const id = 'dropdown' + props.name;
|
||||
const label = props.label || '';
|
||||
const selectedItem = props.items.find((item) => item?.value === value);
|
||||
const defaultValue = props.default || props.items[0]?.value;
|
||||
|
||||
// Filter items based on search query
|
||||
const filteredItems = props.searchable && searchQuery
|
||||
? props.items.filter((item) =>
|
||||
item !== null && item.text.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
: props.items;
|
||||
|
||||
return (
|
||||
<div className={`dropdown ${id} ${props.disabled ? 'disabled' : ''}`} ref={containerRef}>
|
||||
{label && (
|
||||
@@ -181,7 +235,7 @@ const Dropdown = memo((props) => {
|
||||
)}
|
||||
<div
|
||||
ref={controlRef}
|
||||
className="dropdown-control"
|
||||
className={`dropdown-control ${props.searchable && (isOpen || searchQuery) ? 'searching' : ''}`}
|
||||
onClick={() => {
|
||||
if (props.disabled) return;
|
||||
if (isOpen) {
|
||||
@@ -197,7 +251,37 @@ const Dropdown = memo((props) => {
|
||||
aria-label={label || props.name}
|
||||
tabIndex={props.disabled ? -1 : 0}
|
||||
>
|
||||
<span className="dropdown-value">{selectedItem?.text || value}</span>
|
||||
{props.searchable ? (
|
||||
<>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
className="dropdown-search-input-control"
|
||||
placeholder={selectedItem?.text || value}
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
onClick={handleInputClick}
|
||||
onFocus={handleInputFocus}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (searchQuery) {
|
||||
setSearchQuery('');
|
||||
} else {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
if (e.key === ' ') {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(searchQuery || value !== (props.default || props.items[0]?.value)) && (
|
||||
<MdClose className="dropdown-clear" onClick={clearSearch} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="dropdown-value">{selectedItem?.text || value}</span>
|
||||
)}
|
||||
<MdExpandMore className={`dropdown-arrow ${isOpen ? 'open' : ''}`} />
|
||||
</div>
|
||||
{(isOpen || isClosing) &&
|
||||
@@ -215,7 +299,7 @@ const Dropdown = memo((props) => {
|
||||
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
|
||||
}}
|
||||
>
|
||||
{props.items.map((item, index) =>
|
||||
{filteredItems.map((item, index) =>
|
||||
item !== null ? (
|
||||
<div
|
||||
key={id + item.value}
|
||||
|
||||
@@ -107,6 +107,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: 56px;
|
||||
padding: 0 16px;
|
||||
cursor: pointer;
|
||||
@@ -132,6 +133,10 @@
|
||||
box-shadow: 0 0 0 3px t($link);
|
||||
}
|
||||
}
|
||||
|
||||
&.searching {
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-value {
|
||||
@@ -145,21 +150,69 @@
|
||||
.dropdown-arrow {
|
||||
flex-shrink: 0;
|
||||
font-size: 24px;
|
||||
transition: transform 0.2s ease;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
margin: -4px;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-search-input-control {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
font-size: 14px;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
|
||||
@include themed {
|
||||
color: t($color);
|
||||
|
||||
&::placeholder {
|
||||
color: t($color);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-clear {
|
||||
flex-shrink: 0;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
|
||||
@include themed {
|
||||
color: t($subColor);
|
||||
|
||||
&:hover {
|
||||
background: t($modal-sidebarActive);
|
||||
color: t($color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-menu {
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 9999;
|
||||
@include animation(dropdownSlideIn 0.2s ease-out);
|
||||
will-change: transform, opacity;
|
||||
|
||||
@@ -139,6 +139,7 @@ function AdvancedOptions({ currentSubSection, onSubSectionChange, sectionName })
|
||||
<Dropdown
|
||||
name="timezone"
|
||||
category="timezone"
|
||||
searchable={true}
|
||||
items={[
|
||||
{
|
||||
value: 'auto',
|
||||
|
||||
Reference in New Issue
Block a user