From f033200ba98a62d667170416d602345abbc6d570 Mon Sep 17 00:00:00 2001 From: EthanHealy01 Date: Wed, 30 Jul 2025 19:49:08 +0100 Subject: [PATCH] make the dropdown list with footer a reusable component --- .../shared/DropdownListWithFooter.tsx | 252 ++++++++++++++++++ .../components/tools/ocr/LanguagePicker.tsx | 116 +++----- 2 files changed, 288 insertions(+), 80 deletions(-) create mode 100644 frontend/src/components/shared/DropdownListWithFooter.tsx diff --git a/frontend/src/components/shared/DropdownListWithFooter.tsx b/frontend/src/components/shared/DropdownListWithFooter.tsx new file mode 100644 index 000000000..fa99c44e8 --- /dev/null +++ b/frontend/src/components/shared/DropdownListWithFooter.tsx @@ -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 = ({ + 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) => { + setSearchTerm(event.currentTarget.value); + }; + + return ( + + {label && ( + + {label} + + )} + + searchable && setSearchTerm('')} + > + + + + {getDisplayText()} + + + + + + + + {header && ( + + {header} + + )} + + {searchable && ( + + } + size="sm" + style={{ width: '100%' }} + /> + + )} + + + {filteredItems.length === 0 ? ( + + + {searchable && searchTerm ? 'No results found' : 'No items available'} + + + ) : ( + filteredItems.map((item) => ( + !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'; + }} + > + + {item.leftIcon && ( + + {item.leftIcon} + + )} + {item.name} + + + {multiSelect && ( + {}} // Handled by parent onClick + size="sm" + disabled={item.disabled} + /> + )} + + )) + )} + + + {footer && ( + + {footer} + + )} + + + + + ); +}; + +export default DropdownListWithFooter; \ No newline at end of file diff --git a/frontend/src/components/tools/ocr/LanguagePicker.tsx b/frontend/src/components/tools/ocr/LanguagePicker.tsx index 34ba25454..a68e33202 100644 --- a/frontend/src/components/tools/ocr/LanguagePicker.tsx +++ b/frontend/src/components/tools/ocr/LanguagePicker.tsx @@ -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 = ({ languagesEndpoint = '/api/v1/ui-data/ocr-pdf' }) => { const { t } = useTranslation(); - const [availableLanguages, setAvailableLanguages] = useState([]); + const { colorScheme } = useMantineColorScheme(); + const [availableLanguages, setAvailableLanguages] = useState([]); const [isLoadingLanguages, setIsLoadingLanguages] = useState(true); useEffect(() => { @@ -51,7 +51,7 @@ const LanguagePicker: React.FC = ({ return { value: lang, - label: displayName + name: displayName }; }); @@ -85,83 +85,39 @@ const LanguagePicker: React.FC = ({ ); } - 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 = ( + <> + + {t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')} + + window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')} + > + {t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')} + + + ); return ( - - {label && ( - - {label} - - )} - - - -
- - {getDisplayText()} - - -
-
-
- - - - {availableLanguages.map((lang) => ( - handleLanguageToggle(lang.value)} - > - {lang.label} - {}} // Handled by parent onClick - className={styles.languagePickerCheckbox} - size="sm" - /> - - ))} - - - - {t('ocr.languagePicker.additionalLanguages', 'Looking for additional languages?')} - - window.open('https://docs.stirlingpdf.com/Advanced%20Configuration/OCR', '_blank')} - > - {t('ocr.languagePicker.viewSetupGuide', 'View setup guide →')} - - - - -
-
+ onChange(newValue as string[])} + items={availableLanguages} + placeholder={placeholder} + disabled={disabled} + label={label} + footer={footer} + multiSelect={true} + maxHeight={300} + /> ); };