Fix column selector popover behavior, improve product filters

This commit is contained in:
2025-01-15 16:07:06 -05:00
parent 82da563ee1
commit 3c600659e5
3 changed files with 268 additions and 162 deletions

View File

@@ -231,32 +231,40 @@ export function ProductFilters({
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null); const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
const [selectedOperator, setSelectedOperator] = React.useState<ComparisonOperator>('='); const [selectedOperator, setSelectedOperator] = React.useState<ComparisonOperator>('=');
const [inputValue, setInputValue] = React.useState(""); const [inputValue, setInputValue] = React.useState("");
const [inputValue2, setInputValue2] = React.useState(""); // For 'between' operator const [inputValue2, setInputValue2] = React.useState("");
const [searchValue, setSearchValue] = React.useState(""); const [searchValue, setSearchValue] = React.useState("");
// Reset states when popup closes
const handlePopoverClose = () => {
setShowCommand(false);
setSelectedFilter(null);
setSelectedOperator('=');
setInputValue("");
setInputValue2("");
setSearchValue("");
};
// Handle keyboard shortcuts // Handle keyboard shortcuts
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Command/Ctrl + K to toggle filter // Command/Ctrl + K to toggle filter
if ((e.metaKey || e.ctrlKey) && e.key === 'k') { if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault(); e.preventDefault();
setShowCommand(prev => !prev); if (!showCommand) {
} setShowCommand(true);
// Escape to close or go back
if (e.key === 'Escape') {
if (selectedFilter) {
setSelectedFilter(null);
setInputValue("");
} else { } else {
setShowCommand(false); handlePopoverClose();
setSearchValue("");
} }
} }
// Only handle Escape at the root level
if (e.key === 'Escape' && !selectedFilter) {
handlePopoverClose();
}
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedFilter]); }, [selectedFilter, showCommand]);
// Update filter options with dynamic data // Update filter options with dynamic data
const filterOptions = React.useMemo(() => { const filterOptions = React.useMemo(() => {
@@ -311,33 +319,16 @@ export function ProductFilters({
}; };
onFilterChange(newFilters as Record<string, ActiveFilterValue>); onFilterChange(newFilters as Record<string, ActiveFilterValue>);
handlePopoverClose();
};
const handleBackToFilters = () => {
setSelectedFilter(null); setSelectedFilter(null);
setSelectedOperator('='); setSelectedOperator('=');
setInputValue(""); setInputValue("");
setInputValue2(""); setInputValue2("");
setSearchValue("");
}; };
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && selectedFilter) {
if (selectedFilter.type === 'select') {
const option = selectedFilter.options?.find(opt =>
opt.label.toLowerCase().includes(inputValue.toLowerCase())
);
if (option) {
handleApplyFilter(option.value);
}
} else if (selectedFilter.type === 'number') {
const numValue = parseFloat(inputValue);
if (!isNaN(numValue)) {
handleApplyFilter(numValue);
}
} else if (selectedFilter.type === 'text' && inputValue.trim() !== '') {
handleApplyFilter(inputValue.trim());
}
}
}, [selectedFilter, inputValue]);
const activeFiltersList = React.useMemo(() => { const activeFiltersList = React.useMemo(() => {
if (!activeFilters) return []; if (!activeFilters) return [];
@@ -377,6 +368,15 @@ export function ProductFilters({
const renderNumberInput = () => ( const renderNumberInput = () => (
<div className="flex flex-col gap-4 items-start"> <div className="flex flex-col gap-4 items-start">
<div className="mb-4">
<Button
variant="ghost"
onClick={handleBackToFilters}
className="text-muted-foreground"
>
Back to filters
</Button>
</div>
{renderOperatorSelect()} {renderOperatorSelect()}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Input <Input
@@ -400,6 +400,9 @@ export function ProductFilters({
handleApplyFilter(val); handleApplyFilter(val);
} }
} }
} else if (e.key === 'Escape') {
e.stopPropagation();
handleBackToFilters();
} }
}} }}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@@ -419,6 +422,9 @@ export function ProductFilters({
if (!isNaN(val1) && !isNaN(val2)) { if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]); handleApplyFilter([val1, val2]);
} }
} else if (e.key === 'Escape') {
e.stopPropagation();
handleBackToFilters();
} }
}} }}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
@@ -448,18 +454,23 @@ export function ProductFilters({
); );
const getFilterDisplayValue = (filter: ActiveFilter) => { const getFilterDisplayValue = (filter: ActiveFilter) => {
if (Array.isArray(filter.value)) { const filterValue = activeFilters[filter.id];
return `between ${filter.value[0]} and ${filter.value[1]}`; const filterOption = filterOptions.find(opt => opt.id === filter.id);
// For between ranges
if (Array.isArray(filterValue)) {
return `${filter.label} between ${filterValue[0]} and ${filterValue[1]}`;
} }
const filterValue = activeFilters[filter.id]; // For direct selections (select type) or text search
const operator = typeof filterValue === 'object' && 'operator' in filterValue if (filterOption?.type === 'select' || filterOption?.type === 'text' || typeof filterValue !== 'object') {
? filterValue.operator const value = typeof filterValue === 'object' ? filterValue.value : filterValue;
: '='; return `${filter.label}: ${value}`;
const value = typeof filterValue === 'object' && 'value' in filterValue }
? filterValue.value
: filterValue;
// For numeric filters with operators
const operator = filterValue.operator;
const value = filterValue.value;
const operatorDisplay = { const operatorDisplay = {
'=': '=', '=': '=',
'>': '>', '>': '>',
@@ -469,13 +480,23 @@ export function ProductFilters({
'between': 'between' 'between': 'between'
}[operator]; }[operator];
return `${operatorDisplay} ${value}`; return `${filter.label} ${operatorDisplay} ${value}`;
}; };
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Popover open={showCommand} onOpenChange={setShowCommand}> <Popover
open={showCommand}
onOpenChange={(open) => {
if (!open) {
handlePopoverClose();
} else {
setShowCommand(true);
}
}}
modal={true}
>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
@@ -489,14 +510,37 @@ export function ProductFilters({
{showCommand ? "Cancel" : "Add Filter"} {showCommand ? "Cancel" : "Add Filter"}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0 w-[520px]" align="start"> <PopoverContent
<Command className="rounded-none border-0" shouldFilter={false}> className="p-0 w-[520px]"
align="start"
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (selectedFilter) {
handleBackToFilters();
} else {
handlePopoverClose();
}
}
}}
>
<Command
className="rounded-none border-0"
shouldFilter={false}
>
{!selectedFilter ? ( {!selectedFilter ? (
<> <>
<CommandInput <CommandInput
placeholder="Search and select filters..." placeholder="Search and select filters..."
value={searchValue} value={searchValue}
onValueChange={setSearchValue} onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
handlePopoverClose();
}
}}
/> />
<CommandList> <CommandList>
<CommandEmpty>No filters found.</CommandEmpty> <CommandEmpty>No filters found.</CommandEmpty>
@@ -540,16 +584,90 @@ export function ProductFilters({
</> </>
) : selectedFilter.type === 'number' ? ( ) : selectedFilter.type === 'number' ? (
<div className="p-4"> <div className="p-4">
<div className="mb-4"> <div className="flex flex-col gap-4 items-start">
<Button <div className="mb-4">
variant="ghost" <Button
onClick={() => setSelectedFilter(null)} variant="ghost"
className="text-muted-foreground" onClick={handleBackToFilters}
> className="text-muted-foreground"
Back to filters >
</Button> Back to filters
</Button>
</div>
{renderOperatorSelect()}
<div className="flex items-center gap-2">
<Input
type="number"
placeholder={`Enter ${selectedFilter?.label.toLowerCase()}`}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
if (selectedOperator === 'between') {
if (inputValue2) {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
}
} else {
const val = parseFloat(inputValue);
if (!isNaN(val)) {
handleApplyFilter(val);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
handleBackToFilters();
}
}}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
{selectedOperator === 'between' && (
<>
<span>and</span>
<Input
type="number"
placeholder={`Enter maximum`}
value={inputValue2}
onChange={(e) => setInputValue2(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
} else if (e.key === 'Escape') {
e.preventDefault();
handleBackToFilters();
}
}}
className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
/>
</>
)}
<Button
onClick={() => {
if (selectedOperator === 'between') {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
handleApplyFilter([val1, val2]);
}
} else {
const val = parseFloat(inputValue);
if (!isNaN(val)) {
handleApplyFilter(val);
}
}
}}
>
Apply
</Button>
</div>
</div> </div>
{renderNumberInput()}
</div> </div>
) : selectedFilter.type === 'select' ? ( ) : selectedFilter.type === 'select' ? (
<> <>
@@ -560,9 +678,10 @@ export function ProductFilters({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Backspace' && !inputValue) { if (e.key === 'Backspace' && !inputValue) {
e.preventDefault(); e.preventDefault();
setSelectedFilter(null); handleBackToFilters();
} else { } else if (e.key === 'Escape') {
handleKeyDown(e); e.preventDefault();
handleBackToFilters();
} }
}} }}
/> />
@@ -570,10 +689,7 @@ export function ProductFilters({
<CommandEmpty>No options found.</CommandEmpty> <CommandEmpty>No options found.</CommandEmpty>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
onSelect={() => { onSelect={handleBackToFilters}
setSelectedFilter(null);
setInputValue("");
}}
className="cursor-pointer text-muted-foreground" className="cursor-pointer text-muted-foreground"
> >
Back to filters Back to filters
@@ -587,10 +703,7 @@ export function ProductFilters({
<CommandItem <CommandItem
key={option.value} key={option.value}
value={option.value} value={option.value}
onSelect={() => { onSelect={() => handleApplyFilter(option.value)}
handleApplyFilter(option.value);
setShowCommand(false);
}}
className="cursor-pointer" className="cursor-pointer"
> >
{option.label} {option.label}
@@ -603,68 +716,35 @@ export function ProductFilters({
) : ( ) : (
<> <>
<CommandInput <CommandInput
placeholder={`Enter ${selectedFilter.label.toLowerCase()}`} placeholder={`Enter ${selectedFilter.label.toLowerCase()}...`}
value={inputValue} value={inputValue}
onValueChange={(value) => { onValueChange={setInputValue}
if (selectedFilter.type === 'number') {
if (/^\d*\.?\d*$/.test(value)) {
setInputValue(value);
}
} else {
setInputValue(value);
}
}}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Backspace' && !inputValue) { if (e.key === 'Enter' && inputValue.trim()) {
handleApplyFilter(inputValue.trim());
} else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
setSelectedFilter(null); handleBackToFilters();
} else if (e.key === 'Enter') {
if (selectedFilter.type === 'number') {
const numValue = parseFloat(inputValue);
if (!isNaN(numValue)) {
handleApplyFilter(numValue);
setShowCommand(false);
}
} else {
if (inputValue.trim() !== '') {
handleApplyFilter(inputValue.trim());
setShowCommand(false);
}
}
} }
}} }}
/> />
<CommandList> <CommandList>
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
onSelect={() => { onSelect={handleBackToFilters}
setSelectedFilter(null);
setInputValue("");
}}
className="cursor-pointer text-muted-foreground" className="cursor-pointer text-muted-foreground"
> >
Back to filters Back to filters
</CommandItem> </CommandItem>
<CommandSeparator /> <CommandSeparator />
<CommandItem {inputValue.trim() && (
onSelect={() => { <CommandItem
if (selectedFilter.type === 'number') { onSelect={() => handleApplyFilter(inputValue.trim())}
const numValue = parseFloat(inputValue); className="cursor-pointer"
if (!isNaN(numValue)) { >
handleApplyFilter(numValue); Apply filter: {inputValue}
setShowCommand(false); </CommandItem>
} )}
} else {
if (inputValue.trim() !== '') {
handleApplyFilter(inputValue.trim());
setShowCommand(false);
}
}
}}
className="cursor-pointer"
>
Apply filter: {inputValue}
</CommandItem>
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</> </>
@@ -678,9 +758,7 @@ export function ProductFilters({
variant="secondary" variant="secondary"
className="flex items-center gap-1" className="flex items-center gap-1"
> >
<span> <span>{getFilterDisplayValue(filter)}</span>
{filter.label}: {getFilterDisplayValue(filter)}
</span>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -688,7 +766,6 @@ export function ProductFilters({
onClick={() => { onClick={() => {
const newFilters = { ...activeFilters }; const newFilters = { ...activeFilters };
delete newFilters[filter.id]; delete newFilters[filter.id];
delete newFilters[`${filter.id}_operator`];
onFilterChange(newFilters); onFilterChange(newFilters);
}} }}
> >

View File

@@ -1,3 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react" import { X } from "lucide-react"

View File

@@ -1,3 +1,4 @@
import * as React from "react";
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
@@ -340,51 +341,77 @@ export function Products() {
return acc; return acc;
}, {} as Record<string, typeof AVAILABLE_COLUMNS>); }, {} as Record<string, typeof AVAILABLE_COLUMNS>);
const renderColumnToggle = () => ( const renderColumnToggle = () => {
<DropdownMenu modal={false}> const [open, setOpen] = React.useState(false);
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-auto"> return (
<Settings2 className="mr-2 h-4 w-4" /> <DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
Columns <DropdownMenuTrigger asChild>
</Button> <Button variant="outline" className="ml-auto">
</DropdownMenuTrigger> <Settings2 className="mr-2 h-4 w-4" />
<DropdownMenuContent Columns
align="end" </Button>
className="w-[500px] max-h-[calc(100vh-4rem)] overflow-y-auto" </DropdownMenuTrigger>
onCloseAutoFocus={(e) => e.preventDefault()} <DropdownMenuContent
> align="end"
<DropdownMenuLabel className="sticky top-0 bg-background z-10">Toggle columns</DropdownMenuLabel> className="w-[500px] max-h-[calc(100vh-4rem)] overflow-y-auto"
<DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" /> onCloseAutoFocus={(e) => e.preventDefault()}
<div className="grid grid-cols-2 gap-4"> onPointerDownOutside={(e) => {
{Object.entries(columnsByGroup).map(([group, columns]) => ( // Only close if clicking outside the dropdown
<div key={group}> if (!(e.target as HTMLElement).closest('[role="dialog"]')) {
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> setOpen(false);
{group} }
</DropdownMenuLabel> }}
{columns.map((column) => ( onInteractOutside={(e) => {
<DropdownMenuCheckboxItem // Prevent closing when interacting with checkboxes
key={column.key} if ((e.target as HTMLElement).closest('[role="dialog"]')) {
className="capitalize" e.preventDefault();
checked={visibleColumns.has(column.key)} }
onCheckedChange={(checked) => handleColumnVisibilityChange(column.key, checked)} }}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</div>
))}
</div>
<DropdownMenuSeparator />
<Button
variant="ghost"
className="w-full justify-start"
onClick={resetColumnsToDefault}
> >
Reset to Default <DropdownMenuLabel className="sticky top-0 bg-background z-10">Toggle columns</DropdownMenuLabel>
</Button> <DropdownMenuSeparator className="sticky top-[29px] bg-background z-10" />
</DropdownMenuContent> <div className="grid grid-cols-2 gap-4">
</DropdownMenu> {Object.entries(columnsByGroup).map(([group, columns]) => (
); <div key={group}>
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
{group}
</DropdownMenuLabel>
{columns.map((column) => (
<DropdownMenuCheckboxItem
key={column.key}
className="capitalize"
checked={visibleColumns.has(column.key)}
onCheckedChange={(checked) => {
handleColumnVisibilityChange(column.key, checked);
}}
onSelect={(e) => {
// Prevent closing by stopping propagation
e.preventDefault();
}}
>
{column.label}
</DropdownMenuCheckboxItem>
))}
</div>
))}
</div>
<DropdownMenuSeparator />
<Button
variant="ghost"
className="w-full justify-start"
onClick={(e) => {
resetColumnsToDefault();
// Prevent closing by stopping propagation
e.stopPropagation();
}}
>
Reset to Default
</Button>
</DropdownMenuContent>
</DropdownMenu>
);
};
// Calculate pagination numbers // Calculate pagination numbers
const totalPages = data?.pagination.pages || 1; const totalPages = data?.pagination.pages || 1;