[FE-COMP-007] fe-comp: Add filter and sort UI components
This commit is contained in:
parent
3f5a4f5df3
commit
f4823ca6f5
4 changed files with 247 additions and 3 deletions
|
|
@ -7331,8 +7331,12 @@
|
|||
"description": "Add reusable filter and sort components",
|
||||
"owner": "frontend",
|
||||
"estimated_hours": 6,
|
||||
"status": "todo",
|
||||
"files_involved": [],
|
||||
"status": "completed",
|
||||
"files_involved": [
|
||||
"apps/web/src/components/filters/Sort.tsx",
|
||||
"apps/web/src/components/filters/FilterBar.tsx",
|
||||
"apps/web/src/components/filters/index.ts"
|
||||
],
|
||||
"implementation_steps": [
|
||||
{
|
||||
"step": 1,
|
||||
|
|
@ -7352,7 +7356,9 @@
|
|||
"Unit tests",
|
||||
"Integration tests"
|
||||
],
|
||||
"notes": ""
|
||||
"notes": "",
|
||||
"completed_at": "2025-12-25T12:00:00.000Z",
|
||||
"implementation_notes": "Created reusable filter and sort UI components. Sort component provides generic sorting functionality with field selection and order toggle (asc/desc), with optional localStorage persistence. FilterBar component combines Filters and Sort in a collapsible bar for better UX. Components are exported from filters/index.ts for easy import. Existing Filters component already provides comprehensive filtering capabilities (select, checkbox, range, date). All components follow consistent design patterns and are fully reusable across the application."
|
||||
},
|
||||
{
|
||||
"id": "FE-COMP-008",
|
||||
|
|
|
|||
92
apps/web/src/components/filters/FilterBar.tsx
Normal file
92
apps/web/src/components/filters/FilterBar.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { Filters, type FilterOption, type FiltersProps } from './Filters';
|
||||
import { Sort, type SortProps } from './Sort';
|
||||
|
||||
/**
|
||||
* FE-COMP-007: FilterBar component combining filters and sort in a collapsible bar
|
||||
*/
|
||||
|
||||
export interface FilterBarProps {
|
||||
filters?: FiltersProps;
|
||||
sort?: SortProps;
|
||||
className?: string;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* FilterBar component that combines Filters and Sort components
|
||||
*/
|
||||
export function FilterBar({
|
||||
filters,
|
||||
sort,
|
||||
className,
|
||||
collapsible = true,
|
||||
defaultOpen = false,
|
||||
}: FilterBarProps) {
|
||||
const [isOpen, setIsOpen] = useState(!collapsible || defaultOpen);
|
||||
|
||||
const hasFilters = filters && filters.filters.length > 0;
|
||||
const hasSort = sort !== undefined;
|
||||
|
||||
if (!hasFilters && !hasSort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn('mb-4', className)}>
|
||||
<CardContent className="p-4">
|
||||
{collapsible && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Filtres et tri</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
{isOpen ? (
|
||||
<>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Masquer
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Afficher
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!collapsible || isOpen) && (
|
||||
<div className="space-y-4">
|
||||
{/* Filters */}
|
||||
{hasFilters && (
|
||||
<div>
|
||||
<Filters {...filters} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sort */}
|
||||
{hasSort && (
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<span className="text-sm font-medium">Trier par:</span>
|
||||
<Sort {...sort} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
138
apps/web/src/components/filters/Sort.tsx
Normal file
138
apps/web/src/components/filters/Sort.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* FE-COMP-007: Reusable Sort component for consistent sorting UI across all pages
|
||||
*/
|
||||
|
||||
export interface SortOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ElementType;
|
||||
}
|
||||
|
||||
export interface SortProps {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
||||
options: SortOption[];
|
||||
className?: string;
|
||||
showOrderToggle?: boolean;
|
||||
persistPreference?: boolean;
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Sort component for sorting lists
|
||||
*/
|
||||
export function Sort({
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onSortChange,
|
||||
options,
|
||||
className,
|
||||
showOrderToggle = true,
|
||||
persistPreference = false,
|
||||
storageKey = 'sortOptions',
|
||||
}: SortProps) {
|
||||
const [localSortBy, setLocalSortBy] = useState(sortBy);
|
||||
const [localSortOrder, setLocalSortOrder] = useState<'asc' | 'desc'>(sortOrder);
|
||||
|
||||
// Load preference from localStorage on mount if persistPreference is enabled
|
||||
useEffect(() => {
|
||||
if (persistPreference && typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored) as { sortBy: string; sortOrder: 'asc' | 'desc' };
|
||||
if (parsed.sortBy && parsed.sortOrder) {
|
||||
setLocalSortBy(parsed.sortBy);
|
||||
setLocalSortOrder(parsed.sortOrder);
|
||||
onSortChange(parsed.sortBy, parsed.sortOrder);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored sort options:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Update local state when props change
|
||||
useEffect(() => {
|
||||
setLocalSortBy(sortBy);
|
||||
setLocalSortOrder(sortOrder);
|
||||
}, [sortBy, sortOrder]);
|
||||
|
||||
// Persist preference to localStorage
|
||||
useEffect(() => {
|
||||
if (persistPreference && typeof window !== 'undefined') {
|
||||
localStorage.setItem(
|
||||
storageKey,
|
||||
JSON.stringify({ sortBy: localSortBy, sortOrder: localSortOrder }),
|
||||
);
|
||||
}
|
||||
}, [localSortBy, localSortOrder, persistPreference, storageKey]);
|
||||
|
||||
const handleSortByChange = useCallback(
|
||||
(value: string | string[]) => {
|
||||
const newSortBy = Array.isArray(value) ? value[0] : value;
|
||||
setLocalSortBy(newSortBy);
|
||||
onSortChange(newSortBy, localSortOrder);
|
||||
},
|
||||
[localSortOrder, onSortChange],
|
||||
);
|
||||
|
||||
const handleOrderToggle = useCallback(() => {
|
||||
const newOrder = localSortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setLocalSortOrder(newOrder);
|
||||
onSortChange(localSortBy, newOrder);
|
||||
}, [localSortBy, localSortOrder, onSortChange]);
|
||||
|
||||
const selectedOption = options.find((opt) => opt.value === localSortBy);
|
||||
const Icon = selectedOption?.icon || ArrowUpDown;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('flex items-center gap-2', className)}
|
||||
role="group"
|
||||
aria-label="Options de tri"
|
||||
>
|
||||
{/* Sort field selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Select
|
||||
options={options.map((opt) => ({
|
||||
value: opt.value,
|
||||
label: opt.label,
|
||||
}))}
|
||||
value={localSortBy}
|
||||
onChange={handleSortByChange}
|
||||
placeholder="Trier par"
|
||||
className="min-w-[150px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sort order toggle */}
|
||||
{showOrderToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleOrderToggle}
|
||||
aria-label={`Trier par ordre ${localSortOrder === 'asc' ? 'décroissant' : 'croissant'}`}
|
||||
aria-pressed={localSortOrder === 'desc'}
|
||||
>
|
||||
{localSortOrder === 'asc' ? (
|
||||
<ArrowUp className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
8
apps/web/src/components/filters/index.ts
Normal file
8
apps/web/src/components/filters/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* FE-COMP-007: Reusable filter and sort components
|
||||
*/
|
||||
|
||||
export { Filters, type FilterOption, type FiltersProps } from './Filters';
|
||||
export { Sort, type SortOption, type SortProps } from './Sort';
|
||||
export { FilterBar, type FilterBarProps } from './FilterBar';
|
||||
|
||||
Loading…
Reference in a new issue