[FE-COMP-007] fe-comp: Add filter and sort UI components

This commit is contained in:
senke 2025-12-25 11:38:41 +01:00
parent 3f5a4f5df3
commit f4823ca6f5
4 changed files with 247 additions and 3 deletions

View file

@ -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",

View 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>
);
}

View 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>
);
}

View 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';