mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-02 18:45:21 +00:00
make the dropdown list with footer a reusable component
This commit is contained in:
parent
f958311fa2
commit
f033200ba9
252
frontend/src/components/shared/DropdownListWithFooter.tsx
Normal file
252
frontend/src/components/shared/DropdownListWithFooter.tsx
Normal 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;
|
@ -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`;
|
||||
}
|
||||
};
|
||||
const footer = (
|
||||
<>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
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>
|
||||
</>
|
||||
);
|
||||
|
||||
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}>
|
||||
<Text size="xs" c="dimmed" mb={4}>
|
||||
{t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')}
|
||||
</Text>
|
||||
<Text
|
||||
size="xs"
|
||||
className={styles.languagePickerLink}
|
||||
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>
|
||||
<DropdownListWithFooter
|
||||
value={value}
|
||||
onChange={(newValue) => onChange(newValue as string[])}
|
||||
items={availableLanguages}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
label={label}
|
||||
footer={footer}
|
||||
multiSelect={true}
|
||||
maxHeight={300}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user