import type { ColumnStats, DateFormat, DateRange, NumberFormat, NumericRange } from '@smallwebco/tinypivot-core' /** * Column Filter Dropdown Component for React * Shows unique values with checkboxes, search, and sort controls * For numeric and date columns, also provides a range filter option */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { DateRangeFilter } from './DateRangeFilter' import { NumericRangeFilter } from './NumericRangeFilter' type FilterMode = 'values' | 'range' interface ColumnFilterProps { columnId: string columnName: string stats: ColumnStats selectedValues: string[] sortDirection: 'asc' | 'desc' | null /** Current numeric range filter (if any) */ numericRange?: NumericRange | null /** Current date range filter (if any) */ dateRange?: DateRange | null /** Number display format */ numberFormat?: NumberFormat /** Date display format */ dateFormat?: DateFormat onFilter: (values: string[]) => void onSort: (direction: 'asc' | 'desc' | null) => void onClose: () => void /** Called when a numeric range filter is applied */ onRangeFilter?: (range: NumericRange | null) => void /** Called when a date range filter is applied */ onDateRangeFilter?: (range: DateRange | null) => void } export function ColumnFilter({ columnName, stats, selectedValues, sortDirection, numericRange, dateRange, numberFormat, dateFormat, onFilter, onSort, onClose, onRangeFilter, onDateRangeFilter, }: ColumnFilterProps) { const [searchQuery, setSearchQuery] = useState('') const [localSelected, setLocalSelected] = useState>(new Set(selectedValues)) const dropdownRef = useRef(null) const searchInputRef = useRef(null) // Filter mode (values vs range) - available for numeric and date columns const isNumericColumn = stats.type === 'number' && stats.numericMin !== undefined && stats.numericMax !== undefined const isDateColumn = stats.type === 'date' && stats.dateMin !== undefined && stats.dateMax !== undefined // Determine initial mode based on existing filters const [filterMode, setFilterMode] = useState( numericRange || dateRange ? 'range' : 'values', ) // Local range for the numeric filter const [localRange, setLocalRange] = useState(numericRange ?? null) // Local date range for the date filter const [localDateRange, setLocalDateRange] = useState(dateRange ?? null) // Include blank option if there are null values const hasBlankValues = stats.nullCount > 0 // Filtered unique values based on search const filteredValues = useMemo(() => { const values = stats.uniqueValues if (!searchQuery) return values const query = searchQuery.toLowerCase() return values.filter(v => v.toLowerCase().includes(query)) }, [stats.uniqueValues, searchQuery]) // All values including blank const allValues = useMemo(() => { const values = [...filteredValues] if (hasBlankValues && (!searchQuery || '(blank)'.includes(searchQuery.toLowerCase()))) { values.unshift('(blank)') } return values }, [filteredValues, hasBlankValues, searchQuery]) // Check states (kept for potential future use) const _isAllSelected = useMemo( () => allValues.every(v => localSelected.has(v)), [allValues, localSelected], ) // Toggle single value const toggleValue = useCallback((value: string) => { setLocalSelected((prev) => { const next = new Set(prev) if (next.has(value)) { next.delete(value) } else { next.add(value) } return next }) }, []) // Select all visible const selectAll = useCallback(() => { setLocalSelected((prev) => { const next = new Set(prev) for (const value of allValues) { next.add(value) } return next }) }, [allValues]) // Clear all const clearAll = useCallback(() => { setLocalSelected(new Set()) }, []) // Apply filter const applyFilter = useCallback(() => { if (localSelected.size === 0) { onFilter([]) } else { onFilter(Array.from(localSelected)) } onClose() }, [localSelected, onFilter, onClose]) // Sort handlers const sortAscending = useCallback(() => { onSort(sortDirection === 'asc' ? null : 'asc') }, [sortDirection, onSort]) const sortDescending = useCallback(() => { onSort(sortDirection === 'desc' ? null : 'desc') }, [sortDirection, onSort]) // Clear filter only const clearFilter = useCallback(() => { setLocalSelected(new Set()) onFilter([]) onClose() }, [onFilter, onClose]) // Handle range filter change from the NumericRangeFilter component const handleRangeChange = useCallback((range: NumericRange | null) => { setLocalRange(range) }, []) // Apply the range filter const applyRangeFilter = useCallback(() => { onRangeFilter?.(localRange) onClose() }, [localRange, onRangeFilter, onClose]) // Clear range filter const clearRangeFilter = useCallback(() => { setLocalRange(null) onRangeFilter?.(null) onClose() }, [onRangeFilter, onClose]) // Handle date range filter change from the DateRangeFilter component const handleDateRangeChange = useCallback((range: DateRange | null) => { setLocalDateRange(range) }, []) // Apply the date range filter const applyDateRangeFilter = useCallback(() => { onDateRangeFilter?.(localDateRange) onClose() }, [localDateRange, onDateRangeFilter, onClose]) // Clear date range filter const clearDateRangeFilter = useCallback(() => { setLocalDateRange(null) onDateRangeFilter?.(null) onClose() }, [onDateRangeFilter, onClose]) // Click outside handler useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { onClose() } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [onClose]) // Keyboard handling useEffect(() => { const handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape') { onClose() } else if (event.key === 'Enter' && event.ctrlKey) { applyFilter() } } document.addEventListener('keydown', handleKeydown) return () => document.removeEventListener('keydown', handleKeydown) }, [onClose, applyFilter]) // Focus search on mount useEffect(() => { searchInputRef.current?.focus() }, []) // Sync with props useEffect(() => { setLocalSelected(new Set(selectedValues)) }, [selectedValues]) // Sync numeric range with props useEffect(() => { setLocalRange(numericRange ?? null) if (numericRange) { setFilterMode('range') } }, [numericRange]) // Sync date range with props useEffect(() => { setLocalDateRange(dateRange ?? null) if (dateRange) { setFilterMode('range') } }, [dateRange]) return (
{/* Header */}
{columnName} {stats.uniqueValues.length.toLocaleString()} {' '} unique
{/* Sort Controls */}
{/* Filter Mode Tabs (for numeric and date columns) */} {(isNumericColumn || isDateColumn) && (
)} {/* Values Filter Mode */} {((!isNumericColumn && !isDateColumn) || filterMode === 'values') && ( <> {/* Search */}
setSearchQuery(e.target.value)} placeholder="Search values..." className="vpg-search-input" /> {searchQuery && ( )}
{/* Select All / Clear All */}
{/* Values List */}
{allValues.map(value => ( ))} {allValues.length === 0 &&
No matching values
}
{/* Footer for Values Mode */}
)} {/* Numeric Range Filter Mode */} {isNumericColumn && filterMode === 'range' && ( <> {/* Footer for Range Mode */}
)} {/* Date Range Filter Mode */} {isDateColumn && filterMode === 'range' && ( <> {/* Footer for Date Range Mode */}
)}
) }