make the dropdown list with footer a reusable component

This commit is contained in:
EthanHealy01 2025-07-30 19:49:08 +01:00
parent f958311fa2
commit f033200ba9
2 changed files with 288 additions and 80 deletions

View File

@ -0,0 +1,252 @@
import React, { ReactNode, useState, useMemo } from 'react';
import { Stack, Text, Popover, Box, Checkbox, Group, TextInput, useMantineColorScheme } from '@mantine/core';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import SearchIcon from '@mui/icons-material/Search';
export interface DropdownItem {
value: string;
name: string;
leftIcon?: ReactNode;
disabled?: boolean;
}
export interface DropdownListWithFooterProps {
// Value and onChange - support both single and multi-select
value: string | string[];
onChange: (value: string | string[]) => void;
// Items and display
items: DropdownItem[];
placeholder?: string;
disabled?: boolean;
// Labels and headers
label?: string;
header?: ReactNode;
footer?: ReactNode;
// Behavior
multiSelect?: boolean;
searchable?: boolean;
maxHeight?: number;
// Styling
className?: string;
dropdownClassName?: string;
// Popover props
position?: 'top' | 'bottom' | 'left' | 'right';
withArrow?: boolean;
width?: 'target' | number;
}
const DropdownListWithFooter: React.FC<DropdownListWithFooterProps> = ({
value,
onChange,
items,
placeholder = 'Select option',
disabled = false,
label,
header,
footer,
multiSelect = false,
searchable = false,
maxHeight = 300,
className = '',
dropdownClassName = '',
position = 'bottom',
withArrow = false,
width = 'target'
}) => {
const [searchTerm, setSearchTerm] = useState('');
const { colorScheme } = useMantineColorScheme();
const isMultiValue = Array.isArray(value);
const selectedValues = isMultiValue ? value : (value ? [value] : []);
// Filter items based on search term
const filteredItems = useMemo(() => {
if (!searchable || !searchTerm.trim()) {
return items;
}
return items.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}, [items, searchTerm, searchable]);
const handleItemClick = (itemValue: string) => {
if (multiSelect) {
const newSelection = selectedValues.includes(itemValue)
? selectedValues.filter(v => v !== itemValue)
: [...selectedValues, itemValue];
onChange(newSelection);
} else {
onChange(itemValue);
}
};
const getDisplayText = () => {
if (selectedValues.length === 0) {
return placeholder;
} else if (selectedValues.length === 1) {
const selectedItem = items.find(item => item.value === selectedValues[0]);
return selectedItem?.name || selectedValues[0];
} else {
return `${selectedValues.length} selected`;
}
};
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.currentTarget.value);
};
return (
<Box className={className}>
{label && (
<Text size="sm" fw={500} mb={4}>
{label}
</Text>
)}
<Popover
width={width}
position={position}
withArrow={withArrow}
shadow="md"
onClose={() => searchable && setSearchTerm('')}
>
<Popover.Target>
<Box
style={{
border: colorScheme === 'dark'
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-3)',
borderRadius: 'var(--mantine-radius-sm)',
padding: '8px 12px',
backgroundColor: colorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-white)',
opacity: disabled ? 0.6 : 1,
cursor: disabled ? 'not-allowed' : 'pointer',
minHeight: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
>
<Text size="sm" style={{ flex: 1 }}>
{getDisplayText()}
</Text>
<UnfoldMoreIcon style={{
fontSize: '1rem',
color: colorScheme === 'dark'
? 'var(--mantine-color-dark-2)'
: 'var(--mantine-color-gray-5)'
}} />
</Box>
</Popover.Target>
<Popover.Dropdown className={dropdownClassName}>
<Stack gap="xs">
{header && (
<Box style={{
borderBottom: colorScheme === 'dark'
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-2)',
paddingBottom: '8px'
}}>
{header}
</Box>
)}
{searchable && (
<Box style={{
borderBottom: colorScheme === 'dark'
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-2)',
paddingBottom: '8px'
}}>
<TextInput
placeholder="Search..."
value={searchTerm}
onChange={handleSearchChange}
leftSection={<SearchIcon style={{ fontSize: '1rem' }} />}
size="sm"
style={{ width: '100%' }}
/>
</Box>
)}
<Box style={{ maxHeight, overflowY: 'auto' }}>
{filteredItems.length === 0 ? (
<Box style={{ padding: '12px', textAlign: 'center' }}>
<Text size="sm" c="dimmed">
{searchable && searchTerm ? 'No results found' : 'No items available'}
</Text>
</Box>
) : (
filteredItems.map((item) => (
<Box
key={item.value}
onClick={() => !item.disabled && handleItemClick(item.value)}
style={{
padding: '8px 12px',
cursor: item.disabled ? 'not-allowed' : 'pointer',
borderRadius: 'var(--mantine-radius-sm)',
opacity: item.disabled ? 0.5 : 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
}}
onMouseEnter={(e) => {
if (!item.disabled) {
e.currentTarget.style.backgroundColor = colorScheme === 'dark'
? 'var(--mantine-color-dark-5)'
: 'var(--mantine-color-gray-0)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
<Group gap="sm" style={{ flex: 1 }}>
{item.leftIcon && (
<Box style={{ display: 'flex', alignItems: 'center' }}>
{item.leftIcon}
</Box>
)}
<Text size="sm">{item.name}</Text>
</Group>
{multiSelect && (
<Checkbox
checked={selectedValues.includes(item.value)}
onChange={() => {}} // Handled by parent onClick
size="sm"
disabled={item.disabled}
/>
)}
</Box>
))
)}
</Box>
{footer && (
<Box style={{
borderTop: colorScheme === 'dark'
? '1px solid var(--mantine-color-dark-4)'
: '1px solid var(--mantine-color-gray-2)',
paddingTop: '8px'
}}>
{footer}
</Box>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Box>
);
};
export default DropdownListWithFooter;

View File

@ -1,9 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Stack, Text, Loader, Popover, Box, Checkbox } from '@mantine/core';
import { Text, Loader, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { tempOcrLanguages } from '../../../utils/tempOcrLanguages';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import styles from './LanguagePicker.module.css';
import DropdownListWithFooter, { DropdownItem } from '../../shared/DropdownListWithFooter';
export interface LanguageOption {
value: string;
@ -28,7 +27,8 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
languagesEndpoint = '/api/v1/ui-data/ocr-pdf'
}) => {
const { t } = useTranslation();
const [availableLanguages, setAvailableLanguages] = useState<LanguageOption[]>([]);
const { colorScheme } = useMantineColorScheme();
const [availableLanguages, setAvailableLanguages] = useState<DropdownItem[]>([]);
const [isLoadingLanguages, setIsLoadingLanguages] = useState(true);
useEffect(() => {
@ -51,7 +51,7 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
return {
value: lang,
label: displayName
name: displayName
};
});
@ -85,83 +85,39 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
);
}
const handleLanguageToggle = (languageValue: string) => {
const newSelection = value.includes(languageValue)
? value.filter(v => v !== languageValue)
: [...value, languageValue];
onChange(newSelection);
};
const getDisplayText = () => {
if (value.length === 0) {
return placeholder;
} else if (value.length === 1) {
const selectedLanguage = availableLanguages.find(lang => lang.value === value[0]);
return selectedLanguage?.label || value[0];
} else {
return `${value.length} languages selected`;
}
};
return (
<Box>
{label && (
<Text size="sm" fw={500} mb={4}>
{label}
</Text>
)}
<Popover width="target" position="bottom" withArrow={false} shadow="md">
<Popover.Target>
<Box
className={`${styles.languagePicker} ${disabled ? '' : ''}`}
style={{
opacity: disabled ? 0.6 : 1,
cursor: disabled ? 'not-allowed' : 'pointer'
}}
>
<div className={styles.languagePickerContent}>
<Text size="sm" className={styles.languagePickerText}>
{getDisplayText()}
</Text>
<UnfoldMoreIcon className={styles.languagePickerIcon} />
</div>
</Box>
</Popover.Target>
<Popover.Dropdown className={styles.languagePickerDropdown}>
<Stack gap="xs">
<Box className={styles.languagePickerScrollArea}>
{availableLanguages.map((lang) => (
<Box
key={lang.value}
className={`${styles.languagePickerOption} ${styles.languagePickerOptionWithCheckbox}`}
onClick={() => handleLanguageToggle(lang.value)}
>
<Text size="sm">{lang.label}</Text>
<Checkbox
checked={value.includes(lang.value)}
onChange={() => {}} // Handled by parent onClick
className={styles.languagePickerCheckbox}
size="sm"
/>
</Box>
))}
</Box>
<Box className={styles.languagePickerFooter}>
const footer = (
<>
<Text size="xs" c="dimmed" mb={4}>
{t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')}
</Text>
<Text
size="xs"
className={styles.languagePickerLink}
style={{
color: colorScheme === 'dark'
? 'var(--mantine-color-blue-4)'
: 'var(--mantine-color-blue-6)',
cursor: 'pointer',
textDecoration: 'underline'
}}
onClick={() => window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')}
>
{t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')}
</Text>
</Box>
</Stack>
</Popover.Dropdown>
</Popover>
</Box>
</>
);
return (
<DropdownListWithFooter
value={value}
onChange={(newValue) => onChange(newValue as string[])}
items={availableLanguages}
placeholder={placeholder}
disabled={disabled}
label={label}
footer={footer}
multiSelect={true}
maxHeight={300}
/>
);
};