mirror of
https://github.com/Dictionarry-Hub/profilarr.git
synced 2026-01-22 02:41:11 +01:00
feat: search dropdown (#150)
- new search dropdown component that adds search / sort functionality to dropdowns - replaces browser select component in select condition type dropdowns - add font size prop to sort dropdown / search bar to scale size for dropdowns - fix bad background color on tests page - add scrollable class description / condition area for format / regex cards
This commit is contained in:
25
CLAUDE.md
Normal file
25
CLAUDE.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Profilarr Development Guide
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
- **Frontend**: `cd frontend && npm run dev` - Start React dev server
|
||||||
|
- **Backend**: `cd backend && gunicorn -b 0.0.0.0:5000 app.main:app` - Run Flask server
|
||||||
|
- **Docker**: `docker compose up` - Start both frontend/backend in dev mode
|
||||||
|
- **Lint**: `cd frontend && npx eslint 'src/**/*.{js,jsx}'` - Check frontend code style
|
||||||
|
- **Build**: `cd frontend && npm run build` - Build for production
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
### Frontend (React)
|
||||||
|
- **Imports**: React first, third-party libs next, components, then utils
|
||||||
|
- **Components**: Functional components with hooks, PascalCase naming
|
||||||
|
- **Props**: PropTypes for validation, destructure props in component signature
|
||||||
|
- **State**: Group related state, useCallback for memoized handlers
|
||||||
|
- **JSX**: 4-space indentation, attributes on new lines for readability
|
||||||
|
- **Error Handling**: try/catch for async operations, toast notifications
|
||||||
|
|
||||||
|
### Backend (Python)
|
||||||
|
- **Imports**: Standard lib first, third-party next, local modules last
|
||||||
|
- **Naming**: snake_case for functions/vars/files, PascalCase for classes
|
||||||
|
- **Functions**: Single responsibility, descriptive docstrings
|
||||||
|
- **Error Handling**: Specific exception catches, return (success, message) tuples
|
||||||
|
- **Indentation**: 4 spaces consistently
|
||||||
|
- **Modularity**: Related functionality grouped in directories
|
||||||
@@ -168,7 +168,7 @@ function FormatCard({
|
|||||||
: 'translate-x-0'
|
: 'translate-x-0'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Conditions */}
|
{/* Conditions */}
|
||||||
<div className='w-full flex-shrink-0 overflow-y-auto'>
|
<div className='w-full flex-shrink-0 overflow-y-auto scrollable'>
|
||||||
<div className='flex flex-wrap gap-1.5 content-start'>
|
<div className='flex flex-wrap gap-1.5 content-start'>
|
||||||
{content.conditions?.map((condition, index) => (
|
{content.conditions?.map((condition, index) => (
|
||||||
<span
|
<span
|
||||||
@@ -189,7 +189,7 @@ function FormatCard({
|
|||||||
: 'translate-x-full'
|
: 'translate-x-full'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div className='w-full h-full overflow-y-auto'>
|
<div className='w-full h-full overflow-y-auto scrollable'>
|
||||||
{content.description ? (
|
{content.description ? (
|
||||||
<div className='text-gray-300 text-xs prose prose-invert prose-gray max-w-none'>
|
<div className='text-gray-300 text-xs prose prose-invert prose-gray max-w-none'>
|
||||||
<ReactMarkdown>
|
<ReactMarkdown>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const FormatTestingTab = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'>
|
<div className='text-center py-12 rounded-lg'>
|
||||||
<p className='text-gray-500 dark:text-gray-400'>
|
<p className='text-gray-500 dark:text-gray-400'>
|
||||||
No tests added yet
|
No tests added yet
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import {CONDITION_TYPES, createCondition} from './conditionTypes';
|
import {CONDITION_TYPES, createCondition} from './conditionTypes';
|
||||||
import {ArrowUp, ArrowDown, X, ChevronsUp, ChevronsDown} from 'lucide-react';
|
import {ArrowUp, ArrowDown, X, ChevronsUp, ChevronsDown} from 'lucide-react';
|
||||||
import BrowserSelect from '@ui/BrowserSelect';
|
import SearchDropdown from '@ui/SearchDropdown';
|
||||||
|
|
||||||
const ConditionCard = ({
|
const ConditionCard = ({
|
||||||
condition,
|
condition,
|
||||||
@@ -21,7 +21,8 @@ const ConditionCard = ({
|
|||||||
|
|
||||||
const typeOptions = Object.values(CONDITION_TYPES).map(type => ({
|
const typeOptions = Object.values(CONDITION_TYPES).map(type => ({
|
||||||
value: type.id,
|
value: type.id,
|
||||||
label: type.name
|
label: type.name,
|
||||||
|
description: type.description || ''
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleTypeChange = e => {
|
const handleTypeChange = e => {
|
||||||
@@ -57,14 +58,13 @@ const ConditionCard = ({
|
|||||||
|
|
||||||
<div className='flex items-center gap-4'>
|
<div className='flex items-center gap-4'>
|
||||||
{/* Type Selection */}
|
{/* Type Selection */}
|
||||||
<BrowserSelect
|
<SearchDropdown
|
||||||
value={condition.type || ''}
|
value={condition.type || ''}
|
||||||
onChange={handleTypeChange}
|
onChange={handleTypeChange}
|
||||||
options={typeOptions}
|
options={typeOptions}
|
||||||
placeholder='Select type...'
|
placeholder='Select type...'
|
||||||
className='min-w-[140px] px-3 py-2 text-sm rounded-md
|
className='min-w-[200px] condition-type-dropdown'
|
||||||
bg-gray-700 border border-gray-700
|
width='w-auto'
|
||||||
text-gray-200'
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Render the specific condition component */}
|
{/* Render the specific condition component */}
|
||||||
|
|||||||
@@ -1,25 +1,31 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import BrowserSelect from '@ui/BrowserSelect';
|
import SearchDropdown from '@ui/SearchDropdown';
|
||||||
|
|
||||||
const EditionCondition = ({condition, onChange, patterns}) => {
|
const EditionCondition = ({condition, onChange, patterns}) => {
|
||||||
// Convert patterns to options format
|
// Format patterns for the dropdown with descriptions if available
|
||||||
const patternOptions = patterns.map(pattern => ({
|
const patternOptions = patterns.map(pattern => ({
|
||||||
value: pattern.name,
|
value: pattern.name,
|
||||||
label: pattern.name
|
label: pattern.name,
|
||||||
|
description: pattern.description || 'No description available',
|
||||||
|
priority: pattern.priority
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const handlePatternChange = e => {
|
||||||
|
onChange({...condition, pattern: e.target.value});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex-1'>
|
<div className="flex-1">
|
||||||
<BrowserSelect
|
<SearchDropdown
|
||||||
value={condition.pattern || ''}
|
value={condition.pattern || ''}
|
||||||
onChange={e =>
|
onChange={handlePatternChange}
|
||||||
onChange({...condition, pattern: e.target.value})
|
|
||||||
}
|
|
||||||
options={patternOptions}
|
options={patternOptions}
|
||||||
placeholder='Select edition pattern...'
|
placeholder='Select edition pattern...'
|
||||||
className='w-full px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600
|
searchableFields={['label', 'description']}
|
||||||
rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
|
className='min-w-[200px]'
|
||||||
|
width='w-auto'
|
||||||
|
dropdownWidth='100%'
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import SearchDropdown from '@ui/SearchDropdown';
|
||||||
|
|
||||||
const ReleaseGroupCondition = ({condition, onChange, patterns}) => {
|
const ReleaseGroupCondition = ({condition, onChange, patterns}) => {
|
||||||
const sortedPatterns = [...patterns].sort((a, b) =>
|
// Format patterns for the dropdown with descriptions if available
|
||||||
a.name.localeCompare(b.name)
|
const patternOptions = patterns.map(pattern => ({
|
||||||
);
|
value: pattern.name,
|
||||||
|
label: pattern.name,
|
||||||
|
description: pattern.description || 'No description available',
|
||||||
|
priority: pattern.priority
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handlePatternChange = e => {
|
||||||
|
onChange({...condition, pattern: e.target.value});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<div className="flex-1">
|
||||||
className='flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700'
|
<SearchDropdown
|
||||||
value={condition.pattern || ''}
|
value={condition.pattern || ''}
|
||||||
onChange={e => onChange({...condition, pattern: e.target.value})}>
|
onChange={handlePatternChange}
|
||||||
<option value=''>Select release group pattern...</option>
|
options={patternOptions}
|
||||||
{sortedPatterns.map(pattern => (
|
placeholder='Select release group pattern...'
|
||||||
<option key={pattern.name} value={pattern.name}>
|
searchableFields={['label', 'description']}
|
||||||
{pattern.name}
|
className='min-w-[200px]'
|
||||||
</option>
|
width='w-auto'
|
||||||
))}
|
dropdownWidth='100%'
|
||||||
</select>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,33 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import SearchDropdown from '@ui/SearchDropdown';
|
||||||
|
|
||||||
const ReleaseTitleCondition = ({condition, onChange, patterns}) => {
|
const ReleaseTitleCondition = ({condition, onChange, patterns}) => {
|
||||||
const sortedPatterns = [...patterns].sort((a, b) =>
|
// Format patterns for the dropdown with enhanced descriptions
|
||||||
a.name.localeCompare(b.name)
|
const patternOptions = patterns.map(pattern => ({
|
||||||
);
|
value: pattern.name,
|
||||||
|
label: pattern.name,
|
||||||
|
description: pattern.description || 'No description available',
|
||||||
|
priority: pattern.priority
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handlePatternChange = e => {
|
||||||
|
onChange({...condition, pattern: e.target.value});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<select
|
<div className="flex-1">
|
||||||
className='flex-1 px-2 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700'
|
<SearchDropdown
|
||||||
value={condition.pattern || ''}
|
value={condition.pattern || ''}
|
||||||
onChange={e => onChange({...condition, pattern: e.target.value})}>
|
onChange={handlePatternChange}
|
||||||
<option value=''>Select release title pattern...</option>
|
options={patternOptions}
|
||||||
{sortedPatterns.map(pattern => (
|
placeholder='Select release title pattern...'
|
||||||
<option key={pattern.name} value={pattern.name}>
|
searchableFields={['label', 'description']}
|
||||||
{pattern.name}
|
className='min-w-[200px]'
|
||||||
</option>
|
width='w-auto'
|
||||||
))}
|
dropdownWidth='100%'
|
||||||
</select>
|
/>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ const RegexCard = ({
|
|||||||
[&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4
|
[&>ul]:list-disc [&>ul]:ml-4 [&>ul]:mt-2 [&>ul]:mb-4
|
||||||
[&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4
|
[&>ol]:list-decimal [&>ol]:ml-4 [&>ol]:mt-2 [&>ol]:mb-4
|
||||||
[&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5
|
[&>ul>li]:mt-0.5 [&>ol>li]:mt-0.5
|
||||||
[&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50'>
|
[&_code]:bg-gray-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded-md [&_code]:text-blue-300 [&_code]:border [&_code]:border-gray-700/50 scrollable'>
|
||||||
<ReactMarkdown>{pattern.description}</ReactMarkdown>
|
<ReactMarkdown>{pattern.description}</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ const RegexTestingTab = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className='text-center py-12 bg-gray-50 dark:bg-gray-800/50 rounded-lg'>
|
<div className='text-center py-12 rounded-lg'>
|
||||||
<p className='text-gray-500 dark:text-gray-400'>
|
<p className='text-gray-500 dark:text-gray-400'>
|
||||||
No tests added yet
|
No tests added yet
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ const SearchBar = ({
|
|||||||
onInputChange,
|
onInputChange,
|
||||||
onAddTerm,
|
onAddTerm,
|
||||||
onRemoveTerm,
|
onRemoveTerm,
|
||||||
onClearTerms
|
onClearTerms,
|
||||||
|
textSize = 'text-sm', // Default text size
|
||||||
|
badgeTextSize = 'text-sm', // Default badge text size
|
||||||
|
iconSize = 'h-4 w-4', // Default icon size
|
||||||
|
minHeight = 'min-h-10' // Default min height
|
||||||
}) => {
|
}) => {
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
@@ -47,7 +51,7 @@ const SearchBar = ({
|
|||||||
<div className={`relative flex-1 min-w-0 group ${className}`}>
|
<div className={`relative flex-1 min-w-0 group ${className}`}>
|
||||||
<Search
|
<Search
|
||||||
className={`
|
className={`
|
||||||
absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4
|
absolute left-3 top-1/2 -translate-y-1/2 ${iconSize}
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
${
|
${
|
||||||
isFocused
|
isFocused
|
||||||
@@ -58,9 +62,14 @@ const SearchBar = ({
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
w-full min-h-10 pl-9 pr-8 rounded-md
|
w-full ${minHeight} pl-9 pr-8 rounded-md
|
||||||
transition-all duration-200 ease-in-out
|
transition-all duration-200 ease-in-out
|
||||||
border shadow-sm flex items-center flex-wrap gap-2 p-2
|
border shadow-sm flex items-center gap-2 p-2
|
||||||
|
${
|
||||||
|
minHeight && minHeight.startsWith('h-')
|
||||||
|
? 'overflow-x-auto overflow-y-hidden whitespace-nowrap'
|
||||||
|
: ''
|
||||||
|
}
|
||||||
${
|
${
|
||||||
isFocused
|
isFocused
|
||||||
? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5'
|
? 'border-blue-500 ring-2 ring-blue-500/20 bg-white/5'
|
||||||
@@ -71,17 +80,24 @@ const SearchBar = ({
|
|||||||
{searchTerms.map((term, index) => (
|
{searchTerms.map((term, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className='flex items-center gap-1.5 px-2 py-1
|
className={`
|
||||||
bg-blue-500/10 dark:bg-blue-500/20
|
flex items-center gap-1.5 px-2
|
||||||
border border-blue-500/20 dark:border-blue-400/20
|
${
|
||||||
text-blue-600 dark:text-blue-400
|
minHeight && minHeight.startsWith('h-')
|
||||||
rounded-md shadow-sm
|
? 'py-0.5'
|
||||||
hover:bg-blue-500/15 dark:hover:bg-blue-500/25
|
: 'py-1'
|
||||||
hover:border-blue-500/30 dark:hover:border-blue-400/30
|
}
|
||||||
group/badge
|
bg-blue-500/10 dark:bg-blue-500/20
|
||||||
transition-all duration-200
|
border border-blue-500/20 dark:border-blue-400/20
|
||||||
'>
|
text-blue-600 dark:text-blue-400
|
||||||
<span className='text-sm font-medium leading-none'>
|
rounded-md shadow-sm
|
||||||
|
hover:bg-blue-500/15 dark:hover:bg-blue-500/25
|
||||||
|
hover:border-blue-500/30 dark:hover:border-blue-400/30
|
||||||
|
group/badge flex-shrink-0
|
||||||
|
transition-all duration-200
|
||||||
|
`}>
|
||||||
|
<span
|
||||||
|
className={`${badgeTextSize} font-medium leading-none`}>
|
||||||
{term}
|
{term}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -106,10 +122,10 @@ const SearchBar = ({
|
|||||||
? 'Add another filter...'
|
? 'Add another filter...'
|
||||||
: placeholder
|
: placeholder
|
||||||
}
|
}
|
||||||
className='flex-1 min-w-[200px] bg-transparent
|
className={`flex-1 min-w-[200px] bg-transparent
|
||||||
text-gray-900 dark:text-gray-100
|
${textSize} text-gray-900 dark:text-gray-100
|
||||||
placeholder:text-gray-500 dark:placeholder:text-gray-400
|
placeholder:text-gray-500 dark:placeholder:text-gray-400
|
||||||
focus:outline-none'
|
focus:outline-none`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -122,7 +138,7 @@ const SearchBar = ({
|
|||||||
hover:bg-gray-100 dark:hover:bg-gray-700
|
hover:bg-gray-100 dark:hover:bg-gray-700
|
||||||
transition-all duration-200
|
transition-all duration-200
|
||||||
group/clear'>
|
group/clear'>
|
||||||
<X className='h-4 w-4' />
|
<X className={iconSize} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
223
frontend/src/components/ui/SearchDropdown.jsx
Normal file
223
frontend/src/components/ui/SearchDropdown.jsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import React, {useEffect, useRef, useState, useCallback} from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import {ChevronDown, Check, Search} from 'lucide-react';
|
||||||
|
import SearchBar from './DataBar/SearchBar';
|
||||||
|
import SortDropdown from './SortDropdown';
|
||||||
|
import useSearch from '@hooks/useSearch';
|
||||||
|
import {useSorting} from '@hooks/useSorting';
|
||||||
|
|
||||||
|
const SearchDropdown = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
width = 'w-full',
|
||||||
|
dropdownWidth,
|
||||||
|
labelKey = 'label',
|
||||||
|
valueKey = 'value',
|
||||||
|
searchableFields = ['label', 'description']
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [selectedOption, setSelectedOption] = useState(
|
||||||
|
options.find(opt => opt[valueKey] === value) || null
|
||||||
|
);
|
||||||
|
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
searchTerms,
|
||||||
|
currentInput,
|
||||||
|
setCurrentInput,
|
||||||
|
addSearchTerm,
|
||||||
|
removeSearchTerm,
|
||||||
|
clearSearchTerms,
|
||||||
|
items: filteredOptions
|
||||||
|
} = useSearch(options, {
|
||||||
|
searchableFields,
|
||||||
|
initialSortBy: 'label'
|
||||||
|
});
|
||||||
|
|
||||||
|
const {sortConfig, updateSort, sortData} = useSorting({
|
||||||
|
field: 'label',
|
||||||
|
direction: 'asc'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort options configuration for the dropdown (name only)
|
||||||
|
const sortOptions = [{value: 'label', label: 'Name (A-Z)'}];
|
||||||
|
|
||||||
|
// Update selected option when value changes externally
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedOption(options.find(opt => opt[valueKey] === value) || null);
|
||||||
|
}, [value, options, valueKey]);
|
||||||
|
|
||||||
|
// Handle dropdown visibility
|
||||||
|
useEffect(() => {
|
||||||
|
// Handle clicks outside dropdown (close the dropdown)
|
||||||
|
const handleClickOutside = event => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Apply final sorting to the filtered results
|
||||||
|
const sortedOptions = useCallback(() => {
|
||||||
|
return sortData(filteredOptions);
|
||||||
|
}, [sortData, filteredOptions]);
|
||||||
|
|
||||||
|
// Handle selection
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
option => {
|
||||||
|
setSelectedOption(option);
|
||||||
|
onChange({target: {value: option[valueKey]}});
|
||||||
|
setIsOpen(false);
|
||||||
|
},
|
||||||
|
[onChange, valueKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${width}`} ref={dropdownRef}>
|
||||||
|
{/* Selected Value Button */}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={`w-full flex items-center justify-between px-3 py-2 text-sm
|
||||||
|
border rounded-md
|
||||||
|
bg-gray-700 border-gray-600 text-gray-100 hover:border-gray-500
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||||
|
transition-colors ${className}`}>
|
||||||
|
<span className='truncate'>
|
||||||
|
{selectedOption
|
||||||
|
? selectedOption[labelKey]
|
||||||
|
: placeholder || 'Select option...'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`w-4 h-4 ml-2 transition-transform ${
|
||||||
|
isOpen ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
ref={menuRef}
|
||||||
|
className='absolute z-50 mt-1
|
||||||
|
bg-gray-800 border border-gray-700 rounded-md shadow-lg
|
||||||
|
flex flex-col overflow-hidden'
|
||||||
|
style={{
|
||||||
|
width: dropdownWidth || '650px',
|
||||||
|
maxHeight: '700px',
|
||||||
|
left: '0'
|
||||||
|
}}>
|
||||||
|
<div className='p-3 bg-gray-800 shadow-sm relative'>
|
||||||
|
<div className='absolute left-0 right-0 bottom-0 h-px bg-gray-700/50'></div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<div className='flex-grow'>
|
||||||
|
<SearchBar
|
||||||
|
placeholder='Search options...'
|
||||||
|
searchTerms={searchTerms}
|
||||||
|
currentInput={currentInput}
|
||||||
|
onInputChange={setCurrentInput}
|
||||||
|
onAddTerm={addSearchTerm}
|
||||||
|
onRemoveTerm={removeSearchTerm}
|
||||||
|
onClearTerms={clearSearchTerms}
|
||||||
|
requireEnter={true}
|
||||||
|
textSize='text-xs'
|
||||||
|
badgeTextSize='text-xs'
|
||||||
|
iconSize='h-3.5 w-3.5'
|
||||||
|
minHeight='h-8'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<SortDropdown
|
||||||
|
sortOptions={sortOptions}
|
||||||
|
currentSort={sortConfig}
|
||||||
|
onSortChange={updateSort}
|
||||||
|
className='flex-shrink-0'
|
||||||
|
textSize='text-xs'
|
||||||
|
menuTextSize='text-xs'
|
||||||
|
iconSize={14}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options List */}
|
||||||
|
<div className='flex-1 p-2 pt-3 overflow-auto'>
|
||||||
|
{sortedOptions().length > 0 ? (
|
||||||
|
<div className='flex flex-col'>
|
||||||
|
{sortedOptions().map(option => (
|
||||||
|
<div
|
||||||
|
key={option[valueKey]}
|
||||||
|
onClick={() => handleSelect(option)}
|
||||||
|
className={`px-2.5 py-1.5 text-xs cursor-pointer rounded
|
||||||
|
${
|
||||||
|
selectedOption?.[valueKey] ===
|
||||||
|
option[valueKey]
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'text-gray-100 hover:bg-gray-700'
|
||||||
|
}`}>
|
||||||
|
<div className='flex items-center'>
|
||||||
|
<div className='flex-grow truncate'>
|
||||||
|
{option[labelKey]}
|
||||||
|
</div>
|
||||||
|
{selectedOption?.[valueKey] ===
|
||||||
|
option[valueKey] && (
|
||||||
|
<Check className='w-3.5 h-3.5 ml-1.5 flex-shrink-0' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='px-3 py-12 text-center'>
|
||||||
|
<div className='bg-gray-700/30 rounded-lg p-4 max-w-xs mx-auto'>
|
||||||
|
<Search className='w-6 h-6 mb-2 mx-auto text-gray-500 opacity-40' />
|
||||||
|
<p className='text-gray-300 text-xs'>
|
||||||
|
No options match your search
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={clearSearchTerms}
|
||||||
|
className='mt-2 text-xs text-blue-400 hover:underline'>
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
SearchDropdown.propTypes = {
|
||||||
|
value: PropTypes.string,
|
||||||
|
onChange: PropTypes.func.isRequired,
|
||||||
|
options: PropTypes.arrayOf(
|
||||||
|
PropTypes.shape({
|
||||||
|
value: PropTypes.string.isRequired,
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
description: PropTypes.string
|
||||||
|
})
|
||||||
|
).isRequired,
|
||||||
|
placeholder: PropTypes.string,
|
||||||
|
className: PropTypes.string,
|
||||||
|
width: PropTypes.string,
|
||||||
|
dropdownWidth: PropTypes.string,
|
||||||
|
labelKey: PropTypes.string,
|
||||||
|
valueKey: PropTypes.string,
|
||||||
|
searchableFields: PropTypes.arrayOf(PropTypes.string)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchDropdown;
|
||||||
@@ -7,7 +7,10 @@ const SortDropdown = ({
|
|||||||
sortOptions,
|
sortOptions,
|
||||||
currentSort,
|
currentSort,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
className = ''
|
className = '',
|
||||||
|
textSize = 'text-sm', // Default text size
|
||||||
|
menuTextSize = 'text-xs', // Default menu text size
|
||||||
|
iconSize = 16 // Default icon size
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
@@ -30,7 +33,7 @@ const SortDropdown = ({
|
|||||||
onClick={toggleDropdown}
|
onClick={toggleDropdown}
|
||||||
className={`
|
className={`
|
||||||
inline-flex items-center justify-between
|
inline-flex items-center justify-between
|
||||||
min-h-[40px] px-4 py-2 text-sm
|
px-4 py-2 ${textSize}
|
||||||
bg-white dark:bg-gray-800
|
bg-white dark:bg-gray-800
|
||||||
border border-gray-300 dark:border-gray-700
|
border border-gray-300 dark:border-gray-700
|
||||||
text-gray-900 dark:text-gray-100
|
text-gray-900 dark:text-gray-100
|
||||||
@@ -42,9 +45,9 @@ const SortDropdown = ({
|
|||||||
<span className='flex items-center gap-2'>
|
<span className='flex items-center gap-2'>
|
||||||
{getCurrentSortLabel()}
|
{getCurrentSortLabel()}
|
||||||
{currentSort.direction === 'asc' ? (
|
{currentSort.direction === 'asc' ? (
|
||||||
<ArrowUp size={16} />
|
<ArrowUp size={iconSize} />
|
||||||
) : (
|
) : (
|
||||||
<ArrowDown size={16} />
|
<ArrowDown size={iconSize} />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -63,17 +66,17 @@ const SortDropdown = ({
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
type='button'
|
type='button'
|
||||||
onClick={() => handleSortClick(option.value)}
|
onClick={() => handleSortClick(option.value)}
|
||||||
className='
|
className={`
|
||||||
flex items-center justify-between w-full px-4 py-2
|
flex items-center justify-between w-full px-4 py-2
|
||||||
text-xs text-gray-700 dark:text-gray-200
|
${menuTextSize} text-gray-700 dark:text-gray-200
|
||||||
hover:bg-gray-50 dark:hover:bg-gray-700
|
hover:bg-gray-50 dark:hover:bg-gray-700
|
||||||
'>
|
`}>
|
||||||
<span>{option.label}</span>
|
<span>{option.label}</span>
|
||||||
{currentSort.field === option.value &&
|
{currentSort.field === option.value &&
|
||||||
(currentSort.direction === 'asc' ? (
|
(currentSort.direction === 'asc' ? (
|
||||||
<ArrowUp size={16} />
|
<ArrowUp size={iconSize} />
|
||||||
) : (
|
) : (
|
||||||
<ArrowDown size={16} />
|
<ArrowDown size={iconSize} />
|
||||||
))}
|
))}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user