Stirling-PDF/frontend/src/components/shared/DropdownListWithFooter.tsx

237 lines
7.4 KiB
TypeScript
Raw Normal View History

Tools/ocr/v2 (#4055) # Description of Changes - Added the OCR tool - Added language mappings file to map selected browser language -> OCR language and OCR language codes -> english display values. TODO: Use the translation function to translate the languages rather than mapping them to english be default - Added chevron icons to tool step to show expand and collapsed state more visibly - Added a re-usable dropdown picker with a footer component --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-08-01 14:22:19 +01:00
import React, { ReactNode, useState, useMemo } from 'react';
import { Stack, Text, Popover, Box, Checkbox, Group, TextInput } 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 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: 'light-dark(1px solid var(--mantine-color-gray-3), 1px solid var(--mantine-color-dark-4))',
borderRadius: 'var(--mantine-radius-sm)',
padding: '8px 12px',
backgroundColor: 'light-dark(var(--mantine-color-white), var(--mantine-color-dark-6))',
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: 'light-dark(var(--mantine-color-gray-5), var(--mantine-color-dark-2))'
}} />
</Box>
</Popover.Target>
<Popover.Dropdown className={dropdownClassName}>
<Stack gap="xs">
{header && (
<Box style={{
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingBottom: '8px'
}}>
{header}
</Box>
)}
{searchable && (
<Box style={{
borderBottom: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
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 = 'light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5))';
}
}}
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: 'light-dark(1px solid var(--mantine-color-gray-2), 1px solid var(--mantine-color-dark-4))',
paddingTop: '8px'
}}>
{footer}
</Box>
)}
</Stack>
</Popover.Dropdown>
</Popover>
</Box>
);
};
export default DropdownListWithFooter;