From b76b64b044cc948ce6c3682d9810153195bcce08 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Mon, 20 Apr 2026 17:05:24 +0200 Subject: [PATCH] Add Excel-like filter conditions to column filters Add Condition tab with: equals, not equals, contains, starts with, ends with, greater than, less than, between --- src/components/ColumnFilterPopover.tsx | 369 +++++++++++++++++-------- 1 file changed, 253 insertions(+), 116 deletions(-) diff --git a/src/components/ColumnFilterPopover.tsx b/src/components/ColumnFilterPopover.tsx index 5350a54..79adfb3 100644 --- a/src/components/ColumnFilterPopover.tsx +++ b/src/components/ColumnFilterPopover.tsx @@ -1,7 +1,15 @@ import React, { useState, useMemo } from 'react'; -import { Search, Check, X } from 'lucide-react'; +import { Search, Check, X, Filter } from 'lucide-react'; import { cn } from '../lib/utils'; +type FilterType = 'equals' | 'notEquals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between'; + +interface FilterCondition { + type: FilterType; + value: string; + value2?: string; +} + interface ColumnFilterPopoverProps { uniqueValues: string[]; selectedValues: string[]; @@ -24,31 +32,80 @@ export function ColumnFilterPopover({ className }: ColumnFilterPopoverProps) { const [search, setSearch] = useState(''); - const [isDragging, setIsDragging] = useState(false); - const [dragStart, setDragStart] = useState(null); - const [hoveredIndex, setHoveredIndex] = useState(null); - const [lastClickedIndex, setLastClickedIndex] = useState(null); - const listRef = React.useRef(null); - - const filteredValues = useMemo(() => { - return uniqueValues.filter(v => - String(v || '').toLowerCase().includes(search.toLowerCase()) - ); - }, [uniqueValues, search]); - - const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0; + const [activeTab, setActiveTab] = useState<'values' | 'condition'>('values'); + const [condition, setCondition] = useState({ type: 'equals', value: '' }); + const [conditionResult, setConditionResult] = useState([]); const isDraggingRef = React.useRef(false); const dragStartRef = React.useRef(null); const hoveredIndexRef = React.useRef(null); const selectedValuesRef = React.useRef(selectedValues); const onSelectAllRef = React.useRef(onSelectAll); - const filteredValuesRef = React.useRef(filteredValues); + const filteredValuesRef = React.useRef([]); const didDragRef = React.useRef(false); + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [lastClickedIndex, setLastClickedIndex] = useState(null); + const listRef = React.useRef(null); + const filteredValues = useMemo(() => { + return uniqueValues.filter(v => + String(v || '').toLowerCase().includes(search.toLowerCase()) + ); + }, [uniqueValues, search]); + + filteredValuesRef.current = filteredValues; selectedValuesRef.current = selectedValues; onSelectAllRef.current = onSelectAll; - filteredValuesRef.current = filteredValues; + + const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0; + + const applyCondition = () => { + const results: string[] = []; + const valNum = parseFloat(condition.value); + const val2Num = condition.value2 ? parseFloat(condition.value2) : 0; + + filteredValues.forEach(v => { + const strVal = String(v || ''); + const numVal = parseFloat(v); + + let matches = false; + + switch (condition.type) { + case 'equals': + matches = strVal.toLowerCase() === condition.value.toLowerCase(); + break; + case 'notEquals': + matches = strVal.toLowerCase() !== condition.value.toLowerCase(); + break; + case 'contains': + matches = strVal.toLowerCase().includes(condition.value.toLowerCase()); + break; + case 'startsWith': + matches = strVal.toLowerCase().startsWith(condition.value.toLowerCase()); + break; + case 'endsWith': + matches = strVal.toLowerCase().endsWith(condition.value.toLowerCase()); + break; + case 'greaterThan': + matches = !isNaN(numVal) && !isNaN(valNum) && numVal > valNum; + break; + case 'lessThan': + matches = !isNaN(numVal) && !isNaN(valNum) && numVal < valNum; + break; + case 'between': + matches = !isNaN(numVal) && !isNaN(val2Num) && numVal >= valNum && numVal <= val2Num; + break; + } + + if (matches) results.push(v); + }); + + setConditionResult(results); + onSelectAll(results); + setActiveTab('values'); + }; const handleMouseDown = (index: number) => { isDraggingRef.current = true; @@ -73,7 +130,6 @@ export function ColumnFilterPopover({ const start = dragStartRef.current; const end = hoveredIndexRef.current; - // Only trigger special drag-select if it covered more than one item if (start !== null && end !== null && start !== end) { const s = Math.min(start, end); const e = Math.max(start, end); @@ -81,8 +137,6 @@ export function ColumnFilterPopover({ const newSelected = new Set([...selectedValuesRef.current]); itemsToSelect.forEach(v => newSelected.add(v)); onSelectAllRef.current(Array.from(newSelected)); - - // Set flag to prevent subsequent click event from toggling the end item didDragRef.current = true; setTimeout(() => { didDragRef.current = false; }, 100); } @@ -102,113 +156,196 @@ export function ColumnFilterPopover({ return (
e.stopPropagation()} > {title &&
{title}
} - -
- - setSearch(e.target.value)} - className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500" - autoFocus - /> - {search && ( - - )} -
-
- {filteredValues.map((val, idx) => { - const isDragSelected = isDragging && dragStart !== null && hoveredIndex !== null && - ((idx >= dragStart && idx <= hoveredIndex) || (idx <= dragStart && idx >= hoveredIndex)); - return ( -
{ e.preventDefault(); handleMouseDown(idx); }} - onMouseEnter={() => handleMouseEnter(idx)} - onClick={(e) => { - // If a drag operation just happened, ignore the click to avoid double-selection issues - if (didDragRef.current) return; - - if (e.shiftKey && lastClickedIndex !== null) { - const start = Math.min(lastClickedIndex, idx); - const end = Math.max(lastClickedIndex, idx); - const itemsToSelect = filteredValues.slice(start, end + 1); - const newSelected = new Set([...selectedValues, ...itemsToSelect]); - onSelectAll(Array.from(newSelected)); - } else { - onToggle(val); - } - setLastClickedIndex(idx); - }} - onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); setLastClickedIndex(idx); } }} - className={cn( - "flex items-center gap-2 p-1.5 rounded cursor-pointer group transition-colors select-none", - isDragSelected ? "bg-blue-600/40" : "hover:bg-slate-700/50" - )} - > -
- {selectedValues.includes(val) && } -
- {val || '(Empty)'} -
- ); - })} - {filteredValues.length === 0 && ( -
No values found
- )} -
- -
-
- - - -
+
+
+ + {activeTab === 'values' ? ( + <> +
+ + setSearch(e.target.value)} + className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500" + autoFocus + /> + {search && ( + + )} +
+ +
+ {filteredValues.map((val, idx) => { + const isDragSelected = isDragging && dragStart !== null && hoveredIndex !== null && + ((idx >= dragStart && idx <= hoveredIndex) || (idx <= dragStart && idx >= hoveredIndex)); + return ( +
{ e.preventDefault(); handleMouseDown(idx); }} + onMouseEnter={() => handleMouseEnter(idx)} + onClick={(e) => { + if (didDragRef.current) return; + if (e.shiftKey && lastClickedIndex !== null) { + const start = Math.min(lastClickedIndex, idx); + const end = Math.max(lastClickedIndex, idx); + const itemsToSelect = filteredValues.slice(start, end + 1); + const newSelected = new Set([...selectedValues, ...itemsToSelect]); + onSelectAll(Array.from(newSelected)); + } else { + onToggle(val); + } + setLastClickedIndex(idx); + }} + onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); setLastClickedIndex(idx); } }} + className={cn( + "flex items-center gap-2 p-1.5 rounded cursor-pointer group transition-colors select-none", + isDragSelected ? "bg-blue-600/40" : "hover:bg-slate-700/50" + )} + > +
+ {selectedValues.includes(val) && } +
+ {val || '(Empty)'} +
+ ); + })} + {filteredValues.length === 0 && ( +
No values found
+ )} +
+ +
+
+ + + +
+ +
+ + ) : ( + <> +
+
+ + +
+ +
+ + setCondition({ ...condition, value: e.target.value })} + placeholder="Enter value..." + className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500" + /> +
+ + {condition.type === 'between' && ( +
+ + setCondition({ ...condition, value2: e.target.value })} + placeholder="Enter second value..." + className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500" + /> +
+ )} + + +
+ + {conditionResult.length > 0 && ( +
+ Found {conditionResult.length} matching value{conditionResult.length !== 1 ? 's' : ''} +
+ )} + + )}
); }