Fix Esc handling in filter popover

This commit is contained in:
2025-01-15 16:55:10 -05:00
parent 3c600659e5
commit e5f97ab836

View File

@@ -18,13 +18,10 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { Input } from "@/components/ui/input";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/components/ui/toggle-group";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
type FilterValue = string | number | boolean;
type ComparisonOperator = '=' | '>' | '>=' | '<' | '<=' | 'between';
type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between";
interface FilterValueWithOperator {
value: FilterValue | [number, number];
@@ -43,7 +40,7 @@ interface ActiveFilter {
interface FilterOption {
id: string;
label: string;
type: 'select' | 'number' | 'boolean' | 'text';
type: "select" | "number" | "boolean" | "text";
options?: { label: string; value: string }[];
group: string;
operators?: ComparisonOperator[];
@@ -51,163 +48,163 @@ interface FilterOption {
const FILTER_OPTIONS: FilterOption[] = [
// Basic Info Group
{ id: 'search', label: 'Search', type: 'text', group: 'Basic Info' },
{ id: 'sku', label: 'SKU', type: 'text', group: 'Basic Info' },
{ id: 'vendor', label: 'Vendor', type: 'select', group: 'Basic Info' },
{ id: 'brand', label: 'Brand', type: 'select', group: 'Basic Info' },
{ id: 'category', label: 'Category', type: 'select', group: 'Basic Info' },
{ id: "search", label: "Search", type: "text", group: "Basic Info" },
{ id: "sku", label: "SKU", type: "text", group: "Basic Info" },
{ id: "vendor", label: "Vendor", type: "select", group: "Basic Info" },
{ id: "brand", label: "Brand", type: "select", group: "Basic Info" },
{ id: "category", label: "Category", type: "select", group: "Basic Info" },
// Inventory Group
{
id: 'stockStatus',
label: 'Stock Status',
type: 'select',
id: "stockStatus",
label: "Stock Status",
type: "select",
options: [
{ label: 'Critical', value: 'critical' },
{ label: 'At Risk', value: 'at-risk' },
{ label: 'Reorder', value: 'reorder' },
{ label: 'Healthy', value: 'healthy' },
{ label: 'Overstocked', value: 'overstocked' },
{ label: 'New', value: 'new' }
{ label: "Critical", value: "critical" },
{ label: "At Risk", value: "at-risk" },
{ label: "Reorder", value: "reorder" },
{ label: "Healthy", value: "healthy" },
{ label: "Overstocked", value: "overstocked" },
{ label: "New", value: "new" },
],
group: 'Inventory'
group: "Inventory",
},
{
id: 'stock',
label: 'Stock Quantity',
type: 'number',
group: 'Inventory',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "stock",
label: "Stock Quantity",
type: "number",
group: "Inventory",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'daysOfStock',
label: 'Days of Stock',
type: 'number',
group: 'Inventory',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "daysOfStock",
label: "Days of Stock",
type: "number",
group: "Inventory",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'replenishable',
label: 'Replenishable',
type: 'select',
id: "replenishable",
label: "Replenishable",
type: "select",
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' }
{ label: "Yes", value: "true" },
{ label: "No", value: "false" },
],
group: 'Inventory'
group: "Inventory",
},
// Pricing Group
{
id: 'price',
label: 'Price',
type: 'number',
group: 'Pricing',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "price",
label: "Price",
type: "number",
group: "Pricing",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'costPrice',
label: 'Cost Price',
type: 'number',
group: 'Pricing',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "costPrice",
label: "Cost Price",
type: "number",
group: "Pricing",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'landingCost',
label: 'Landing Cost',
type: 'number',
group: 'Pricing',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "landingCost",
label: "Landing Cost",
type: "number",
group: "Pricing",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
// Sales Metrics Group
{
id: 'dailySalesAvg',
label: 'Daily Sales Avg',
type: 'number',
group: 'Sales Metrics',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "dailySalesAvg",
label: "Daily Sales Avg",
type: "number",
group: "Sales Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'weeklySalesAvg',
label: 'Weekly Sales Avg',
type: 'number',
group: 'Sales Metrics',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "weeklySalesAvg",
label: "Weekly Sales Avg",
type: "number",
group: "Sales Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'monthlySalesAvg',
label: 'Monthly Sales Avg',
type: 'number',
group: 'Sales Metrics',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "monthlySalesAvg",
label: "Monthly Sales Avg",
type: "number",
group: "Sales Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
// Financial Metrics Group
{
id: 'margin',
label: 'Margin %',
type: 'number',
group: 'Financial Metrics',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "margin",
label: "Margin %",
type: "number",
group: "Financial Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'gmroi',
label: 'GMROI',
type: 'number',
group: 'Financial Metrics',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "gmroi",
label: "GMROI",
type: "number",
group: "Financial Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
// Lead Time & Stock Coverage Group
{
id: 'leadTime',
label: 'Lead Time (Days)',
type: 'number',
group: 'Lead Time & Coverage',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "leadTime",
label: "Lead Time (Days)",
type: "number",
group: "Lead Time & Coverage",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: 'leadTimeStatus',
label: 'Lead Time Status',
type: 'select',
id: "leadTimeStatus",
label: "Lead Time Status",
type: "select",
options: [
{ label: 'On Target', value: 'on_target' },
{ label: 'Warning', value: 'warning' },
{ label: 'Critical', value: 'critical' }
{ label: "On Target", value: "on_target" },
{ label: "Warning", value: "warning" },
{ label: "Critical", value: "critical" },
],
group: 'Lead Time & Coverage'
group: "Lead Time & Coverage",
},
{
id: 'stockCoverage',
label: 'Stock Coverage Ratio',
type: 'number',
group: 'Lead Time & Coverage',
operators: ['=', '>', '>=', '<', '<=', 'between']
id: "stockCoverage",
label: "Stock Coverage Ratio",
type: "number",
group: "Lead Time & Coverage",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
// Classification Group
{
id: 'abcClass',
label: 'ABC Class',
type: 'select',
id: "abcClass",
label: "ABC Class",
type: "select",
options: [
{ label: 'A', value: 'A' },
{ label: 'B', value: 'B' },
{ label: 'C', value: 'C' }
{ label: "A", value: "A" },
{ label: "B", value: "B" },
{ label: "C", value: "C" },
],
group: 'Classification'
group: "Classification",
},
{
id: 'managingStock',
label: 'Managing Stock',
type: 'select',
id: "managingStock",
label: "Managing Stock",
type: "select",
options: [
{ label: 'Yes', value: 'true' },
{ label: 'No', value: 'false' }
{ label: "Yes", value: "true" },
{ label: "No", value: "false" },
],
group: 'Classification'
}
group: "Classification",
},
];
interface ProductFiltersProps {
@@ -229,16 +226,21 @@ export function ProductFilters({
}: ProductFiltersProps) {
const [showCommand, setShowCommand] = React.useState(false);
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 [inputValue2, setInputValue2] = React.useState("");
const [searchValue, setSearchValue] = React.useState("");
// Add refs for the inputs
const numberInputRef = React.useRef<HTMLInputElement>(null);
const selectInputRef = React.useRef<HTMLInputElement>(null);
const textInputRef = React.useRef<HTMLInputElement>(null);
// Reset states when popup closes
const handlePopoverClose = () => {
setShowCommand(false);
setSelectedFilter(null);
setSelectedOperator('=');
setSelectedOperator("=");
setInputValue("");
setInputValue2("");
setSearchValue("");
@@ -248,7 +250,7 @@ export function ProductFilters({
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Command/Ctrl + K to toggle filter
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
e.preventDefault();
if (!showCommand) {
setShowCommand(true);
@@ -256,35 +258,31 @@ export function ProductFilters({
handlePopoverClose();
}
}
// Only handle Escape at the root level
if (e.key === 'Escape' && !selectedFilter) {
handlePopoverClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedFilter, showCommand]);
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [showCommand]);
// Update filter options with dynamic data
const filterOptions = React.useMemo(() => {
return FILTER_OPTIONS.map(option => {
if (option.id === 'category') {
return FILTER_OPTIONS.map((option) => {
if (option.id === "category") {
return {
...option,
options: categories.map(cat => ({ label: cat, value: cat }))
options: categories.map((cat) => ({ label: cat, value: cat })),
};
}
if (option.id === 'vendor') {
if (option.id === "vendor") {
return {
...option,
options: vendors.map(vendor => ({ label: vendor, value: vendor }))
options: vendors.map((vendor) => ({ label: vendor, value: vendor })),
};
}
if (option.id === 'brand') {
if (option.id === "brand") {
return {
...option,
options: brands.map(brand => ({ label: brand, value: brand }))
options: brands.map((brand) => ({ label: brand, value: brand })),
};
}
return option;
@@ -296,15 +294,27 @@ export function ProductFilters({
if (!searchValue) return filterOptions;
const search = searchValue.toLowerCase();
return filterOptions.filter(option =>
option.label.toLowerCase().includes(search) ||
option.group.toLowerCase().includes(search)
return filterOptions.filter(
(option) =>
option.label.toLowerCase().includes(search) ||
option.group.toLowerCase().includes(search)
);
}, [filterOptions, searchValue]);
const handleSelectFilter = React.useCallback((filter: FilterOption) => {
setSelectedFilter(filter);
setInputValue("");
// Focus the appropriate input after state updates
requestAnimationFrame(() => {
if (filter.type === "number") {
numberInputRef.current?.focus();
} else if (filter.type === "select") {
selectInputRef.current?.focus();
} else {
textInputRef.current?.focus();
}
});
}, []);
const handleApplyFilter = (value: FilterValue | [number, number]) => {
@@ -314,8 +324,8 @@ export function ProductFilters({
...activeFilters,
[selectedFilter.id]: {
value,
operator: selectedOperator
}
operator: selectedOperator,
},
};
onFilterChange(newFilters as Record<string, ActiveFilterValue>);
@@ -324,7 +334,7 @@ export function ProductFilters({
const handleBackToFilters = () => {
setSelectedFilter(null);
setSelectedOperator('=');
setSelectedOperator("=");
setInputValue("");
setInputValue2("");
};
@@ -333,11 +343,13 @@ export function ProductFilters({
if (!activeFilters) return [];
return Object.entries(activeFilters).map(([id, value]): ActiveFilter => {
const option = filterOptions.find(opt => opt.id === id);
const option = filterOptions.find((opt) => opt.id === id);
let displayValue = String(value);
if (option?.type === 'select' && option.options) {
const optionLabel = option.options.find(opt => opt.value === value)?.label;
if (option?.type === "select" && option.options) {
const optionLabel = option.options.find(
(opt) => opt.value === value
)?.label;
if (optionLabel) displayValue = optionLabel;
}
@@ -345,7 +357,7 @@ export function ProductFilters({
id,
label: option?.label || id,
value,
displayValue
displayValue,
};
});
}, [activeFilters, filterOptions]);
@@ -354,15 +366,29 @@ export function ProductFilters({
<ToggleGroup
type="single"
value={selectedOperator}
onValueChange={(value: ComparisonOperator) => value && setSelectedOperator(value)}
onValueChange={(value: ComparisonOperator) =>
value && setSelectedOperator(value)
}
className="flex-wrap"
>
<ToggleGroupItem value="=" aria-label="equals">=</ToggleGroupItem>
<ToggleGroupItem value=">" aria-label="greater than">{'>'}</ToggleGroupItem>
<ToggleGroupItem value=">=" aria-label="greater than or equal"></ToggleGroupItem>
<ToggleGroupItem value="<" aria-label="less than">{'<'}</ToggleGroupItem>
<ToggleGroupItem value="<=" aria-label="less than or equal"></ToggleGroupItem>
<ToggleGroupItem value="between" aria-label="between">Between</ToggleGroupItem>
<ToggleGroupItem value="=" aria-label="equals">
=
</ToggleGroupItem>
<ToggleGroupItem value=">" aria-label="greater than">
{">"}
</ToggleGroupItem>
<ToggleGroupItem value=">=" aria-label="greater than or equal">
</ToggleGroupItem>
<ToggleGroupItem value="<" aria-label="less than">
{"<"}
</ToggleGroupItem>
<ToggleGroupItem value="<=" aria-label="less than or equal">
</ToggleGroupItem>
<ToggleGroupItem value="between" aria-label="between">
Between
</ToggleGroupItem>
</ToggleGroup>
);
@@ -380,13 +406,14 @@ export function ProductFilters({
{renderOperatorSelect()}
<div className="flex items-center gap-2">
<Input
ref={numberInputRef}
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 (e.key === "Enter") {
if (selectedOperator === "between") {
if (inputValue2) {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
@@ -400,14 +427,14 @@ export function ProductFilters({
handleApplyFilter(val);
}
}
} else if (e.key === 'Escape') {
} 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"
/>
{selectedOperator === 'between' && (
{selectedOperator === "between" && (
<>
<span>and</span>
<Input
@@ -416,13 +443,13 @@ export function ProductFilters({
value={inputValue2}
onChange={(e) => setInputValue2(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
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') {
} else if (e.key === "Escape") {
e.stopPropagation();
handleBackToFilters();
}
@@ -433,7 +460,7 @@ export function ProductFilters({
)}
<Button
onClick={() => {
if (selectedOperator === 'between') {
if (selectedOperator === "between") {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
@@ -455,7 +482,7 @@ export function ProductFilters({
const getFilterDisplayValue = (filter: ActiveFilter) => {
const filterValue = activeFilters[filter.id];
const filterOption = filterOptions.find(opt => opt.id === filter.id);
const filterOption = filterOptions.find((opt) => opt.id === filter.id);
// For between ranges
if (Array.isArray(filterValue)) {
@@ -463,8 +490,13 @@ export function ProductFilters({
}
// For direct selections (select type) or text search
if (filterOption?.type === 'select' || filterOption?.type === 'text' || typeof filterValue !== 'object') {
const value = typeof filterValue === 'object' ? filterValue.value : filterValue;
if (
filterOption?.type === "select" ||
filterOption?.type === "text" ||
typeof filterValue !== "object"
) {
const value =
typeof filterValue === "object" ? filterValue.value : filterValue;
return `${filter.label}: ${value}`;
}
@@ -472,12 +504,12 @@ export function ProductFilters({
const operator = filterValue.operator;
const value = filterValue.value;
const operatorDisplay = {
'=': '=',
'>': '>',
'>=': '≥',
'<': '<',
'<=': '≤',
'between': 'between'
"=": "=",
">": ">",
">=": "≥",
"<": "<",
"<=": "≤",
between: "between",
}[operator];
return `${filter.label} ${operatorDisplay} ${value}`;
@@ -498,37 +530,29 @@ export function ProductFilters({
modal={true}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 border-dashed"
>
<Plus className={cn(
"mr-2 h-4 w-4 transition-transform duration-200",
showCommand && "rotate-[135deg]"
)} />
<Button variant="outline" size="sm" className="h-8 border-dashed">
<Plus
className={cn(
"mr-2 h-4 w-4 transition-transform duration-200",
showCommand && "rotate-[135deg]"
)}
/>
{showCommand ? "Cancel" : "Add Filter"}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[520px]"
align="start"
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
if (selectedFilter) {
handleBackToFilters();
} else {
handlePopoverClose();
}
onEscapeKeyDown={(event) => {
console.log('Escape pressed, selectedFilter:', selectedFilter); // Debug log
if (selectedFilter) {
event.preventDefault();
event.stopPropagation();
handleBackToFilters();
}
}}
>
<Command
className="rounded-none border-0"
shouldFilter={false}
>
<Command className="rounded-none border-0" shouldFilter={false}>
{!selectedFilter ? (
<>
<CommandInput
@@ -536,7 +560,7 @@ export function ProductFilters({
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={(e) => {
if (e.key === 'Escape') {
if (e.key === "Escape") {
e.preventDefault();
handlePopoverClose();
}
@@ -545,11 +569,14 @@ export function ProductFilters({
<CommandList>
<CommandEmpty>No filters found.</CommandEmpty>
{Object.entries(
filteredOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
if (!acc[filter.group]) acc[filter.group] = [];
acc[filter.group].push(filter);
return acc;
}, {})
filteredOptions.reduce<Record<string, FilterOption[]>>(
(acc, filter) => {
if (!acc[filter.group]) acc[filter.group] = [];
acc[filter.group].push(filter);
return acc;
},
{}
)
).map(([group, filters]) => (
<React.Fragment key={group}>
<CommandGroup heading={group}>
@@ -559,7 +586,7 @@ export function ProductFilters({
value={`${filter.id} ${filter.label}`}
onSelect={() => {
handleSelectFilter(filter);
if (filter.type !== 'select') {
if (filter.type !== "select") {
setInputValue("");
}
}}
@@ -582,7 +609,7 @@ export function ProductFilters({
))}
</CommandList>
</>
) : selectedFilter.type === 'number' ? (
) : selectedFilter.type === "number" ? (
<div className="p-4">
<div className="flex flex-col gap-4 items-start">
<div className="mb-4">
@@ -597,13 +624,14 @@ export function ProductFilters({
{renderOperatorSelect()}
<div className="flex items-center gap-2">
<Input
ref={numberInputRef}
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 (e.key === "Enter") {
if (selectedOperator === "between") {
if (inputValue2) {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
@@ -617,14 +645,14 @@ export function ProductFilters({
handleApplyFilter(val);
}
}
} else if (e.key === 'Escape') {
} 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' && (
{selectedOperator === "between" && (
<>
<span>and</span>
<Input
@@ -633,13 +661,13 @@ export function ProductFilters({
value={inputValue2}
onChange={(e) => setInputValue2(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
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') {
} else if (e.key === "Escape") {
e.preventDefault();
handleBackToFilters();
}
@@ -650,7 +678,7 @@ export function ProductFilters({
)}
<Button
onClick={() => {
if (selectedOperator === 'between') {
if (selectedOperator === "between") {
const val1 = parseFloat(inputValue);
const val2 = parseFloat(inputValue2);
if (!isNaN(val1) && !isNaN(val2)) {
@@ -669,17 +697,18 @@ export function ProductFilters({
</div>
</div>
</div>
) : selectedFilter.type === 'select' ? (
) : selectedFilter.type === "select" ? (
<>
<CommandInput
ref={selectInputRef}
placeholder={`Select ${selectedFilter.label.toLowerCase()}...`}
value={inputValue}
onValueChange={setInputValue}
onKeyDown={(e) => {
if (e.key === 'Backspace' && !inputValue) {
if (e.key === "Backspace" && !inputValue) {
e.preventDefault();
handleBackToFilters();
} else if (e.key === 'Escape') {
} else if (e.key === "Escape") {
e.preventDefault();
handleBackToFilters();
}
@@ -696,8 +725,10 @@ export function ProductFilters({
</CommandItem>
<CommandSeparator />
{selectedFilter.options
?.filter(option =>
option.label.toLowerCase().includes(inputValue.toLowerCase())
?.filter((option) =>
option.label
.toLowerCase()
.includes(inputValue.toLowerCase())
)
.map((option) => (
<CommandItem
@@ -708,21 +739,21 @@ export function ProductFilters({
>
{option.label}
</CommandItem>
))
}
))}
</CommandGroup>
</CommandList>
</>
) : (
<>
<CommandInput
ref={textInputRef}
placeholder={`Enter ${selectedFilter.label.toLowerCase()}...`}
value={inputValue}
onValueChange={setInputValue}
onKeyDown={(e) => {
if (e.key === 'Enter' && inputValue.trim()) {
if (e.key === "Enter" && inputValue.trim()) {
handleApplyFilter(inputValue.trim());
} else if (e.key === 'Escape') {
} else if (e.key === "Escape") {
e.preventDefault();
handleBackToFilters();
}