import React, { useState, useEffect, useRef } from 'react' import { useVirtualizer } from '@tanstack/react-virtual' import type { VirtualItem } from '@tanstack/react-virtual' import { ArrowUp, ArrowDown, Search, ChevronRight, Check } from 'lucide-react' import { TextAUnderlineIcon, TrashSimpleIcon, MathOperationsIcon, ArrowLineLeftIcon, ArrowLineRightIcon, FunnelSimpleIcon, } from '@phosphor-icons/react' import Block from '../../Primitives/Block/Block' import PrimitiveText from '../../Primitives/PrimitiveText/PrimitiveText' import { SearchInput } from '../../Inputs/SearchInput' import Slider from '../../Slider/Slider' import { SliderSize, SliderValueType } from '../../Slider/types' import { ColumnDefinition, FilterType, SortDirection } from '../types' import { TableTokenType } from '../dataTable.tokens' import { SortHandlers, FilterHandlers, FilterState, ColumnFilterHandler, SortState, } from './handlers' import { getSelectMenuItems, getMultiSelectMenuItems, filterItemsBySearch, getColumnTypeConfigForColumn, } from './utils' import { FOUNDATION_THEME } from '../../../tokens' import { useBreakpoints } from '../../../hooks/useBreakPoints' import { useTheme } from '../../../context/ThemeContext' import { BREAKPOINTS } from '../../../breakpoints/breakPoints' import { Popover } from '../../Popover' import MobileFilterDrawer from './MobileFilterDrawer' import { Checkbox } from '../../Checkbox' import { CheckboxSize } from '../../Checkbox/types' import { VirtualListItem } from '../../VirtualList' import DateRangePicker from '../../DateRangePicker/DateRangePicker' import { DateRange } from '../../DateRangePicker/types' import { parseDateLike, toLocalDateString } from '../utils' const FILTER_VIRTUAL_ITEM_ESTIMATE_HEIGHT = 40 const FILTER_VIRTUAL_LIST_MAX_HEIGHT = 220 type FilterComponentsProps = { column: ColumnDefinition> data?: Record[] tableToken: TableTokenType sortHandlers: SortHandlers filterHandlers: FilterHandlers filterState: FilterState sortState: SortState /** When false, no filter UI is offered — a filter here could not affect rows. */ enableFiltering?: boolean onColumnFilter?: ColumnFilterHandler onRenameHeader?: () => void onOperations?: () => void onInsertLeft?: () => void onInsertRight?: () => void onDeleteColumn?: () => void onPopoverClose?: () => void onFilterApplied?: () => void } /** Reusable menu item for the column header popover */ type MenuItemProps = { icon: React.ReactNode label: string onClick?: (e: React.MouseEvent) => void /** * Default true. Set false when MenuItem is used * as a Radix `asChild` trigger (Popover, etc). */ stopPropagationOnClick?: boolean trailingIcon?: React.ReactNode isDestructive?: boolean tableToken: TableTokenType } & Omit, 'onClick' | 'onKeyDown'> const MenuItem = React.forwardRef( ( { icon, label, onClick, stopPropagationOnClick = true, trailingIcon, isDestructive, tableToken, ...props }, ref ) => { const { theme } = useTheme() const destructiveColor = theme === 'dark' ? FOUNDATION_THEME.colors.red[400] : FOUNDATION_THEME.colors.red[500] const interactiveHandlers = onClick ? { onClick: (e: React.MouseEvent) => { if (stopPropagationOnClick) e.stopPropagation() onClick(e) }, onKeyDown: (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() if (stopPropagationOnClick) e.stopPropagation() onClick( e as unknown as React.MouseEvent ) } }, } : {} return ( {icon} {label} {trailingIcon} ) } ) const renderSortOption = ( tableToken: TableTokenType, icon: React.ReactNode, label: string, onClick: () => void, isActive: boolean, checkColor?: string ) => ( { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() e.stopPropagation() onClick() } }} tabIndex={0} role="menuitem" _focus={{ outline: 'none' }} _focusVisible={{ outline: tableToken.header.actionIcons.columnManagerTrigger?.focusVisible .outline, outlineOffset: '2px', }} > {icon} {label} {isActive && ( )} ) export const SortOptions: React.FC<{ column: ColumnDefinition> fieldKey: string tableToken: TableTokenType sortHandlers: SortHandlers sortState: SortState onFilterApplied?: () => void onPopoverClose?: () => void }> = ({ column, fieldKey, tableToken, sortHandlers, sortState, onFilterApplied, onPopoverClose, }) => { const hasDeltaSort = column.isDeltaSortable === true && !!column.getSortField const isCurrentField = sortState.currentSortField === fieldKey const currentDirection = isCurrentField ? sortState.currentSortDirection : SortDirection.NONE const currentSortType = sortState.currentSortType const isPrimaryAscendingActive = isCurrentField && currentDirection === SortDirection.ASCENDING && (!currentSortType || currentSortType === 'primary') const isPrimaryDescendingActive = isCurrentField && currentDirection === SortDirection.DESCENDING && (!currentSortType || currentSortType === 'primary') const isDeltaAscendingActive = isCurrentField && currentDirection === SortDirection.ASCENDING && currentSortType === 'delta' const isDeltaDescendingActive = isCurrentField && currentDirection === SortDirection.DESCENDING && currentSortType === 'delta' return ( {renderSortOption( tableToken, , 'Sort Ascending', () => { sortHandlers.handleSortAscending(fieldKey, 'primary') onFilterApplied?.() onPopoverClose?.() }, isPrimaryAscendingActive, tableToken.dataTable.table.header.filter.sortOption.textColor )} {renderSortOption( tableToken, , 'Sort Descending', () => { sortHandlers.handleSortDescending(fieldKey, 'primary') onFilterApplied?.() onPopoverClose?.() }, isPrimaryDescendingActive, tableToken.dataTable.table.header.filter.sortOption.textColor )} {hasDeltaSort && ( <> Delta {renderSortOption( tableToken, , 'Sort Ascending', () => { sortHandlers.handleSortAscending(fieldKey, 'delta') onFilterApplied?.() onPopoverClose?.() }, isDeltaAscendingActive, tableToken.dataTable.table.header.filter.sortOption .textColor )} {renderSortOption( tableToken, , 'Sort Descending', () => { sortHandlers.handleSortDescending(fieldKey, 'delta') onFilterApplied?.() onPopoverClose?.() }, isDeltaDescendingActive, tableToken.dataTable.table.header.filter.sortOption .textColor )} )} ) } export const DropdownSearchSection: React.FC<{ column: ColumnDefinition> fieldKey: string tableToken: TableTokenType filterHandlers: FilterHandlers filterState: FilterState }> = ({ fieldKey, tableToken, filterHandlers, filterState }) => ( } onChange={(e: React.ChangeEvent) => { const value = e.target.value filterHandlers.handleSearchChange(fieldKey, value) }} /> ) export const Separator: React.FC<{ tableToken: TableTokenType }> = ({ tableToken, }) => ( ) export const SingleSelectItems: React.FC<{ column: ColumnDefinition> fieldKey: string tableToken: TableTokenType filterHandlers: FilterHandlers filterState: FilterState data?: Record[] onColumnFilter?: ColumnFilterHandler onPopoverClose?: () => void }> = ({ column, fieldKey, tableToken, filterHandlers, filterState, data, onColumnFilter, onPopoverClose, }) => { const menuItems = getSelectMenuItems(column, data) const filterSearch = menuItems.map((group) => filterItemsBySearch( group.items, filterState.columnSearchValues[fieldKey] || '' ) ) const items: VirtualListItem[] = filterSearch[0].map( (item: { label: string; value: string }) => ({ id: item.value, label: item.label, value: item.value, }) ) const virtualScrollRef = useRef(null) const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => virtualScrollRef.current, estimateSize: () => FILTER_VIRTUAL_ITEM_ESTIMATE_HEIGHT, overscan: 5, getItemKey: (index: number) => items[index]?.id ?? index, }) const virtualItems = virtualizer.getVirtualItems() const viewportHeight = Math.min( FILTER_VIRTUAL_LIST_MAX_HEIGHT, Math.max(items.length * FILTER_VIRTUAL_ITEM_ESTIMATE_HEIGHT, 0) ) return ( {items.length === 0 ? ( No results found ) : ( {virtualItems.map((virtualItem: VirtualItem) => { const item = items[virtualItem.index] const value = item.value as string const label = item.label as React.ReactNode const selectedValues = filterState.columnSelectedValues[fieldKey] const currentSelected = Array.isArray( selectedValues ) ? selectedValues[0] : typeof selectedValues === 'string' ? selectedValues : '' const isSelected = currentSelected === value return ( { filterHandlers.handleSelectFilter( column, fieldKey, value, onColumnFilter ) onPopoverClose?.() }} onKeyDown={(e) => { if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault() e.stopPropagation() filterHandlers.handleSelectFilter( column, fieldKey, value, onColumnFilter ) onPopoverClose?.() } }} tabIndex={0} role="menuitemradio" aria-checked={isSelected} _focus={{ outline: 'none' }} _focusVisible={{ outline: tableToken.header.actionIcons .columnManagerTrigger ?.focusVisible.outline, outlineOffset: '2px', }} > {label} {isSelected && ( )} ) })} )} ) } export const MultiSelectItems: React.FC<{ column: ColumnDefinition> fieldKey: string tableToken: TableTokenType filterHandlers: FilterHandlers filterState: FilterState data?: Record[] onColumnFilter?: ColumnFilterHandler }> = ({ column, fieldKey, tableToken, filterHandlers, filterState, data, onColumnFilter, }) => { const menuItems = getMultiSelectMenuItems(column, data) const filterSearch = menuItems.map((group) => filterItemsBySearch( group.items, filterState.columnSearchValues[fieldKey] || '' ) ) const items: VirtualListItem[] = filterSearch[0].map( (item: { label: string; value: string }) => ({ id: item.value, label: item.label, value: item.value, }) ) const virtualScrollRef = useRef(null) const virtualizer = useVirtualizer({ count: items.length, getScrollElement: () => virtualScrollRef.current, estimateSize: () => FILTER_VIRTUAL_ITEM_ESTIMATE_HEIGHT, overscan: 5, getItemKey: (index: number) => items[index]?.id ?? index, }) const virtualItems = virtualizer.getVirtualItems() const viewportHeight = Math.min( FILTER_VIRTUAL_LIST_MAX_HEIGHT, Math.max(items.length * FILTER_VIRTUAL_ITEM_ESTIMATE_HEIGHT, 0) ) return ( {items.length === 0 ? ( No results found ) : ( {virtualItems.map((virtualItem: VirtualItem) => { const item = items[virtualItem.index] const value = item.value as string const label = item.label as React.ReactNode const selectedValues = filterState.columnSelectedValues[fieldKey] const currentSelected = Array.isArray( selectedValues ) ? selectedValues : [] const isSelected = currentSelected.includes(value) return ( { filterHandlers.handleMultiSelectFilter( column, fieldKey, value, onColumnFilter ) }} onKeyDown={(e) => { if ( e.key === 'Enter' || e.key === ' ' ) { e.preventDefault() e.stopPropagation() filterHandlers.handleMultiSelectFilter( column, fieldKey, value, onColumnFilter ) } }} tabIndex={0} role="menuitemcheckbox" aria-checked={isSelected} _focus={{ outline: 'none' }} _focusVisible={{ outline: tableToken.header.actionIcons .columnManagerTrigger ?.focusVisible.outline, outlineOffset: '2px', }} > {label} ) })} )} ) } export const SliderFilter: React.FC<{ column: ColumnDefinition> fieldKey: string tableToken: TableTokenType filterHandlers: FilterHandlers filterState: FilterState data?: Record[] onColumnFilter?: ColumnFilterHandler }> = ({ column, fieldKey, tableToken, filterState, data, onColumnFilter }) => { const sliderColumn = column as ColumnDefinition> & { sliderConfig?: { min: number max: number step?: number valueType?: string prefix?: string suffix?: string decimalPlaces?: number } } const sliderConfig = sliderColumn.sliderConfig const dataValues = data ?.map((row) => { const value = row[column.field] return typeof value === 'number' ? value : parseFloat(String(value)) || 0 }) .filter((val) => !isNaN(val)) || [] const dataMin = dataValues.length > 0 ? Math.min(...dataValues) : 0 const dataMax = dataValues.length > 0 ? Math.max(...dataValues) : 100 const min = sliderConfig?.min ?? dataMin const max = sliderConfig?.max ?? dataMax const step = sliderConfig?.step ?? 1 const currentFilter = filterState.columnSelectedValues[fieldKey] const isRangeFilter = currentFilter && typeof currentFilter === 'object' && 'min' in currentFilter && 'max' in currentFilter const currentMin = isRangeFilter && typeof currentFilter.min === 'number' ? currentFilter.min : min const currentMax = isRangeFilter && typeof currentFilter.max === 'number' ? currentFilter.max : max const validCurrentMin = Math.max(min, Math.min(max, currentMin)) const validCurrentMax = Math.max(min, Math.min(max, currentMax)) const [localValue, setLocalValue] = useState<[number, number]>([ validCurrentMin, validCurrentMax, ]) const debounceRef = useRef(null) useEffect(() => { setLocalValue([validCurrentMin, validCurrentMax]) }, [validCurrentMin, validCurrentMax]) useEffect(() => { return () => { if (debounceRef.current) { clearTimeout(debounceRef.current) } } }, []) const debouncedFilterUpdate = (values: [number, number]) => { if (debounceRef.current) { clearTimeout(debounceRef.current) } debounceRef.current = setTimeout(() => { const filterValue = { min: values[0], max: values[1] } if (onColumnFilter) { onColumnFilter( fieldKey, FilterType.SLIDER, filterValue, 'range' ) } }, 300) } if (!sliderConfig) { return ( Slider configuration missing ) } const handleSliderChange = (values: number[]) => { const [newMin, newMax] = values const newValue: [number, number] = [newMin, newMax] setLocalValue(newValue) debouncedFilterUpdate(newValue) } const getValueType = (): SliderValueType => { switch (sliderConfig.valueType) { case 'percentage': return SliderValueType.PERCENTAGE case 'decimal': return SliderValueType.DECIMAL default: return SliderValueType.NUMBER } } return ( Filter by Range {sliderConfig.prefix || ''} {localValue[0]} {sliderConfig.suffix || ''} {sliderConfig.prefix || ''} {localValue[1]} {sliderConfig.suffix || ''} {sliderConfig.prefix || ''} {min} {sliderConfig.suffix || ''} {sliderConfig.prefix || ''} {max} {sliderConfig.suffix || ''} ) } export const DateFilter: React.FC<{ column: ColumnDefinition> fieldKey: string tableToken: TableTokenType filterState: FilterState onColumnFilter?: ColumnFilterHandler }> = ({ column, fieldKey, tableToken, filterState, onColumnFilter }) => { const { theme } = useTheme() const destructiveColor = theme === 'dark' ? FOUNDATION_THEME.colors.red[400] : FOUNDATION_THEME.colors.red[600] const selectedValue = filterState.columnSelectedValues[fieldKey] const selectedRange = Array.isArray(selectedValue) ? selectedValue : typeof selectedValue === 'string' && selectedValue ? [selectedValue, selectedValue] : [] const pickerValue: DateRange | undefined = (() => { if (selectedRange.length === 0 || !selectedRange[0]) return undefined const start = parseDateLike(selectedRange[0]) if (!start) return undefined const end = parseDateLike(selectedRange[1] || selectedRange[0]) || start return { startDate: start, endDate: end } })() const hasActiveDateRange = selectedRange.length > 0 return ( { const start = toLocalDateString(range.startDate) const end = toLocalDateString( range.endDate || range.startDate ) onColumnFilter?.( String(column.field), FilterType.DATE, [start, end], 'range' ) }} showDateTimePicker={false} showDateInput={false} showPresets={false} allowSingleDateSelection={false} useDrawerOnMobile={false} popoverConfig={{ side: 'right', align: 'start', sideOffset: 10, }} triggerConfig={{ style: { width: '100%' }, renderTrigger: ({ onClick }) => ( } label="Filter" onClick={(e) => { e.stopPropagation() onClick() }} trailingIcon={ } tableToken={tableToken} style={{ width: '100%' }} /> ), }} /> {hasActiveDateRange && ( } label="Clear Filter" onClick={(e) => { e.stopPropagation() onColumnFilter?.( String(column.field), FilterType.DATE, [], 'range' ) }} isDestructive tableToken={tableToken} style={{ width: '100%' }} /> )} ) } export const ColumnFilter: React.FC = ({ column, data, tableToken, sortHandlers, filterHandlers, filterState, sortState, enableFiltering = true, onColumnFilter, onRenameHeader, onOperations, onInsertLeft, onInsertRight, onDeleteColumn, onPopoverClose, onFilterApplied, }) => { const { theme } = useTheme() const destructiveColor = theme === 'dark' ? FOUNDATION_THEME.colors.red[400] : FOUNDATION_THEME.colors.red[600] const { breakPointLabel } = useBreakpoints(BREAKPOINTS) const isMobile = breakPointLabel === 'sm' const columnConfig = getColumnTypeConfigForColumn(column) const fieldKey = String(column.field) const [nestedFilterOpen, setNestedFilterOpen] = useState(false) const hasFiltering = columnConfig.supportsFiltering && enableFiltering const selectedFilterValue = filterState.columnSelectedValues[fieldKey] // An applied value can be absent from the current option list (async or // server-driven options), so the clear affordance is driven by the filter // state rather than by what the dropdown happens to render. const hasActiveSelection = Array.isArray(selectedFilterValue) ? selectedFilterValue.length > 0 : selectedFilterValue !== undefined && selectedFilterValue !== '' const menuRef = useRef(null) const [focusedIndex, setFocusedIndex] = useState(-1) const isSortingEnabled = columnConfig.supportsSorting && column.isSortable !== false const iconColor = tableToken.dataTable.table.header.filter.sortOption.iconColor const iconSize = FOUNDATION_THEME.unit[16] // Get all focusable menu items const getMenuItems = (): HTMLElement[] => { if (!menuRef.current) return [] return Array.from( menuRef.current.querySelectorAll( '[role="menuitem"], [role="menuitemradio"], [role="menuitemcheckbox"]' ) ) as HTMLElement[] } // Handle arrow key navigation const handleMenuKeyDown = (e: React.KeyboardEvent) => { const items = getMenuItems() if (items.length === 0) return const currentFocused = document.activeElement as HTMLElement const currentIndex = items.findIndex((item) => item === currentFocused) let newIndex = currentIndex >= 0 ? currentIndex : focusedIndex switch (e.key) { case 'ArrowDown': e.preventDefault() e.stopPropagation() newIndex = newIndex < items.length - 1 ? newIndex + 1 : 0 setFocusedIndex(newIndex) items[newIndex]?.focus() break case 'ArrowUp': e.preventDefault() e.stopPropagation() newIndex = newIndex > 0 ? newIndex - 1 : items.length - 1 setFocusedIndex(newIndex) items[newIndex]?.focus() break case 'Home': e.preventDefault() e.stopPropagation() setFocusedIndex(0) items[0]?.focus() break case 'End': e.preventDefault() e.stopPropagation() setFocusedIndex(items.length - 1) items[items.length - 1]?.focus() break case 'Escape': e.preventDefault() e.stopPropagation() onPopoverClose?.() break } } useEffect(() => { if (nestedFilterOpen) { setFocusedIndex(-1) } }, [nestedFilterOpen]) if (isMobile) { return ( ) } return ( {/* Sort — direct options */} {isSortingEnabled && ( )} {/* Filter — direct for date range, nested for others */} {hasFiltering && columnConfig.filterComponent === 'dateRange' && ( )} {hasFiltering && columnConfig.filterComponent !== 'dateRange' && ( } label="Filter" stopPropagationOnClick={false} trailingIcon={ } tableToken={tableToken} /> } maxWidth={220} minWidth={220} side="right" align="start" sideOffset={10} open={nestedFilterOpen} onOpenChange={(open) => { setNestedFilterOpen(open) }} > {(columnConfig.filterComponent === 'multiselect' || columnConfig.filterComponent === 'select') && ( )} {columnConfig.filterComponent === 'select' && ( { setNestedFilterOpen(false) onPopoverClose?.() }} /> )} {columnConfig.filterComponent === 'multiselect' && ( )} {columnConfig.filterComponent === 'slider' && ( )} )} {hasFiltering && columnConfig.filterComponent !== 'dateRange' && hasActiveSelection && ( } label="Clear Filter" onClick={(e) => { e.stopPropagation() setNestedFilterOpen(false) onColumnFilter?.( String(column.field), columnConfig.filterType, [], 'equals' ) }} isDestructive tableToken={tableToken} /> )} {onOperations && ( } label="Operations" onClick={() => { setNestedFilterOpen(false) onOperations() onPopoverClose?.() }} tableToken={tableToken} /> )} {(onRenameHeader || onInsertLeft || onInsertRight) && ( )} {onRenameHeader && ( } label="Rename" onClick={() => { setNestedFilterOpen(false) onRenameHeader() onPopoverClose?.() }} tableToken={tableToken} /> )} {/* Insert Left — callback */} {onInsertLeft && ( } label="Insert Left" onClick={() => { setNestedFilterOpen(false) onInsertLeft() onPopoverClose?.() }} tableToken={tableToken} /> )} {onInsertRight && ( } label="Insert Right" onClick={() => { setNestedFilterOpen(false) onInsertRight() onPopoverClose?.() }} tableToken={tableToken} /> )} {onDeleteColumn && } {onDeleteColumn && ( } label="Delete" isDestructive onClick={() => { setNestedFilterOpen(false) onDeleteColumn() onPopoverClose?.() }} tableToken={tableToken} /> )} ) }